├── .github └── workflows │ └── build.yml ├── .gitignore ├── .jazzy.yaml ├── .swiftlint.yml ├── CHANGES.md ├── Gemfile ├── LICENSE ├── Makefile ├── Package.resolved ├── Package.swift ├── Podfile ├── README.md ├── Sora.podspec ├── Sora.xcodeproj ├── project.pbxproj └── xcshareddata │ └── xcschemes │ └── Sora.xcscheme ├── Sora ├── AspectRatio.swift ├── AudioCodec.swift ├── AudioMode.swift ├── CameraVideoCapturer.swift ├── Configuration.swift ├── ConnectionState.swift ├── ConnectionTimer.swift ├── DataChannel.swift ├── DeviceInfo.swift ├── Extensions │ ├── Array+Base.swift │ └── RTC+Description.swift ├── ICECandidate.swift ├── ICEServerInfo.swift ├── ICETransportPolicy.swift ├── Info.plist ├── Logger.swift ├── MediaChannel.swift ├── MediaChannelConfiguration.swift ├── MediaStream.swift ├── NativePeerChannelFactory.swift ├── PackageInfo.swift ├── PeerChannel.swift ├── Role.swift ├── Signaling.swift ├── SignalingChannel.swift ├── Sora.h ├── Sora.swift ├── SoraDispatcher.swift ├── SoraError.swift ├── Statistics.swift ├── TLSSecurityPolicy.swift ├── URLSessionWebSocketChannel.swift ├── Utilities.swift ├── VideoCapturer.swift ├── VideoCodec.swift ├── VideoFrame.swift ├── VideoRenderer.swift ├── VideoView.swift ├── VideoView.xib ├── WebRTCConfiguration.swift └── WebSocketChannel.swift ├── SoraTests ├── Info.plist └── SoraTests.swift ├── THANKS └── canary.py /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | paths-ignore: 7 | - 'README.md' 8 | - 'CHANGES.md' 9 | - 'LICENSE' 10 | - 'Sora.podspec' 11 | 12 | jobs: 13 | build: 14 | runs-on: macos-15 15 | env: 16 | XCODE: /Applications/Xcode_16.3.app 17 | XCODE_SDK: iphoneos18.4 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Select Xcode Version 21 | run: sudo xcode-select -s '${{ env.XCODE }}/Contents/Developer' 22 | - name: Show Xcode Version 23 | run: xcodebuild -version 24 | - name: Show CocoaPods Version 25 | run: pod --version 26 | - name: Restore Pods 27 | uses: actions/cache@v4 28 | with: 29 | path: Pods 30 | key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }} 31 | restore-keys: | 32 | ${{ runner.os }}-pods- 33 | - name: Install Dependences 34 | run: | 35 | pod repo update 36 | pod install 37 | if nm ./Pods/WebRTC/WebRTC.xcframework/ios-arm64/WebRTC.framework/WebRTC | grep _kVTVideoEncoderSpecification_RequiredLowLatency >/dev/null 2>&1; then 38 | echo 'Error: Non-public API detected in WebRTC framework.' 39 | exit 1 40 | fi 41 | - name: Build Xcode Project 42 | run: | 43 | set -o pipefail && \ 44 | xcodebuild \ 45 | -workspace 'Sora.xcworkspace' \ 46 | -scheme 'Sora' \ 47 | -sdk ${{ env.XCODE_SDK }} \ 48 | -configuration Release \ 49 | -derivedDataPath build \ 50 | clean build \ 51 | CODE_SIGNING_REQUIRED=NO \ 52 | CODE_SIGN_IDENTITY= \ 53 | PROVISIONING_PROFILE= 54 | - name: Format Lint 55 | run: | 56 | make fmt-lint 57 | - name: Lint 58 | run: | 59 | make lint 60 | slack_notify_succeeded: 61 | needs: [build] 62 | runs-on: ubuntu-24.04 63 | if: success() 64 | steps: 65 | - name: Slack Notification 66 | uses: rtCamp/action-slack-notify@v2 67 | env: 68 | SLACK_CHANNEL: sora-ios-sdk 69 | SLACK_COLOR: good 70 | SLACK_TITLE: SUCCEEDED 71 | SLACK_ICON_EMOJI: ":star-struck:" 72 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 73 | slack_notify_failed: 74 | needs: [build] 75 | runs-on: ubuntu-24.04 76 | if: failure() 77 | steps: 78 | - name: Slack Notification 79 | uses: rtCamp/action-slack-notify@v2 80 | env: 81 | SLACK_CHANNEL: sora-ios-sdk 82 | SLACK_COLOR: danger 83 | SLACK_TITLE: "FAILED" 84 | SLACK_ICON_EMOJI: ":japanese_ogre:" 85 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 86 | release: 87 | if: contains(github.ref, 'tags/v') 88 | needs: [build] 89 | runs-on: macos-latest 90 | steps: 91 | - name: Checkout code 92 | uses: actions/checkout@v4 93 | - name: Create Release 94 | id: create_release 95 | # TODO: https://github.com/softprops/action-gh-release への置き換えを検討する 96 | uses: actions/create-release@v1.1.4 97 | env: 98 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 99 | with: 100 | tag_name: ${{ github.ref }} 101 | release_name: Release ${{ github.ref }} 102 | draft: false 103 | prerelease: false 104 | 105 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata 19 | project.xcworkspace 20 | 21 | ## Other 22 | .DS_Store 23 | *.xccheckout 24 | *.moved-aside 25 | *.xcuserstate 26 | *.xcscmblueprint 27 | *.swp 28 | doc 29 | 30 | ## Obj-C/Swift specific 31 | *.hmap 32 | *.ipa 33 | 34 | ## Playgrounds 35 | timeline.xctimeline 36 | playground.xcworkspace 37 | 38 | # Swift Package Manager 39 | # 40 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 41 | # Packages/ 42 | .build/ 43 | 44 | # CocoaPods 45 | # 46 | # We recommend against adding the Pods directory to your .gitignore. However 47 | # you should judge for yourself, the pros and cons are mentioned at: 48 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 49 | # 50 | Pods/ 51 | 52 | # Carthage 53 | # 54 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 55 | # Carthage/Checkouts 56 | 57 | Carthage/Build 58 | Carthage/Checkouts 59 | examples/SoraApp/Carthage/Build 60 | examples/SoraApp/Carthage/Checkouts 61 | 62 | # fastlane 63 | # 64 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 65 | # screenshots whenever they are needed. 66 | # For more information about the recommended setup visit: 67 | # https://github.com/fastlane/fastlane/blob/master/docs/Gitignore.md 68 | 69 | fastlane/report.xml 70 | fastlane/screenshots 71 | 72 | docs 73 | -------------------------------------------------------------------------------- /.jazzy.yaml: -------------------------------------------------------------------------------- 1 | clean: true 2 | author: Shiguredo Inc. and Masashi Ono (akisute) 3 | author_url: https://sora.shiguredo.jp/ 4 | github_url: https://github.com/shiguredo/sora-ios-sdk/ 5 | github_file_prefix: https://github.com/shiguredo/sora-ios-sdk/blob/master 6 | root_url: https://sora-ios-sdk.shiguredo.jp/ 7 | theme: apple 8 | min_acl: public 9 | sdk: iphoneos 10 | module: Sora 11 | module_version: 2025.1.0 12 | swift_version: 6.0.3 13 | xcodebuild_arguments: 14 | - -parallelizeTargets 15 | - -sdk 16 | - iphoneos18.2 17 | - -workspace 18 | - Sora.xcworkspace 19 | - -scheme 20 | - Sora 21 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | included: 2 | - Sora 3 | excluded: 4 | - Pods 5 | disabled_rules: 6 | - identifier_name 7 | - force_cast 8 | - force_try 9 | - cyclomatic_complexity 10 | - function_body_length 11 | - file_length 12 | - line_length 13 | - type_body_length 14 | - weak_delegate 15 | - opening_brace 16 | - closing_brace 17 | - anonymous_argument_in_multiline_closure 18 | - conditional_returns_on_newline 19 | - multiline_arguments 20 | - multiline_arguments_brackets 21 | - multiline_literal_brackets 22 | - multiline_parameters 23 | - multiline_parameters_brackets 24 | - vertical_parameter_alignment 25 | - vertical_parameter_alignment_on_call 26 | - vertical_whitespace 27 | - vertical_whitespace_between_cases 28 | - vertical_whitespace_closing_braces 29 | - vertical_whitespace_opening_braces 30 | - colon 31 | - comma 32 | - comment_spacing 33 | - trailing_comma 34 | - trailing_newline 35 | - trailing_whitespace 36 | - closure_parameter_position 37 | - closure_end_indentation 38 | - closure_spacing 39 | - for_where 40 | - large_tuple 41 | - todo -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | source "https://rubygems.org" 3 | 4 | gem 'cocoapods' , '1.15.2' 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: fmt fmt-lint lint 2 | 3 | # すべてを実行 4 | all: fmt fmt-lint lint 5 | 6 | # swift-format 7 | fmt: 8 | swift format --in-place --recursive Sora SoraTests 9 | 10 | # swift-format lint 11 | fmt-lint: 12 | swift format lint --strict --parallel --recursive Sora SoraTests 13 | 14 | # SwiftLint 15 | lint: 16 | swift package plugin --allow-writing-to-package-directory swiftlint --fix . 17 | swift package plugin --allow-writing-to-package-directory swiftlint --strict . 18 | 19 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "SwiftLintPlugins", 6 | "repositoryURL": "https://github.com/SimplyDanny/SwiftLintPlugins", 7 | "state": { 8 | "branch": null, 9 | "revision": "7a3d77f3dd9f91d5cea138e52c20cfceabf352de", 10 | "version": "0.58.2" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | 3 | import Foundation 4 | import PackageDescription 5 | 6 | let libwebrtcVersion = "m136.7103.0.0" 7 | 8 | let package = Package( 9 | name: "Sora", 10 | platforms: [.iOS(.v14)], 11 | products: [ 12 | .library(name: "Sora", targets: ["Sora"]), 13 | .library(name: "WebRTC", targets: ["WebRTC"]), 14 | ], 15 | dependencies: [ 16 | // 開発用依存関係 17 | // SwfitLint 公式で推奨されている SwfitLintPlugins を利用する 18 | .package(url: "https://github.com/SimplyDanny/SwiftLintPlugins", from: "0.58.2") 19 | ], 20 | targets: [ 21 | .binaryTarget( 22 | name: "WebRTC", 23 | url: "https://github.com/shiguredo-webrtc-build/webrtc-build/releases/download/\(libwebrtcVersion)/WebRTC.xcframework.zip", 24 | checksum: "4a44fbb76617638bb4bd972db07de298b30ebcd4aea422e3cca70845e1d34238" 25 | ), 26 | .target( 27 | name: "Sora", 28 | dependencies: ["WebRTC"], 29 | path: "Sora", 30 | exclude: ["Info.plist"], 31 | resources: [.process("VideoView.xib")] 32 | ), 33 | ] 34 | ) 35 | -------------------------------------------------------------------------------- /Podfile: -------------------------------------------------------------------------------- 1 | source 'https://cdn.cocoapods.org/' 2 | source 'https://github.com/shiguredo/sora-ios-sdk-specs.git' 3 | 4 | platform :ios, '14.0' 5 | 6 | target 'Sora' do 7 | use_frameworks! 8 | pod 'WebRTC', '132.6834.5.7' 9 | end 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sora iOS SDK 2 | 3 | [![libwebrtc](https://img.shields.io/badge/libwebrtc-136.7103-blue.svg)](https://chromium.googlesource.com/external/webrtc/+/branch-heads/7103) 4 | [![GitHub tag](https://img.shields.io/github/tag/shiguredo/sora-ios-sdk.svg)](https://github.com/shiguredo/sora-ios-sdk) 5 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 6 | 7 | Sora iOS SDK は [WebRTC SFU Sora](https://sora.shiguredo.jp) の iOS クライアントアプリケーションを開発するためのライブラリです。 8 | 9 | ## About Shiguredo's open source software 10 | 11 | We will not respond to PRs or issues that have not been discussed on Discord. Also, Discord is only available in Japanese. 12 | 13 | Please read https://github.com/shiguredo/oss before use. 14 | 15 | ## 時雨堂のオープンソースソフトウェアについて 16 | 17 | 利用前に https://github.com/shiguredo/oss をお読みください。 18 | 19 | ## システム条件 20 | 21 | - iOS 14 以降 22 | - アーキテクチャ arm64 (シミュレーターの動作は未保証) 23 | - Xcode 16.2 24 | - Swift 5.10 25 | - CocoaPods 1.15.2 以降 26 | - WebRTC SFU Sora 2024.2.0 以降 27 | 28 | Xcode と Swift のバージョンによっては、 CocoaPods で取得できるバイナリに互換性がない可能性があります。詳しくはドキュメントを参照してください。 29 | 30 | ## サンプル 31 | 32 | - [クイックスタート](https://github.com/shiguredo/sora-ios-sdk-quickstart) 33 | - [サンプル集](https://github.com/shiguredo/sora-ios-sdk-samples) 34 | 35 | ## ドキュメント 36 | 37 | [Sora iOS SDK ドキュメント — Sora iOS SDK](https://sora-ios-sdk.shiguredo.jp/) 38 | 39 | ## 有償での優先実装 40 | 41 | - 帯域幅制限時に解像度またはフレームレートのどちらを維持するか指定できるようにする機能 42 | - 企業名非公開 43 | 44 | ## 有償での優先実装が可能な機能一覧 45 | 46 | **詳細は Discord またはメールにてお問い合わせください** 47 | 48 | - オープンソースでの公開が前提 49 | - 可能であれば企業名の公開 50 | - 公開が難しい場合は `企業名非公開` と書かせていただきます 51 | 52 | ### 機能 53 | 54 | - 音声出力先変更機能 55 | 56 | ## ライセンス 57 | 58 | Apache License 2.0 59 | 60 | ``` 61 | Copyright 2017-2024, Shiguredo Inc. 62 | Copyright 2017-2023, SUZUKI Tetsuya (Original Author) 63 | 64 | Licensed under the Apache License, Version 2.0 (the "License"); 65 | you may not use this file except in compliance with the License. 66 | You may obtain a copy of the License at 67 | 68 | http://www.apache.org/licenses/LICENSE-2.0 69 | 70 | Unless required by applicable law or agreed to in writing, software 71 | distributed under the License is distributed on an "AS IS" BASIS, 72 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 73 | See the License for the specific language governing permissions and 74 | limitations under the License. 75 | ``` 76 | -------------------------------------------------------------------------------- /Sora.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = "Sora" 3 | s.version = "2025.2.0-canary.1" 4 | s.summary = "Sora iOS SDK" 5 | s.description = <<-DESC 6 | A library to develop Sora client applications. 7 | DESC 8 | s.homepage = "https://github.com/shiguredo/sora-ios-sdk" 9 | s.license = { :type => "Apache License, Version 2.0" } 10 | s.authors = { "Shiguredo Inc." => "https://shiguredo.jp/" } 11 | s.platform = :ios, "14.0" 12 | s.source = { 13 | :git => "https://github.com/shiguredo/sora-ios-sdk.git", 14 | :tag => s.version 15 | } 16 | s.source_files = "Sora/**/*.swift" 17 | s.resources = ['Sora/*.xib'] 18 | s.dependency "WebRTC", '132.6834.5.7' 19 | s.pod_target_xcconfig = { 20 | 'ARCHS' => 'arm64', 21 | 'ARCHS[config=Debug]' => '$(ARCHS_STANDARD)' 22 | } 23 | end 24 | -------------------------------------------------------------------------------- /Sora.xcodeproj/xcshareddata/xcschemes/Sora.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 49 | 50 | 51 | 52 | 53 | 54 | 64 | 65 | 71 | 72 | 73 | 74 | 75 | 76 | 82 | 83 | 89 | 90 | 91 | 92 | 94 | 95 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /Sora/AspectRatio.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// :nodoc: 4 | public enum AspectRatio { 5 | case standard // 4:3 6 | case wide // 16:9 7 | 8 | public func height(forWidth width: CGFloat) -> CGFloat { 9 | switch self { 10 | case .standard: 11 | return width / 4 * 3 12 | case .wide: 13 | return width / 16 * 9 14 | } 15 | } 16 | 17 | public func size(forWidth width: CGFloat) -> CGSize { 18 | CGSize(width: width, height: height(forWidth: width)) 19 | } 20 | 21 | public func scale(size: CGSize) -> CGSize { 22 | self.size(forWidth: size.width) 23 | } 24 | } 25 | 26 | private var aspectRatioTable: PairTable = 27 | PairTable( 28 | name: "AspectRatio", 29 | pairs: [ 30 | ("standard", .standard), 31 | ("wide", .wide), 32 | ]) 33 | 34 | /// :nodoc: 35 | extension AspectRatio: Codable { 36 | public init(from decoder: Decoder) throws { 37 | self = try aspectRatioTable.decode(from: decoder) 38 | } 39 | 40 | public func encode(to encoder: Encoder) throws { 41 | try aspectRatioTable.encode(self, to: encoder) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sora/AudioCodec.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | private let descriptionTable: PairTable = 4 | PairTable( 5 | name: "AudioCodec", 6 | pairs: [ 7 | ("default", .default), 8 | ("OPUS", .opus), 9 | ("PCMU", .pcmu), 10 | ]) 11 | 12 | /// 音声コーデックを表します。 13 | public enum AudioCodec { 14 | /** 15 | サーバーが指定するデフォルトのコーデック。 16 | 現在のデフォルトのコーデックは Opus です。 17 | */ 18 | case `default` 19 | 20 | /// Opus 21 | case opus 22 | 23 | /// PCMU 24 | case pcmu 25 | } 26 | 27 | extension AudioCodec: CustomStringConvertible { 28 | /// 文字列表現を返します。 29 | public var description: String { 30 | descriptionTable.left(other: self)! 31 | } 32 | } 33 | 34 | /// :nodoc: 35 | extension AudioCodec: Codable { 36 | public init(from decoder: Decoder) throws { 37 | self = try descriptionTable.decode(from: decoder) 38 | } 39 | 40 | public func encode(to encoder: Encoder) throws { 41 | try descriptionTable.encode(self, to: encoder) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sora/AudioMode.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import Foundation 3 | 4 | /// 音声モード。 5 | /// ``AVAudioSession`` の音声モードと音声カテゴリを変更します。 6 | /// 詳細な設定を行いたい場合は ``AVAudioSession`` を使用して下さい。 7 | /// 8 | /// 音声カテゴリのオプションは次の値が指定されます: 9 | /// 10 | /// - ``allowBluetooth`` 11 | /// - ``allowBluetoothA2DP`` 12 | /// - ``allowAirPlay`` 13 | public enum AudioMode { 14 | /** 15 | * デフォルト。 16 | * ``AVAudioSession`` の音声モードを ``default`` に変更します。 17 | * 音声カテゴリを ``category`` の値に変更します。 18 | * 音声出力先の変更は、指定した音声出力先に音声カテゴリが対応している場合のみ有効です。 19 | * 詳細は ``AVAudioSession`` のドキュメントを参照して下さい。 20 | * 21 | * - parameter category: 音声カテゴリ 22 | * - parameter output: 音声出力先 23 | */ 24 | case `default`(category: AVAudioSession.Category, output: AudioOutput) 25 | 26 | /** 27 | * ビデオチャット。 28 | * ``AVAudioSession`` の音声モードを ``videoChat`` に変更します。 29 | * 音声カテゴリを ``playAndRecord`` に変更します。 30 | * 音声はスピーカーから出力されます。 31 | */ 32 | case videoChat 33 | 34 | /** 35 | * ボイスチャット。 36 | * ``AVAudioSession`` の音声モードを ``voiceChat`` に変更します。 37 | * 音声カテゴリを ``playAndRecord`` に変更します。 38 | * 39 | * - parameter output: 音声出力先 40 | */ 41 | case voiceChat(output: AudioOutput) 42 | } 43 | 44 | /// 音声出力先 45 | public enum AudioOutput { 46 | /// デフォルト。端末の状態に依存します。 47 | case `default` 48 | 49 | /// スピーカー 50 | case speaker 51 | 52 | var portOverride: AVAudioSession.PortOverride { 53 | switch self { 54 | case .default: 55 | return .none 56 | case .speaker: 57 | return .speaker 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sora/CameraVideoCapturer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebRTC 3 | 4 | /// 解像度やフレームレートなどの設定は `start` 実行時に指定します。 5 | /// カメラはパブリッシャーまたはグループの接続時に自動的に起動 (起動済みなら再起動) されます。 6 | /// 7 | /// カメラの設定を変更したい場合は、 `change` を実行します。 8 | public final class CameraVideoCapturer { 9 | // MARK: インスタンスの取得 10 | 11 | /// 利用可能なデバイスのリスト 12 | /// RTCCameraVideoCapturer.captureDevices を返します。 13 | public static var devices: [AVCaptureDevice] { RTCCameraVideoCapturer.captureDevices() } 14 | 15 | /// 前面のカメラに対応するデバイス 16 | public private(set) static var front: CameraVideoCapturer? = { 17 | if let device = device(for: .front) { 18 | return CameraVideoCapturer(device: device) 19 | } else { 20 | return nil 21 | } 22 | }() 23 | 24 | /// 背面のカメラに対応するデバイス 25 | public private(set) static var back: CameraVideoCapturer? = { 26 | if let device = device(for: .back) { 27 | return CameraVideoCapturer(device: device) 28 | } else { 29 | return nil 30 | } 31 | }() 32 | 33 | /// 起動中のデバイス 34 | public private(set) static var current: CameraVideoCapturer? 35 | 36 | /// RTCCameraVideoCapturer が保持している AVCaptureSession 37 | public var captureSession: AVCaptureSession { native.captureSession } 38 | 39 | /// 指定したカメラ位置にマッチした最初のデバイスを返します。 40 | /// captureDevice(for: .back) とすれば背面カメラを取得できます。 41 | public static func device(for position: AVCaptureDevice.Position) -> AVCaptureDevice? { 42 | for device in CameraVideoCapturer.devices { 43 | switch (device.position, position) { 44 | case (.front, .front), (.back, .back): 45 | return device 46 | default: 47 | break 48 | } 49 | } 50 | return nil 51 | } 52 | 53 | /// 指定された設定に最も近い AVCaptureDevice.Format? を返します。 54 | public static func format( 55 | width: Int32, height: Int32, for device: AVCaptureDevice, frameRate: Int? = nil 56 | ) -> AVCaptureDevice.Format? { 57 | func calcDiff(_ targetWidth: Int32, _ targetHeight: Int32, _ format: AVCaptureDevice.Format) 58 | -> Int32 59 | { 60 | let dimension = CMVideoFormatDescriptionGetDimensions(format.formatDescription) 61 | return abs(targetWidth - dimension.width) + abs(targetHeight - dimension.height) 62 | } 63 | 64 | let supportedFormats = RTCCameraVideoCapturer.supportedFormats(for: device) 65 | 66 | // 指定された解像度に近いフォーマットを絞り込む 67 | guard let diff = supportedFormats.map({ calcDiff(width, height, $0) }).min() else { 68 | return nil 69 | } 70 | let formats = supportedFormats.filter { calcDiff(width, height, $0) == diff } 71 | guard !formats.isEmpty else { 72 | return nil 73 | } 74 | 75 | // この関数の引数に frameRate が指定された場合、フレームレートも考慮する 76 | guard let frameRate else { 77 | return formats.first 78 | } 79 | return formats.filter { 80 | $0.videoSupportedFrameRateRanges.contains(where: { 81 | Int($0.minFrameRate) <= frameRate && frameRate <= Int($0.maxFrameRate) 82 | }) 83 | }.first ?? formats.first 84 | } 85 | 86 | /// 指定された FPS 値をサポートしているレンジが存在すれば、その値を返します。 87 | /// 存在しない場合はサポートされているレンジの中で最大の値を返します。 88 | public static func maxFrameRate(_ frameRate: Int, for format: AVCaptureDevice.Format) -> Int? { 89 | if format.videoSupportedFrameRateRanges.contains(where: { 90 | Int($0.minFrameRate) <= frameRate && frameRate <= Int($0.maxFrameRate) 91 | }) { 92 | return frameRate 93 | } 94 | return format.videoSupportedFrameRateRanges 95 | .max { $0.maxFrameRate < $1.maxFrameRate } 96 | .map { Int($0.maxFrameRate) } 97 | } 98 | 99 | /// 引数に指定された capturer を停止し、反対の position を持つ CameraVideoCapturer を起動します。 100 | /// CameraVideoCapturer の起動には、 capturer と近い設定のフォーマットとフレームレートが利用されます。 101 | /// また、起動された CameraVideoCapturer には capturer の保持する MediaStream が設定されます。 102 | public static func flip( 103 | _ capturer: CameraVideoCapturer, completionHandler: @escaping ((Error?) -> Void) 104 | ) { 105 | guard let format = capturer.format else { 106 | completionHandler(SoraError.cameraError(reason: "format should not be nil")) 107 | return 108 | } 109 | 110 | // 反対の position を持つ CameraVideoCapturer を取得します。 111 | guard let flip: CameraVideoCapturer = (capturer.device.position == .front ? .back : .front) 112 | else { 113 | let name = capturer.device.position == .front ? "back" : "front" 114 | completionHandler(SoraError.cameraError(reason: "\(name) camera is not found")) 115 | return 116 | } 117 | 118 | let dimension = CMVideoFormatDescriptionGetDimensions(format.formatDescription) 119 | guard 120 | let format = CameraVideoCapturer.format( 121 | width: dimension.width, 122 | height: dimension.height, 123 | for: flip.device, 124 | frameRate: capturer.frameRate!) 125 | else { 126 | completionHandler( 127 | SoraError.cameraError( 128 | reason: "CameraVideoCapturer.format failed: suitable format is not found")) 129 | return 130 | } 131 | 132 | guard let frameRate = CameraVideoCapturer.maxFrameRate(capturer.frameRate!, for: format) 133 | else { 134 | completionHandler( 135 | SoraError.cameraError( 136 | reason: 137 | "CameraVideoCapturer.maxFramerate failed: suitable frameRate is not found")) 138 | return 139 | } 140 | 141 | capturer.stop { error in 142 | guard error == nil else { 143 | completionHandler(SoraError.cameraError(reason: "CameraVideoCapturer.stop failed")) 144 | return 145 | } 146 | flip.start(format: format, frameRate: frameRate) { error in 147 | guard error == nil else { 148 | completionHandler( 149 | SoraError.cameraError(reason: "CameraVideoCapturer.start failed")) 150 | return 151 | } 152 | flip.stream = capturer.stream 153 | completionHandler(nil) 154 | } 155 | } 156 | } 157 | 158 | // MARK: プロパティ 159 | 160 | /// 出力先のストリーム 161 | public var stream: MediaStream? 162 | 163 | /// カメラが起動中であれば ``true`` 164 | public private(set) var isRunning: Bool = false 165 | 166 | /// イベントハンドラ 167 | public static var handlers = CameraVideoCapturerHandlers() 168 | 169 | /// カメラの位置 170 | public var position: AVCaptureDevice.Position { 171 | device.position 172 | } 173 | 174 | /// 使用中のデバイス 175 | public var device: AVCaptureDevice 176 | 177 | /// フレームレート 178 | public private(set) var frameRate: Int? 179 | 180 | /// フォーマット 181 | public private(set) var format: AVCaptureDevice.Format? 182 | 183 | private var native: RTCCameraVideoCapturer! 184 | private var nativeDelegate: CameraVideoCapturerDelegate! 185 | 186 | /// 引数に指定した device を利用して CameraVideoCapturer を初期化します。 187 | /// 自動的に初期化される静的プロパティ、 front/back を定義しています。 188 | /// 上記以外のデバイスを利用したい場合のみ CameraVideoCapturer を生成してください。 189 | public init(device: AVCaptureDevice) { 190 | self.device = device 191 | nativeDelegate = CameraVideoCapturerDelegate(cameraVideoCapturer: self) 192 | native = RTCCameraVideoCapturer(delegate: nativeDelegate) 193 | } 194 | 195 | // MARK: カメラの操作 196 | 197 | /// カメラを起動します。 198 | /// 199 | /// このメソッドを実行すると、 `UIDevice` の 200 | /// `beginGeneratingDeviceOrientationNotifications()` が実行されます。 201 | /// `beginGeneratingDeviceOrientationNotifications()` または 202 | /// `endGeneratingDeviceOrientationNotifications()` を使う際は 203 | /// 必ず対に実行するように注意してください。 204 | public func start( 205 | format: AVCaptureDevice.Format, 206 | frameRate: Int, 207 | completionHandler: @escaping ((Error?) -> Void) 208 | ) { 209 | guard isRunning == false else { 210 | completionHandler(SoraError.cameraError(reason: "isRunning should be false")) 211 | return 212 | } 213 | 214 | native.startCapture( 215 | with: device, 216 | format: format, 217 | fps: frameRate 218 | ) { [self] (error: Error?) in 219 | guard error == nil else { 220 | completionHandler(error) 221 | return 222 | } 223 | Logger.debug( 224 | type: .cameraVideoCapturer, 225 | message: "succeeded to start \(device) with \(format), \(frameRate)fps") 226 | 227 | // start が成功した際の処理 228 | self.format = format 229 | self.frameRate = frameRate 230 | isRunning = true 231 | CameraVideoCapturer.current = self 232 | completionHandler(nil) 233 | CameraVideoCapturer.handlers.onStart?(self) 234 | } 235 | } 236 | 237 | /// カメラを停止します。 238 | /// 239 | /// このメソッドを実行すると、 `UIDevice` の 240 | /// `endGeneratingDeviceOrientationNotifications()` が実行されます。 241 | /// `beginGeneratingDeviceOrientationNotifications()` または 242 | /// `endGeneratingDeviceOrientationNotifications()` を使う際は 243 | /// 必ず対に実行するように注意してください。 244 | public func stop(completionHandler: @escaping ((Error?) -> Void)) { 245 | guard isRunning else { 246 | completionHandler(SoraError.cameraError(reason: "isRunning should be true")) 247 | return 248 | } 249 | 250 | native.stopCapture { [self] in 251 | Logger.debug( 252 | type: .cameraVideoCapturer, 253 | message: "succeeded to stop \(String(describing: device))") 254 | 255 | // stop が成功した際の処理 256 | isRunning = false 257 | CameraVideoCapturer.current = nil 258 | completionHandler(nil) 259 | CameraVideoCapturer.handlers.onStop?(self) 260 | } 261 | } 262 | 263 | /// 停止前と同じ設定でカメラを再起動します。 264 | public func restart(completionHandler: @escaping ((Error?) -> Void)) { 265 | guard let format else { 266 | completionHandler(SoraError.cameraError(reason: "failed to access format")) 267 | return 268 | } 269 | 270 | guard let frameRate else { 271 | completionHandler(SoraError.cameraError(reason: "failed to access frame rate")) 272 | return 273 | } 274 | 275 | if isRunning { 276 | stop { [self] (error: Error?) in 277 | guard error == nil else { 278 | completionHandler(error) 279 | return 280 | } 281 | 282 | start( 283 | format: format, 284 | frameRate: frameRate 285 | ) { (error: Error?) in 286 | guard error == nil else { 287 | completionHandler(error) 288 | return 289 | } 290 | 291 | Logger.debug(type: .cameraVideoCapturer, message: "succeeded to restart") 292 | completionHandler(nil) 293 | } 294 | } 295 | } else { 296 | start( 297 | format: format, 298 | frameRate: frameRate 299 | ) { (error: Error?) in 300 | guard error == nil else { 301 | completionHandler(error) 302 | return 303 | } 304 | 305 | Logger.debug(type: .cameraVideoCapturer, message: "succeeded to restart") 306 | completionHandler(nil) 307 | } 308 | } 309 | } 310 | 311 | /// カメラを停止後、指定されたパラメーターで起動します。 312 | public func change( 313 | format: AVCaptureDevice.Format? = nil, frameRate: Int? = nil, 314 | completionHandler: @escaping ((Error?) -> Void) 315 | ) { 316 | guard isRunning else { 317 | completionHandler(SoraError.cameraError(reason: "isRunning should be true")) 318 | return 319 | } 320 | 321 | guard let format = (format ?? self.format) else { 322 | completionHandler(SoraError.cameraError(reason: "failed to access format")) 323 | return 324 | } 325 | 326 | guard let frameRate = (frameRate ?? self.frameRate) else { 327 | completionHandler(SoraError.cameraError(reason: "failed to access frame rate")) 328 | return 329 | } 330 | 331 | stop { [self] (error: Error?) in 332 | guard error == nil else { 333 | completionHandler(error) 334 | return 335 | } 336 | 337 | start(format: format, frameRate: frameRate) { (error: Error?) in 338 | guard error == nil else { 339 | completionHandler(error) 340 | return 341 | } 342 | 343 | Logger.debug(type: .cameraVideoCapturer, message: "succeeded to change") 344 | completionHandler(nil) 345 | } 346 | } 347 | } 348 | } 349 | 350 | /// `CameraVideoCapturer` の設定を表すオブジェクトです。 351 | public struct CameraSettings: CustomStringConvertible { 352 | /// デフォルトの設定。 353 | public static let `default` = CameraSettings() 354 | 355 | /// `CameraVideoCapturer` で使用する映像解像度を表すenumです。 356 | public enum Resolution { 357 | /// QVGA, 320x240 358 | case qvga240p 359 | 360 | /// VGA, 640x480 361 | case vga480p 362 | 363 | /// qHD540p, 960x540 364 | case qhd540p 365 | 366 | /// HD 720p, 1280x720 367 | case hd720p 368 | 369 | /// HD 1080p, 1920x1080 370 | case hd1080p 371 | 372 | /// UHD 2160p, 3840x2160 373 | case uhd2160p 374 | 375 | /// UHD 3024p, 4032x3024 376 | case uhd3024p 377 | 378 | /// 横方向のピクセル数を返します。 379 | public var width: Int32 { 380 | switch self { 381 | case .qvga240p: return 320 382 | case .vga480p: return 640 383 | case .qhd540p: return 960 384 | case .hd720p: return 1280 385 | case .hd1080p: return 1920 386 | case .uhd2160p: return 3840 387 | case .uhd3024p: return 4032 388 | } 389 | } 390 | 391 | /// 縦方向のピクセル数を返します。 392 | public var height: Int32 { 393 | switch self { 394 | case .qvga240p: return 240 395 | case .vga480p: return 480 396 | case .qhd540p: return 540 397 | case .hd720p: return 720 398 | case .hd1080p: return 1080 399 | case .uhd2160p: return 2160 400 | case .uhd3024p: return 3024 401 | } 402 | } 403 | } 404 | 405 | /// 希望する映像解像度。 406 | /// 407 | /// 可能な限りここで指定された値が尊重されますが、 408 | /// 例えばデバイス側が対応していない値が指定された場合などは、 409 | /// ここで指定された値と異なる値が実際には使用されることがあります。 410 | public var resolution: Resolution 411 | 412 | /// 希望する映像フレームレート(Frames Per Second)。 413 | /// 414 | /// 可能な限りここで指定された値が尊重されますが、 415 | /// 例えばデバイス側が対応していない値が指定された場合などは、 416 | /// ここで指定された値と異なる値が実際には使用されることがあります。 417 | public var frameRate: Int 418 | 419 | /// カメラの位置 420 | public var position: AVCaptureDevice.Position 421 | 422 | /// カメラ起動の有無 423 | public var isEnabled: Bool 424 | 425 | /// 文字列表現を返します。 426 | public var description: String { 427 | "\(resolution), \(frameRate)fps" 428 | } 429 | 430 | /// 初期化します。 431 | /// 432 | /// - parameter resolution: 解像度 433 | /// - parameter frameRate: フレームレート 434 | /// - parameter position: 配信開始時のカメラの位置 435 | /// - parameter isEnabled: カメラの起動の有無 436 | public init( 437 | resolution: Resolution = .hd720p, frameRate: Int = 30, 438 | position: AVCaptureDevice.Position = .front, isEnabled: Bool = true 439 | ) { 440 | self.resolution = resolution 441 | self.frameRate = frameRate 442 | self.position = position 443 | self.isEnabled = isEnabled 444 | } 445 | } 446 | 447 | // MARK: - 448 | 449 | private class CameraVideoCapturerDelegate: NSObject, RTCVideoCapturerDelegate { 450 | weak var cameraVideoCapturer: CameraVideoCapturer! 451 | 452 | init(cameraVideoCapturer: CameraVideoCapturer) { 453 | self.cameraVideoCapturer = cameraVideoCapturer 454 | } 455 | 456 | func capturer(_ capturer: RTCVideoCapturer, didCapture nativeFrame: RTCVideoFrame) { 457 | let frame = VideoFrame.native(capturer: capturer, frame: nativeFrame) 458 | if let editedFrame = CameraVideoCapturer.handlers.onCapture?(cameraVideoCapturer, frame) { 459 | cameraVideoCapturer.stream?.send(videoFrame: editedFrame) 460 | } else { 461 | cameraVideoCapturer.stream?.send(videoFrame: frame) 462 | } 463 | } 464 | } 465 | 466 | // MARK: - 467 | 468 | private var resolutionTable: PairTable = 469 | PairTable( 470 | name: "CameraVideoCapturer.Settings.Resolution", 471 | pairs: [ 472 | ("qvga240p", .qvga240p), 473 | ("vga480p", .vga480p), 474 | ("hd720p", .hd720p), 475 | ("hd1080p", .hd1080p), 476 | ]) 477 | 478 | /// :nodoc: 479 | extension CameraSettings.Resolution: Codable { 480 | public init(from decoder: Decoder) throws { 481 | self = try resolutionTable.decode(from: decoder) 482 | } 483 | 484 | public func encode(to encoder: Encoder) throws { 485 | try resolutionTable.encode(self, to: encoder) 486 | } 487 | } 488 | 489 | /// CameraVideoCapturer のイベントハンドラです。 490 | public class CameraVideoCapturerHandlers { 491 | /// 生成された映像フレームを受け取ります。 492 | /// 返した映像フレームがストリームに渡されます。 493 | public var onCapture: ((CameraVideoCapturer, VideoFrame) -> VideoFrame)? 494 | 495 | /// CameraVideoCapturer.start(format:frameRate:completionHandler) 内で completionHandler の後に実行されます。 496 | /// そのため、 CameraVideoCapturer.restart(completionHandler) のように、 stop の completionHandler で start を実行する場合、 497 | /// イベントハンドラは onStart, onStop の順に呼び出されることに注意してください。 498 | public var onStart: ((CameraVideoCapturer) -> Void)? 499 | 500 | /// CameraVideoCapturer.stop(completionHandler) 内で completionHandler の後に実行されます。 501 | /// 注意点については、 onStart のコメントを参照してください。 502 | public var onStop: ((CameraVideoCapturer) -> Void)? 503 | 504 | /// CameraVideoCapturer のイベントハンドラを初期化します。 505 | public init() {} 506 | } 507 | -------------------------------------------------------------------------------- /Sora/Configuration.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebRTC 3 | 4 | // MARK: デフォルト値 5 | 6 | private let defaultPublisherStreamId: String = "mainStream" 7 | private let defaultPublisherVideoTrackId: String = "mainVideo" 8 | private let defaultPublisherAudioTrackId: String = "mainAudio" 9 | 10 | /// プロキシに関する設定です 11 | public struct Proxy: CustomStringConvertible { 12 | /// プロキシのホスト 13 | let host: String 14 | 15 | /// ポート 16 | let port: Int 17 | 18 | /// username 19 | /// プロキシに認証がかかっている場合に指定する 20 | let username: String? 21 | 22 | /// password 23 | /// プロキシに認証がかかっている場合に指定する 24 | let password: String? 25 | 26 | /// エージェント 27 | var agent: String = "Sora iOS SDK \(SDKInfo.version)" 28 | 29 | /// 初期化します。 30 | /// - parameter host: プロキシのホスト名 31 | /// - parameter port: プロキシのポート 32 | /// - parameter agent: プロキシのエージェント 33 | /// - parameter username: プロキシ認証に使用するユーザー名 34 | /// - parameter password: プロキシ認証に使用するパスワード 35 | public init( 36 | host: String, port: Int, agent: String? = nil, username: String? = nil, 37 | password: String? = nil 38 | ) { 39 | self.host = host 40 | self.port = port 41 | 42 | self.username = username 43 | self.password = password 44 | 45 | if let agent { 46 | self.agent = agent 47 | } 48 | } 49 | 50 | /// 文字列表現を返します。 51 | public var description: String { 52 | "host=\(host) port=\(port) agent=\(agent) username=\(username ?? "") password=\(String(repeating: "*", count: password?.count ?? 0))" 53 | } 54 | } 55 | 56 | /// クライアントに関する設定です。 57 | public struct Configuration { 58 | // MARK: - 接続に関する設定 59 | 60 | /// スポットライトの設定 61 | public enum Spotlight { 62 | /// 有効 63 | case enabled 64 | 65 | /// 無効 66 | case disabled 67 | } 68 | 69 | /// シグナリングに利用する URL の候補 70 | public var urlCandidates: [URL] 71 | 72 | /// チャネル ID 73 | public var channelId: String 74 | 75 | /// クライアント ID 76 | public var clientId: String? 77 | 78 | /// バンドル ID 79 | public var bundleId: String? 80 | /// ロール 81 | public var role: Role 82 | 83 | /// マルチストリームの可否 84 | @available( 85 | *, deprecated, 86 | message: """ 87 | レガシーストリーム機能は 2025 年 6 月リリースの Sora にて廃止します。そのため multistreamEnabled の使用は非推奨です。 88 | このプロパティは 2027 年中に廃止予定です。 89 | """ 90 | ) 91 | public var multistreamEnabled: Bool? 92 | 93 | /// :nodoc: 94 | var isMultistream: Bool { 95 | switch role { 96 | default: 97 | return multistreamEnabled ?? true 98 | } 99 | } 100 | 101 | /// :nodoc: 102 | var isSender: Bool { 103 | switch role { 104 | case .sendonly, .sendrecv: 105 | return true 106 | default: 107 | return false 108 | } 109 | } 110 | 111 | /// 接続試行中のタイムアウト (秒) 。 112 | /// 指定した時間内に接続が成立しなければ接続試行を中止します。 113 | public var connectionTimeout: Int = 30 114 | 115 | /// 映像コーデック。デフォルトは `.default` です。 116 | public var videoCodec: VideoCodec = .default 117 | 118 | /// 映像ビットレート。デフォルトは無指定です。 119 | public var videoBitRate: Int? 120 | 121 | /// カメラの設定 122 | public var cameraSettings = CameraSettings.default 123 | 124 | /// 音声コーデック。デフォルトは `.default` です。 125 | public var audioCodec: AudioCodec = .default 126 | 127 | /// 音声ビットレート。デフォルトは無指定です。 128 | public var audioBitRate: Int? 129 | 130 | /// 映像の可否。 `true` であれば映像を送受信します。 131 | /// デフォルトは `true` です。 132 | public var videoEnabled: Bool = true 133 | 134 | /// 音声の可否。 `true` であれば音声を送受信します。 135 | /// デフォルトは `true` です。 136 | public var audioEnabled: Bool = true 137 | 138 | /// サイマルキャストの可否。 `true` であればサイマルキャストを有効にします。 139 | public var simulcastEnabled: Bool = false 140 | 141 | /// サイマルキャストでの映像の種類。 142 | /// ロールが `.sendrecv` または `.recvonly` のときのみ有効です。 143 | public var simulcastRid: SimulcastRid? 144 | 145 | /// スポットライトの可否 146 | /// 詳しくは Sora のスポットライト機能を参照してください。 147 | public var spotlightEnabled: Spotlight = .disabled 148 | 149 | /// スポットライトの対象人数 150 | public var spotlightNumber: Int? 151 | 152 | /// スポットライト機能でフォーカスした場合の映像の種類 153 | public var spotlightFocusRid: SpotlightRid = .unspecified 154 | 155 | /// スポットライト機能でフォーカスしていない場合の映像の種類 156 | public var spotlightUnfocusRid: SpotlightRid = .unspecified 157 | 158 | /// WebRTC に関する設定 159 | public var webRTCConfiguration = WebRTCConfiguration() 160 | 161 | /// `connect` シグナリングに含めるメタデータ 162 | public var signalingConnectMetadata: Encodable? 163 | 164 | /// `connect` シグナリングに含める通知用のメタデータ 165 | public var signalingConnectNotifyMetadata: Encodable? 166 | 167 | /// シグナリングにおける DataChannel の利用可否。 168 | /// `true` の場合、接続確立後のシグナリングを DataChannel 経由で行います。 169 | public var dataChannelSignaling: Bool? 170 | 171 | /// メッセージング機能で利用する DataChannel の設定 172 | public var dataChannels: Any? 173 | 174 | /// DataChannel 経由のシグナリングを利用している際に、 WebSocket が切断されても Sora との接続を継続するためのフラグ。 175 | /// 詳細: https://sora-doc.shiguredo.jp/DATA_CHANNEL_SIGNALING#07c227 176 | public var ignoreDisconnectWebSocket: Bool? 177 | 178 | /// 音声ストリーミング機能で利用する言語コード 179 | public var audioStreamingLanguageCode: String? 180 | 181 | /// プロキシに関する設定 182 | public var proxy: Proxy? 183 | 184 | /// 転送フィルターの設定 185 | /// 186 | /// この項目は 2025 年 12 月リリース予定の Sora にて廃止されます 187 | public var forwardingFilter: ForwardingFilter? 188 | 189 | /// リスト形式の転送フィルターの設定 190 | public var forwardingFilters: [ForwardingFilter]? 191 | 192 | /// VP9 向け映像コーデックパラメーター 193 | public var videoVp9Params: Encodable? 194 | 195 | /// AV1 向け映像コーデックパラメーター 196 | public var videoAv1Params: Encodable? 197 | 198 | /// H264 向け映像コーデックパラメーター 199 | public var videoH264Params: Encodable? 200 | 201 | // MARK: - イベントハンドラ 202 | 203 | /// WebSocket チャネルに関するイベントハンドラ 204 | public var webSocketChannelHandlers = WebSocketChannelHandlers() 205 | 206 | /// メディアチャネルに関するイベントハンドラ 207 | public var mediaChannelHandlers = MediaChannelHandlers() 208 | 209 | // MARK: パブリッシャーに関する設定 210 | 211 | /// パブリッシャーのストリームの ID です。 212 | /// 通常、指定する必要はありません。 213 | public var publisherStreamId: String = defaultPublisherStreamId 214 | 215 | /// パブリッシャーの映像トラックの ID です。 216 | /// 通常、指定する必要はありません。 217 | public var publisherVideoTrackId: String = defaultPublisherVideoTrackId 218 | 219 | /// パブリッシャーの音声トラックの ID です。 220 | /// 通常、指定する必要はありません。 221 | public var publisherAudioTrackId: String = defaultPublisherAudioTrackId 222 | 223 | /// 初期化します。 224 | /// - parameter url: サーバーの URL 225 | /// - parameter channelId: チャネル ID 226 | /// - parameter role: ロール 227 | /// - parameter multistreamEnabled: マルチストリームの可否(デフォルトは指定なし) 228 | public init( 229 | url: URL, 230 | channelId: String, 231 | role: Role, 232 | multistreamEnabled: Bool? = nil 233 | ) { 234 | urlCandidates = [url] 235 | self.channelId = channelId 236 | self.role = role 237 | self.multistreamEnabled = multistreamEnabled 238 | } 239 | 240 | /// 初期化します。 241 | /// - parameter urlCandidates: シグナリングに利用する URL の候補 242 | /// - parameter channelId: チャネル ID 243 | /// - parameter role: ロール 244 | /// - parameter multistreamEnabled: マルチストリームの可否(デフォルトは指定なし) 245 | public init( 246 | urlCandidates: [URL], 247 | channelId: String, 248 | role: Role, 249 | multistreamEnabled: Bool? = nil 250 | ) { 251 | self.urlCandidates = urlCandidates 252 | self.channelId = channelId 253 | self.role = role 254 | self.multistreamEnabled = multistreamEnabled 255 | } 256 | } 257 | 258 | /// 転送フィルターのルールのフィールドの設定です。 259 | public enum ForwardingFilterRuleField: String, Encodable { 260 | /// connection_id 261 | case connectionId = "connection_id" 262 | 263 | /// client_id 264 | case clientId = "client_id" 265 | 266 | /// kind 267 | case kind 268 | } 269 | 270 | /// 転送フィルターのルールの演算子の設定です。 271 | public enum ForwardingFilterRuleOperator: String, Encodable { 272 | /// is_in 273 | case isIn = "is_in" 274 | 275 | /// is_not_in 276 | case isNotIn = "is_not_in" 277 | } 278 | 279 | /// 転送フィルターのルールの設定です。 280 | public struct ForwardingFilterRule: Encodable { 281 | /// field 282 | public let field: ForwardingFilterRuleField 283 | 284 | /// operator 285 | public let `operator`: ForwardingFilterRuleOperator 286 | 287 | /// values 288 | public let values: [String] 289 | 290 | /// 初期化します。 291 | /// - parameter field: field 292 | /// - parameter operator: operator 293 | /// - parameter values: values 294 | public init( 295 | field: ForwardingFilterRuleField, 296 | operator: ForwardingFilterRuleOperator, 297 | values: [String] 298 | ) { 299 | self.field = field 300 | self.operator = `operator` 301 | self.values = values 302 | } 303 | } 304 | 305 | /// 転送フィルターのアクションの設定です。 306 | public enum ForwardingFilterAction: String, Encodable { 307 | /// block 308 | case block 309 | 310 | /// allow 311 | case allow 312 | } 313 | 314 | /// 転送フィルターに関する設定です。 315 | public struct ForwardingFilter { 316 | /// name 317 | public var name: String? 318 | 319 | /// priority 320 | public var priority: Int? 321 | 322 | /// action 323 | public var action: ForwardingFilterAction? 324 | 325 | /// rules 326 | public var rules: [[ForwardingFilterRule]] 327 | 328 | /// version 329 | public var version: String? 330 | 331 | /// metadata 332 | public var metadata: Encodable? 333 | 334 | /// 初期化します。 335 | /// - parameter action: action (オプショナル) 336 | /// - parameter rules: rules 337 | /// - parameter version: version (オプショナル) 338 | /// - parameter metadata: metadata (オプショナル) 339 | public init( 340 | name: String? = nil, priority: Int? = nil, action: ForwardingFilterAction? = nil, 341 | rules: [[ForwardingFilterRule]], version: String? = nil, metadata: Encodable? = nil 342 | ) { 343 | self.name = name 344 | self.priority = priority 345 | self.action = action 346 | self.rules = rules 347 | self.version = version 348 | self.metadata = metadata 349 | } 350 | } 351 | 352 | extension ForwardingFilter: Encodable { 353 | enum CodingKeys: String, CodingKey { 354 | case name 355 | case priority 356 | case action 357 | case rules 358 | case version 359 | case metadata 360 | } 361 | 362 | public func encode(to encoder: Encoder) throws { 363 | var container = encoder.container(keyedBy: CodingKeys.self) 364 | try container.encodeIfPresent(name, forKey: .name) 365 | try container.encodeIfPresent(priority, forKey: .priority) 366 | try container.encodeIfPresent(action, forKey: .action) 367 | try container.encode(rules, forKey: .rules) 368 | try container.encodeIfPresent(version, forKey: .version) 369 | 370 | // この if をつけないと、常に "metadata": {} が含まれてしまう 371 | if metadata != nil { 372 | let metadataEnc = container.superEncoder(forKey: .metadata) 373 | try metadata?.encode(to: metadataEnc) 374 | } 375 | } 376 | } 377 | -------------------------------------------------------------------------------- /Sora/ConnectionState.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebRTC 3 | 4 | /// MediaChannel, SignalingChannel, WebSocketChannel の接続状態を表します。 5 | public enum ConnectionState { 6 | /// 接続試行中 7 | case connecting 8 | 9 | /// 接続成功済み 10 | case connected 11 | 12 | /// 接続解除試行中 13 | case disconnecting 14 | 15 | /// 接続解除済み 16 | case disconnected 17 | 18 | var isConnecting: Bool { 19 | self == .connecting 20 | } 21 | 22 | var isDisconnected: Bool { 23 | switch self { 24 | case .disconnecting, .disconnected: 25 | return true 26 | default: 27 | return false 28 | } 29 | } 30 | 31 | init(_ state: PeerChannelConnectionState) { 32 | switch state { 33 | case .new: 34 | self = .disconnected 35 | case .connecting: 36 | self = .connecting 37 | // RTCPeerConnectionState の disconnected は connected に遷移する可能性があるため接続中として扱う 38 | case .connected, .disconnected: 39 | self = .connected 40 | case .closed, .failed, .unknown: 41 | self = .disconnected 42 | } 43 | } 44 | } 45 | 46 | /// PeerChannel の接続状態を表します。 47 | enum PeerChannelConnectionState { 48 | case new 49 | case connecting 50 | case connected 51 | case disconnected 52 | case failed 53 | case closed 54 | case unknown 55 | 56 | init(_ state: RTCPeerConnectionState) { 57 | switch state { 58 | case .new: 59 | self = .new 60 | case .connecting: 61 | self = .connecting 62 | case .connected: 63 | self = .connected 64 | case .disconnected: 65 | self = .disconnected 66 | case .failed: 67 | self = .failed 68 | case .closed: 69 | self = .closed 70 | @unknown default: 71 | self = .unknown 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sora/ConnectionTimer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum ConnectionMonitor { 4 | case signalingChannel(SignalingChannel) 5 | case peerChannel(PeerChannel) 6 | 7 | var state: ConnectionState { 8 | switch self { 9 | case .signalingChannel(let chan): 10 | return chan.state 11 | case .peerChannel(let chan): 12 | return ConnectionState(chan.state) 13 | } 14 | } 15 | 16 | func disconnect() { 17 | let error = SoraError.connectionTimeout 18 | switch self { 19 | case .signalingChannel(let chan): 20 | // タイムアウトはシグナリングのエラーと考える 21 | chan.disconnect(error: error, reason: .signalingFailure) 22 | case .peerChannel(let chan): 23 | // タイムアウトはシグナリングのエラーと考える 24 | chan.disconnect(error: error, reason: .signalingFailure) 25 | } 26 | } 27 | } 28 | 29 | class ConnectionTimer { 30 | public var monitors: [ConnectionMonitor] 31 | public var timeout: Int 32 | public var isRunning: Bool = false 33 | 34 | private var timer: Timer? 35 | 36 | public init(monitors: [ConnectionMonitor], timeout: Int) { 37 | self.monitors = monitors 38 | self.timeout = timeout 39 | } 40 | 41 | public func run(timeout: Int? = nil, handler: @escaping () -> Void) { 42 | if let timeout { 43 | self.timeout = timeout 44 | } 45 | Logger.debug( 46 | type: .connectionTimer, 47 | message: "run (timeout: \(self.timeout) seconds)") 48 | 49 | timer = Timer(timeInterval: TimeInterval(self.timeout), repeats: false) { _ in 50 | Logger.debug(type: .connectionTimer, message: "validate timeout") 51 | for monitor in self.monitors { 52 | if monitor.state.isConnecting { 53 | Logger.debug( 54 | type: .connectionTimer, 55 | message: "found timeout") 56 | for monitor in self.monitors { 57 | if !monitor.state.isDisconnected { 58 | monitor.disconnect() 59 | } 60 | } 61 | handler() 62 | self.stop() 63 | return 64 | } 65 | } 66 | Logger.debug(type: .connectionTimer, message: "all OK") 67 | } 68 | RunLoop.main.add(timer!, forMode: RunLoop.Mode.common) 69 | isRunning = true 70 | } 71 | 72 | public func stop() { 73 | Logger.debug(type: .connectionTimer, message: "stop") 74 | timer?.invalidate() 75 | isRunning = false 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Sora/DataChannel.swift: -------------------------------------------------------------------------------- 1 | import Compression 2 | import Foundation 3 | import WebRTC 4 | import zlib 5 | 6 | // Apple が提供する圧縮を扱う API は zlib のヘッダーとチェックサムをサポートしていないため、当該処理を実装する必要があった 7 | // https://developer.apple.com/documentation/accelerate/compressing_and_decompressing_data_with_buffer_compression 8 | // 9 | // TODO: iOS 12 のサポートが不要になれば、 Compression Framework の関数を、 NSData の compressed(using), decompressed(using:) に書き換えることができる 10 | // それに伴い、処理に必要なバッファーのサイズを指定する必要もなくなる 11 | private enum ZLibUtil { 12 | static func zip(_ input: Data) -> Data? { 13 | if input.isEmpty { 14 | return nil 15 | } 16 | 17 | // TODO: 毎回確保するには大きいので、 stream を利用して圧縮する API、もしくは NSData の compressed(using:) を使用することを検討する 18 | // 2021年10月時点では、 DataChannel の最大メッセージサイズは 262,144 バイトだが、これを拡張する RFC が提案されている 19 | // https://sora-doc.shiguredo.jp/DATA_CHANNEL_SIGNALING#48cff8 20 | // https://www.rfc-editor.org/rfc/rfc8260.html 21 | let bufferSize = 262_144 22 | let destinationBuffer = UnsafeMutablePointer.allocate(capacity: bufferSize) 23 | defer { 24 | destinationBuffer.deallocate() 25 | } 26 | 27 | var sourceBuffer = [UInt8](input) 28 | let size = compression_encode_buffer( 29 | destinationBuffer, bufferSize, 30 | &sourceBuffer, sourceBuffer.count, 31 | nil, 32 | COMPRESSION_ZLIB) 33 | if size == 0 { 34 | return nil 35 | } 36 | 37 | var zipped = Data(capacity: size + 6) // ヘッダー: 2バイト, チェックサム: 4バイト 38 | zipped.append(contentsOf: [0x78, 0x5E]) // ヘッダーを追加 39 | zipped.append(destinationBuffer, count: size) 40 | 41 | let checksum = input.withUnsafeBytes { (p: UnsafeRawBufferPointer) -> UInt32 in 42 | let bytef = p.baseAddress!.assumingMemoryBound(to: Bytef.self) 43 | return UInt32(adler32(1, bytef, UInt32(input.count))) 44 | } 45 | 46 | zipped.append(UInt8(checksum >> 24 & 0xFF)) 47 | zipped.append(UInt8(checksum >> 16 & 0xFF)) 48 | zipped.append(UInt8(checksum >> 8 & 0xFF)) 49 | zipped.append(UInt8(checksum & 0xFF)) 50 | return zipped 51 | } 52 | 53 | static func unzip(_ input: Data) -> Data? { 54 | if input.isEmpty { 55 | return nil 56 | } 57 | 58 | // TODO: zip と同様に、stream を利用して解凍する API、もしくは NSData の decompressed(using:) を使用することを検討する 59 | let bufferSize = 262_144 60 | let destinationBuffer = UnsafeMutablePointer.allocate(capacity: bufferSize) 61 | 62 | var sourceBuffer = [UInt8](input) 63 | 64 | // header を削除 65 | sourceBuffer.removeFirst(2) 66 | 67 | // checksum も削除 68 | let checksum = Data(sourceBuffer.suffix(4)) 69 | sourceBuffer.removeLast(4) 70 | 71 | let size = compression_decode_buffer( 72 | destinationBuffer, bufferSize, 73 | &sourceBuffer, sourceBuffer.count, 74 | nil, 75 | COMPRESSION_ZLIB) 76 | 77 | if size == 0 { 78 | return nil 79 | } 80 | 81 | let data = Data(bytesNoCopy: destinationBuffer, count: size, deallocator: .free) 82 | 83 | let calculatedChecksum = data.withUnsafeBytes { (p: UnsafeRawBufferPointer) -> Data in 84 | let bytef = p.baseAddress!.assumingMemoryBound(to: Bytef.self) 85 | var result = UInt32(adler32(1, bytef, UInt32(data.count))).bigEndian 86 | return Data(bytes: &result, count: MemoryLayout.size) 87 | } 88 | 89 | // checksum の検証が成功したら data を返す 90 | return checksum == calculatedChecksum ? data : nil 91 | } 92 | } 93 | 94 | extension RTCDataChannelState: CustomStringConvertible { 95 | public var description: String { 96 | switch self { 97 | case .connecting: 98 | return "connecting" 99 | case .open: 100 | return "open" 101 | case .closing: 102 | return "closing" 103 | case .closed: 104 | return "closed" 105 | @unknown default: 106 | return "unknown" 107 | } 108 | } 109 | } 110 | 111 | class BasicDataChannelDelegate: NSObject, RTCDataChannelDelegate { 112 | let compress: Bool 113 | weak var peerChannel: PeerChannel? 114 | weak var mediaChannel: MediaChannel? 115 | 116 | init(compress: Bool, mediaChannel: MediaChannel?, peerChannel: PeerChannel?) { 117 | self.compress = compress 118 | self.mediaChannel = mediaChannel 119 | self.peerChannel = peerChannel 120 | } 121 | 122 | func dataChannelDidChangeState(_ dataChannel: RTCDataChannel) { 123 | Logger.debug( 124 | type: .dataChannel, 125 | message: 126 | "\(#function): label => \(dataChannel.label), state => \(dataChannel.readyState)") 127 | 128 | if dataChannel.readyState == .closed { 129 | if let peerChannel { 130 | // DataChannel が切断されたタイミングで PeerChannel を切断する 131 | // PeerChannel -> DataChannel の順に切断されるパターンも存在するが、 132 | // PeerChannel.disconnect(error:reason:) 側で排他処理が実装されているため問題ない 133 | peerChannel.disconnect(error: nil, reason: DisconnectReason.dataChannelClosed) 134 | } 135 | } 136 | } 137 | 138 | func dataChannel(_ dataChannel: RTCDataChannel, didChangeBufferedAmount amount: UInt64) { 139 | Logger.debug( 140 | type: .dataChannel, 141 | message: "\(#function): label => \(dataChannel.label), amount => \(amount)") 142 | } 143 | 144 | func dataChannel(_ dataChannel: RTCDataChannel, didReceiveMessageWith buffer: RTCDataBuffer) { 145 | Logger.debug(type: .dataChannel, message: "\(#function): label => \(dataChannel.label)") 146 | 147 | guard let peerChannel else { 148 | Logger.error(type: .dataChannel, message: "peerChannel is unavailable") 149 | return 150 | } 151 | 152 | guard let dc = peerChannel.dataChannels[dataChannel.label] else { 153 | Logger.error( 154 | type: .dataChannel, 155 | message: "DataChannel for label: \(dataChannel.label) is unavailable") 156 | return 157 | } 158 | 159 | guard let data = dc.compress ? ZLibUtil.unzip(buffer.data) : buffer.data else { 160 | Logger.error(type: .dataChannel, message: "failed to decompress data channel message") 161 | return 162 | } 163 | 164 | if let message = String(data: data, encoding: .utf8) { 165 | Logger.info( 166 | type: .dataChannel, 167 | message: "received data channel message: \(String(describing: message))") 168 | } 169 | 170 | // Sora から送られてきたメッセージ 171 | if !dataChannel.label.starts(with: "#") { 172 | switch dataChannel.label { 173 | case "stats": 174 | peerChannel.nativeChannel?.statistics { 175 | // NOTE: stats の型を Signaling.swift に定義していない 176 | let reports = Statistics(contentsOf: $0).jsonObject 177 | let json: [String: Any] = [ 178 | "type": "stats", 179 | "reports": reports, 180 | ] 181 | 182 | var data: Data? 183 | do { 184 | data = try JSONSerialization.data( 185 | withJSONObject: json, options: [.prettyPrinted]) 186 | } catch { 187 | Logger.error( 188 | type: .dataChannel, message: "failed to encode stats data to json") 189 | } 190 | 191 | if let data { 192 | let ok = dc.send(data) 193 | if !ok { 194 | Logger.error( 195 | type: .dataChannel, 196 | message: "failed to send stats data over DataChannel") 197 | } 198 | } 199 | } 200 | 201 | case "signaling", "push", "notify": 202 | switch Signaling.decode(data) { 203 | case .success(let signaling): 204 | peerChannel.handleSignalingOverDataChannel(signaling) 205 | case .failure(let error): 206 | Logger.error( 207 | type: .dataChannel, 208 | message: "decode failed (\(error.localizedDescription)) => ") 209 | } 210 | case "e2ee": 211 | Logger.error( 212 | type: .dataChannel, message: "NOT IMPLEMENTED: label => \(dataChannel.label)") 213 | default: 214 | Logger.error( 215 | type: .dataChannel, message: "unknown data channel label: \(dataChannel.label)") 216 | } 217 | } 218 | if let mediaChannel, let handler = mediaChannel.handlers.onDataChannelMessage { 219 | handler(mediaChannel, dataChannel.label, data) 220 | } 221 | } 222 | } 223 | 224 | class DataChannel { 225 | let native: RTCDataChannel 226 | let delegate: BasicDataChannelDelegate 227 | 228 | init( 229 | dataChannel: RTCDataChannel, compress: Bool, mediaChannel: MediaChannel?, 230 | peerChannel: PeerChannel? 231 | ) { 232 | Logger.info( 233 | type: .dataChannel, 234 | message: 235 | "initialize DataChannel: label => \(dataChannel.label), compress => \(compress)") 236 | native = dataChannel 237 | delegate = BasicDataChannelDelegate( 238 | compress: compress, mediaChannel: mediaChannel, peerChannel: peerChannel) 239 | native.delegate = delegate 240 | } 241 | 242 | var label: String { 243 | native.label 244 | } 245 | 246 | var compress: Bool { 247 | delegate.compress 248 | } 249 | 250 | var readyState: RTCDataChannelState { 251 | native.readyState 252 | } 253 | 254 | func send(_ data: Data) -> Bool { 255 | Logger.debug( 256 | type: .dataChannel, 257 | message: 258 | "\(String(describing: type(of: self))):\(#function): label => \(label), data => \(data.base64EncodedString())" 259 | ) 260 | 261 | guard let data = compress ? ZLibUtil.zip(data) : data else { 262 | Logger.error(type: .dataChannel, message: "failed to compress message") 263 | return false 264 | } 265 | return native.sendData(RTCDataBuffer(data: data, isBinary: true)) 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /Sora/DeviceInfo.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | /// :nodoc: 5 | func currentMachineName() -> String { 6 | let machineKey = "hw.machine" 7 | let machineKeyPtr = UnsafeMutableBufferPointer 8 | .allocate(capacity: machineKey.utf8CString.count) 9 | _ = machineKeyPtr.initialize(from: machineKey.utf8CString) 10 | var machineNameLen = 0 11 | sysctlbyname(machineKeyPtr.baseAddress!, nil, &machineNameLen, nil, 0) 12 | let machineNamePtr = UnsafeMutableBufferPointer 13 | .allocate(capacity: machineNameLen) 14 | sysctlbyname( 15 | machineKeyPtr.baseAddress!, 16 | machineNamePtr.baseAddress!, 17 | &machineNameLen, nil, 0) 18 | let machineName = String.init(cString: machineNamePtr.baseAddress!) 19 | machineKeyPtr.deallocate() 20 | machineNamePtr.deallocate() 21 | return machineName 22 | } 23 | 24 | /// :nodoc: 25 | public struct DeviceInfo { 26 | public static var current: DeviceInfo = .init( 27 | device: UIDevice.current, 28 | machineName: currentMachineName()) 29 | 30 | public let machineName: String 31 | 32 | public var description: String { 33 | "\(machineName); \(device.systemName) \(device.systemVersion)" 34 | } 35 | 36 | private let device: UIDevice 37 | 38 | init(device: UIDevice, machineName: String) { 39 | self.machineName = machineName 40 | self.device = device 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sora/Extensions/Array+Base.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// :nodoc: 4 | extension Array { 5 | public mutating func remove( 6 | _ element: Element, 7 | where predicate: (Element) -> Bool 8 | ) { 9 | self = filter { other in 10 | !predicate(other) 11 | } 12 | } 13 | } 14 | 15 | /// :nodoc: 16 | extension Array where Element: Equatable { 17 | public mutating func remove(_ element: Element) { 18 | remove(element) { other in element == other } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sora/Extensions/RTC+Description.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebRTC 3 | 4 | /// :nodoc: 5 | extension RTCSignalingState: CustomStringConvertible { 6 | public var description: String { 7 | switch self { 8 | case .stable: return "stable" 9 | case .haveLocalOffer: return "haveLocalOffer" 10 | case .haveLocalPrAnswer: return "haveLocalPrAnswer" 11 | case .haveRemoteOffer: return "haveRemoteOffer" 12 | case .haveRemotePrAnswer: return "haveRemotePrAnswer" 13 | case .closed: return "closed" 14 | @unknown default: 15 | fatalError("unknown state") 16 | } 17 | } 18 | } 19 | 20 | /// :nodoc: 21 | extension RTCIceConnectionState: CustomStringConvertible { 22 | public var description: String { 23 | switch self { 24 | case .new: return "new" 25 | case .checking: return "checking" 26 | case .connected: return "connected" 27 | case .completed: return "completed" 28 | case .failed: return "failed" 29 | case .disconnected: return "disconnected" 30 | case .closed: return "closed" 31 | case .count: return "count" 32 | @unknown default: 33 | fatalError("unknown state") 34 | } 35 | } 36 | } 37 | 38 | /// :nodoc: 39 | extension RTCIceGatheringState: CustomStringConvertible { 40 | public var description: String { 41 | switch self { 42 | case .new: return "new" 43 | case .gathering: return "gathering" 44 | case .complete: return "complete" 45 | @unknown default: 46 | fatalError("unknown state") 47 | } 48 | } 49 | } 50 | 51 | /// :nodoc: 52 | extension RTCSessionDescription { 53 | public var sdpDescription: String { 54 | let lines = sdp.components(separatedBy: .newlines) 55 | .filter { line in 56 | !line.isEmpty 57 | } 58 | return lines.joined(separator: "\n") 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sora/ICECandidate.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebRTC 3 | 4 | /// ICE Candidate を表します。 5 | public final class ICECandidate: Equatable { 6 | // MARK: 比較 7 | /// オブジェクト同士を比較します。 8 | /// 双方の URL と SDP 文字列が等しければ ``true`` を返します。 9 | public static func == (lhs: ICECandidate, rhs: ICECandidate) -> Bool { 10 | lhs.url == rhs.url && lhs.sdp == rhs.sdp 11 | } 12 | // MARK: プロパティ 13 | /// URL 14 | public var url: URL? 15 | /// SDP 文字列 16 | public var sdp: String 17 | // MARK: 初期化 18 | /// 初期化します。 19 | public init(url: URL?, sdp: String) { 20 | self.url = url 21 | self.sdp = sdp 22 | } 23 | init(nativeICECandidate: RTCIceCandidate) { 24 | if let urlStr = nativeICECandidate.serverUrl { 25 | url = URL(string: urlStr) 26 | } 27 | sdp = nativeICECandidate.sdp 28 | } 29 | } 30 | 31 | /// :nodoc: 32 | extension ICECandidate: Codable { 33 | public convenience init(from decoder: Decoder) throws { 34 | let container = try decoder.singleValueContainer() 35 | let sdp = try container.decode(String.self) 36 | self.init(url: nil, sdp: sdp) 37 | } 38 | public func encode(to encoder: Encoder) throws { 39 | var container = encoder.singleValueContainer() 40 | try container.encode(sdp) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sora/ICEServerInfo.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebRTC 3 | 4 | /// ICE サーバーの情報を表します。 5 | public final class ICEServerInfo { 6 | // MARK: プロパティ 7 | 8 | /// URL のリスト 9 | /// 10 | /// TURN URI はそのまま文字列として処理する 11 | public var urls: [String] = [] 12 | 13 | /// ユーザー名 14 | public var userName: String? 15 | 16 | /// クレデンシャル 17 | public var credential: String? 18 | 19 | /// TLS のセキュリティポリシー 20 | public var tlsSecurityPolicy: TLSSecurityPolicy = .secure 21 | 22 | var nativeValue: RTCIceServer { 23 | RTCIceServer( 24 | urlStrings: urls, 25 | username: userName, 26 | credential: credential, 27 | tlsCertPolicy: tlsSecurityPolicy.nativeValue) 28 | } 29 | 30 | // MARK: 初期化 31 | 32 | /// 初期化します。 33 | public init( 34 | urls: [String], 35 | userName: String?, 36 | credential: String?, 37 | tlsSecurityPolicy: TLSSecurityPolicy 38 | ) { 39 | self.urls = urls 40 | self.userName = userName 41 | self.credential = credential 42 | self.tlsSecurityPolicy = tlsSecurityPolicy 43 | } 44 | } 45 | 46 | /// :nodoc: 47 | extension ICEServerInfo: CustomStringConvertible { 48 | public var description: String { 49 | let encoder = JSONEncoder() 50 | let data = try! encoder.encode(self) 51 | return String(data: data, encoding: .utf8)! 52 | } 53 | } 54 | 55 | /// :nodoc: 56 | extension ICEServerInfo: Codable { 57 | enum CodingKeys: String, CodingKey { 58 | case urls 59 | case userName = "username" 60 | case credential 61 | } 62 | 63 | public convenience init(from decoder: Decoder) throws { 64 | let container = try decoder.container(keyedBy: CodingKeys.self) 65 | let urls = try container.decode([String].self, forKey: .urls) 66 | let userName = try container.decodeIfPresent(String.self, forKey: .userName) 67 | let credential = try container.decodeIfPresent(String.self, forKey: .credential) 68 | self.init( 69 | urls: urls, 70 | userName: userName, 71 | credential: credential, 72 | tlsSecurityPolicy: .secure) 73 | } 74 | 75 | public func encode(to encoder: Encoder) throws { 76 | var container = encoder.container(keyedBy: CodingKeys.self) 77 | try container.encode(urls, forKey: .urls) 78 | if let userName { 79 | try container.encode(userName, forKey: .userName) 80 | } 81 | if let credential { 82 | try container.encode(credential, forKey: .credential) 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sora/ICETransportPolicy.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebRTC 3 | 4 | private var iceTransportPolicyTable: PairTable = 5 | PairTable( 6 | name: "ICETransportPolicy", 7 | pairs: [(.relay, .relay), (.all, .all)]) 8 | 9 | /// ICE 通信ポリシーを表します。 10 | public enum ICETransportPolicy { 11 | /// TURN サーバーを経由するメディアリレー候補のみを使用します。 12 | case relay 13 | 14 | /// すべての候補を使用します。 15 | case all 16 | 17 | var nativeValue: RTCIceTransportPolicy { 18 | iceTransportPolicyTable.right(other: self)! 19 | } 20 | } 21 | 22 | /// :nodoc: 23 | extension ICETransportPolicy: CustomStringConvertible { 24 | public var description: String { 25 | switch self { 26 | case .relay: 27 | return "relay" 28 | case .all: 29 | return "all" 30 | } 31 | } 32 | } 33 | 34 | /// :nodoc: 35 | extension ICETransportPolicy: Codable { 36 | public init(from decoder: Decoder) throws { 37 | let container = try decoder.singleValueContainer() 38 | let value = try container.decode(String.self) 39 | if value == "relay" { 40 | self = .relay 41 | } else { 42 | throw 43 | DecodingError 44 | .dataCorruptedError( 45 | in: container, 46 | debugDescription: "invalid value") 47 | } 48 | } 49 | 50 | public func encode(to encoder: Encoder) throws { 51 | var container = encoder.singleValueContainer() 52 | switch self { 53 | case .relay: 54 | try container.encode("relay") 55 | case .all: 56 | try container.encode("all") 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sora/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | NSPrincipalClass 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /Sora/Logger.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// :nodoc: 4 | public enum LogType { 5 | case sora 6 | case webSocketChannel 7 | case signaling 8 | case signalingChannel 9 | case peerChannel 10 | case nativePeerChannel 11 | case connectionTimer 12 | case mediaChannel 13 | case mediaStream 14 | case cameraVideoCapturer 15 | case videoRenderer 16 | case videoView 17 | case user(String) 18 | case configurationViewController 19 | case dataChannel 20 | } 21 | 22 | /// :nodoc: 23 | extension LogType: CustomStringConvertible { 24 | public var description: String { 25 | switch self { 26 | case .sora: 27 | return "Sora" 28 | case .webSocketChannel: 29 | return "WebSocketChannel" 30 | case .signaling: 31 | return "Signaling" 32 | case .signalingChannel: 33 | return "SignalingChannel" 34 | case .peerChannel: 35 | return "PeerChannel" 36 | case .nativePeerChannel: 37 | return "NativePeerChannel" 38 | case .connectionTimer: 39 | return "ConnectionTimer" 40 | case .mediaChannel: 41 | return "MediaChannel" 42 | case .mediaStream: 43 | return "MediaStream" 44 | case .cameraVideoCapturer: 45 | return "CameraVideoCapturer" 46 | case .videoRenderer: 47 | return "VideoRenderer" 48 | case .videoView: 49 | return "VideoView" 50 | case .user(let name): 51 | return name 52 | case .configurationViewController: 53 | return "ConfigurationViewController" 54 | case .dataChannel: 55 | return "DataChannel" 56 | } 57 | } 58 | } 59 | 60 | // MARK: - 61 | 62 | /// ログレベルです。 63 | /// 上から下に向かってログの重要度が下がり、詳細度が上がります。 64 | /// `off` はログを出力しません。 65 | /// 66 | /// 6. `fatal` 67 | /// 5. `error` 68 | /// 4. `warn` 69 | /// 3. `info` 70 | /// 2. `debug` 71 | /// 1. `trace` 72 | /// 0. `off` 73 | public enum LogLevel { 74 | /// 致命的なエラー情報 75 | case fatal 76 | 77 | /// エラー情報 78 | case error 79 | 80 | /// 警告 81 | case warn 82 | 83 | /// 一般的な情報 84 | case info 85 | 86 | /// デバッグ情報 87 | case debug 88 | 89 | /// 最も詳細なデバッグ情報 90 | case trace 91 | 92 | /// ログを出力しない 93 | case off 94 | } 95 | 96 | /// :nodoc: 97 | extension LogLevel { 98 | var value: Int { 99 | switch self { 100 | case .fatal: 101 | return 6 102 | case .error: 103 | return 5 104 | case .warn: 105 | return 4 106 | case .info: 107 | return 3 108 | case .debug: 109 | return 2 110 | case .trace: 111 | return 1 112 | case .off: 113 | return 0 114 | } 115 | } 116 | } 117 | 118 | /// :nodoc: 119 | extension LogLevel: CustomStringConvertible { 120 | public var description: String { 121 | switch self { 122 | case .fatal: 123 | return "FATAL" 124 | case .error: 125 | return "ERROR" 126 | case .warn: 127 | return "WARN" 128 | case .info: 129 | return "INFO" 130 | case .debug: 131 | return "DEBUG" 132 | case .trace: 133 | return "TRACE" 134 | case .off: 135 | return "OFF" 136 | } 137 | } 138 | } 139 | 140 | // MARK: - 141 | 142 | /// :nodoc: 143 | public struct Log { 144 | public let level: LogLevel 145 | public let type: LogType 146 | public let timestamp: Date 147 | public let message: String 148 | 149 | init(level: LogLevel, type: LogType, message: String) { 150 | self.level = level 151 | self.type = type 152 | timestamp = Date() 153 | self.message = message 154 | } 155 | } 156 | 157 | /// :nodoc: 158 | extension Log: CustomStringConvertible { 159 | private static let formatter: DateFormatter = { 160 | let formatter = DateFormatter() 161 | formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" 162 | return formatter 163 | }() 164 | 165 | public var description: String { 166 | String( 167 | format: "%@ %@ %@: %@", 168 | Log.formatter.string(from: timestamp), 169 | type.description, 170 | level.description, 171 | message) 172 | } 173 | } 174 | 175 | // MARK: - 176 | 177 | /// :nodoc: 178 | public final class Logger { 179 | public enum Group { 180 | case channels 181 | case connectionTimer 182 | case videoCapturer 183 | case videoRenderer 184 | case configurationViewController 185 | case user 186 | } 187 | 188 | public static var shared = Logger() 189 | 190 | public var onOutputHandler: ((Log) -> Void)? 191 | 192 | public var groups: [Group] = [.channels, .user] 193 | 194 | public static func fatal(type: LogType, message: String) { 195 | Logger.shared.output( 196 | log: Log( 197 | level: .fatal, 198 | type: type, 199 | message: message)) 200 | } 201 | 202 | public static func error(type: LogType, message: String) { 203 | Logger.shared.output( 204 | log: Log( 205 | level: .error, 206 | type: type, 207 | message: message)) 208 | } 209 | 210 | public static func debug(type: LogType, message: String) { 211 | Logger.shared.output( 212 | log: Log( 213 | level: .debug, 214 | type: type, 215 | message: message)) 216 | } 217 | 218 | public static func warn(type: LogType, message: String) { 219 | Logger.shared.output( 220 | log: Log( 221 | level: .warn, 222 | type: type, 223 | message: message)) 224 | } 225 | 226 | public static func info(type: LogType, message: String) { 227 | Logger.shared.output( 228 | log: Log( 229 | level: .info, 230 | type: type, 231 | message: message)) 232 | } 233 | 234 | public static func trace(type: LogType, message: String) { 235 | Logger.shared.output( 236 | log: Log( 237 | level: .trace, 238 | type: type, 239 | message: message)) 240 | } 241 | 242 | public var level: LogLevel = .info 243 | 244 | func output(log: Log) { 245 | var out = false 246 | for group in groups { 247 | switch group { 248 | case .channels: 249 | switch log.type { 250 | case .sora, 251 | .webSocketChannel, 252 | .signalingChannel, 253 | .peerChannel, 254 | .nativePeerChannel, 255 | .mediaChannel, 256 | .mediaStream, 257 | .dataChannel, 258 | .cameraVideoCapturer: 259 | out = true 260 | default: 261 | break 262 | } 263 | case .connectionTimer: 264 | switch log.type { 265 | case .connectionTimer: 266 | out = true 267 | default: 268 | break 269 | } 270 | case .videoCapturer: 271 | switch log.type { 272 | case .cameraVideoCapturer: 273 | out = true 274 | default: 275 | break 276 | } 277 | case .videoRenderer: 278 | switch log.type { 279 | case .videoRenderer, .videoView: 280 | out = true 281 | default: 282 | break 283 | } 284 | case .user: 285 | switch log.type { 286 | case .user: 287 | out = true 288 | default: 289 | break 290 | } 291 | case .configurationViewController: 292 | switch log.type { 293 | case .configurationViewController: 294 | out = true 295 | default: 296 | break 297 | } 298 | } 299 | } 300 | if !out { return } 301 | 302 | if level.value > 0, level.value <= log.level.value { 303 | onOutputHandler?(log) 304 | print(log.description) 305 | } 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /Sora/MediaChannel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebRTC 3 | 4 | /// SoraCloseEvent は、Sora の接続が切断された際のイベント情報を表します。 5 | /// 6 | /// 接続が正常に切断された場合は、`.ok(code, reason)` ケースが使用され、 7 | /// 異常な切断やエラー発生時は、`.error(Error)` ケースが使用されます。 8 | public enum SoraCloseEvent { 9 | /// 正常な接続切断を示します。 10 | /// - Parameters: 11 | /// - code: 接続切断時に返されるコード。例えば、WebSocket の標準切断コード(例: 1000 等)など。 12 | /// - reason: 接続が正常に切断された理由の説明文字列。 13 | case ok(code: Int, reason: String) 14 | /// 異常な切断またはエラーが発生して切断した場合に利用されるケースです。 15 | /// - Parameter error: エラー情報。 16 | case error(Error) 17 | } 18 | 19 | /// メディアチャネルのイベントハンドラです。 20 | public final class MediaChannelHandlers { 21 | /// 接続成功時に呼ばれるクロージャー 22 | public var onConnect: ((Error?) -> Void)? 23 | 24 | /// 接続解除時に呼ばれるクロージャー 25 | @available( 26 | *, deprecated, 27 | message: 28 | "onDisconnect: ((SoraCloseEvent) -> Void)? に移行してください。onDisconnectLegacy: ((Error?) -> Void)? は、2027 年中に削除予定です。" 29 | ) 30 | public var onDisconnectLegacy: ((Error?) -> Void)? 31 | 32 | /// 接続解除時に呼ばれるクロージャー 33 | public var onDisconnect: ((SoraCloseEvent) -> Void)? 34 | 35 | /// ストリームが追加されたときに呼ばれるクロージャー 36 | public var onAddStream: ((MediaStream) -> Void)? 37 | 38 | /// ストリームが除去されたときに呼ばれるクロージャー 39 | public var onRemoveStream: ((MediaStream) -> Void)? 40 | 41 | /// シグナリング受信時に呼ばれるクロージャー 42 | public var onReceiveSignaling: ((Signaling) -> Void)? 43 | 44 | /// シグナリングが DataChannel 経由に切り替わったタイミングで呼ばれるクロージャー 45 | public var onDataChannel: ((MediaChannel) -> Void)? 46 | 47 | /// DataChannel のメッセージ受信時に呼ばれるクロージャー 48 | public var onDataChannelMessage: ((MediaChannel, String, Data) -> Void)? 49 | 50 | /// 初期化します。 51 | public init() {} 52 | } 53 | 54 | // MARK: - 55 | 56 | /// 一度接続を行ったメディアチャネルは再利用できません。 57 | /// 同じ設定で接続を行いたい場合は、新しい接続を行う必要があります。 58 | /// 59 | /// ## 接続が解除されるタイミング 60 | /// 61 | /// メディアチャネルの接続が解除される条件を以下に示します。 62 | /// いずれかの条件が 1 つでも成立すると、メディアチャネルを含めたすべてのチャネル 63 | /// (シグナリングチャネル、ピアチャネル、 WebSocket チャネル) の接続が解除されます。 64 | /// 65 | /// - シグナリングチャネル (`SignalingChannel`) の接続が解除される。 66 | /// - WebSocket チャネル (`WebSocketChannel`) の接続が解除される。 67 | /// - ピアチャネル (`PeerChannel`) の接続が解除される。 68 | /// - サーバーから受信したシグナリング `ping` に対して `pong` を返さない。 69 | /// これはピアチャネルの役目です。 70 | public final class MediaChannel { 71 | // MARK: - イベントハンドラ 72 | 73 | /// イベントハンドラ 74 | public var handlers = MediaChannelHandlers() 75 | 76 | /// 内部処理で使われるイベントハンドラ 77 | var internalHandlers = MediaChannelHandlers() 78 | 79 | // MARK: - 接続情報 80 | 81 | /// クライアントの設定 82 | public let configuration: Configuration 83 | 84 | /// 最初に type: connect メッセージを送信した URL (デバッグ用) 85 | /// 86 | /// Sora から type: redirect メッセージを受信した場合、 contactUrl と connectedUrl には異なる値がセットされます 87 | /// type: redirect メッセージを受信しなかった場合、 contactUrl と connectedUrl には同じ値がセットされます 88 | public var contactUrl: URL? { 89 | signalingChannel.contactUrl 90 | } 91 | 92 | /// 接続中の URL 93 | public var connectedUrl: URL? { 94 | signalingChannel.connectedUrl 95 | } 96 | 97 | /// メディアチャンネルの内部で利用している RTCPeerConnection 98 | public var native: RTCPeerConnection? { 99 | peerChannel.nativeChannel 100 | } 101 | 102 | /// クライアント ID 。接続後にセットされます。 103 | public var clientId: String? { 104 | peerChannel.clientId 105 | } 106 | 107 | /// バンドル ID 。接続後にセットされます。 108 | public var bundleId: String? { 109 | peerChannel.bundleId 110 | } 111 | 112 | /// 接続 ID 。接続後にセットされます。 113 | public var connectionId: String? { 114 | peerChannel.connectionId 115 | } 116 | 117 | /// 接続状態 118 | public private(set) var state: ConnectionState = .disconnected { 119 | didSet { 120 | Logger.trace( 121 | type: .mediaChannel, 122 | message: "changed state from \(oldValue) to \(state)") 123 | } 124 | } 125 | 126 | /// 接続中 (`state == .connected`) であれば ``true`` 127 | public var isAvailable: Bool { state == .connected } 128 | 129 | /// 接続開始時刻。 130 | /// 接続中にのみ取得可能です。 131 | public private(set) var connectionStartTime: Date? 132 | 133 | /// 接続時間 (秒) 。 134 | /// 接続中にのみ取得可能です。 135 | public var connectionTime: Int? { 136 | if let start = connectionStartTime { 137 | return Int(Date().timeIntervalSince(start)) 138 | } else { 139 | return nil 140 | } 141 | } 142 | 143 | // MARK: 接続中のチャネルの情報 144 | 145 | /// 同チャネルに接続中のクライアントの数。 146 | /// サーバーから通知を受信可能であり、かつ接続中にのみ取得可能です。 147 | public private(set) var connectionCount: Int? 148 | 149 | /// 同チャネルに接続中のクライアントのうち、パブリッシャーの数。 150 | /// サーバーから通知を受信可能であり、接続中にのみ取得可能です。 151 | public private(set) var publisherCount: Int? 152 | 153 | /// 同チャネルに接続中のクライアントの数のうち、サブスクライバーの数。 154 | /// サーバーから通知を受信可能であり、接続中にのみ取得可能です。 155 | public private(set) var subscriberCount: Int? 156 | 157 | // MARK: 接続チャネル 158 | 159 | /// シグナリングチャネル 160 | let signalingChannel: SignalingChannel 161 | 162 | /// ピアチャネル 163 | var peerChannel: PeerChannel { 164 | _peerChannel! 165 | } 166 | 167 | // PeerChannel に mediaChannel を保持させる際にこの書き方が必要になった 168 | private var _peerChannel: PeerChannel? 169 | 170 | /// ストリームのリスト 171 | public var streams: [MediaStream] { 172 | peerChannel.streams 173 | } 174 | /// 最初のストリーム。 175 | /// マルチストリームでは、必ずしも最初のストリームが 送信ストリームとは限りません。 176 | /// 送信ストリームが必要であれば `senderStream` を使用してください。 177 | public var mainStream: MediaStream? { 178 | streams.first 179 | } 180 | 181 | /// 送信に使われるストリーム。 182 | /// ストリーム ID が `configuration.publisherStreamId` に等しいストリームを返します。 183 | public var senderStream: MediaStream? { 184 | streams.first { stream in 185 | stream.streamId == configuration.publisherStreamId 186 | } 187 | } 188 | 189 | /// 受信ストリームのリスト。 190 | /// ストリーム ID が `configuration.publisherStreamId` と異なるストリームを返します。 191 | public var receiverStreams: [MediaStream] { 192 | streams.filter { stream in 193 | stream.streamId != configuration.publisherStreamId 194 | } 195 | } 196 | 197 | private var connectionTimer: ConnectionTimer { 198 | _connectionTimer! 199 | } 200 | 201 | // PeerChannel に mediaChannel を保持させる際にこの書き方が必要になった 202 | private var _connectionTimer: ConnectionTimer? 203 | 204 | private let manager: Sora 205 | 206 | // MARK: - インスタンスの生成 207 | 208 | /// 初期化します。 209 | /// 210 | /// - parameter manager: `Sora` オブジェクト 211 | /// - parameter configuration: クライアントの設定 212 | init(manager: Sora, configuration: Configuration) { 213 | self.manager = manager 214 | self.configuration = configuration 215 | signalingChannel = SignalingChannel.init(configuration: configuration) 216 | _peerChannel = PeerChannel.init( 217 | configuration: configuration, 218 | signalingChannel: signalingChannel, 219 | mediaChannel: self) 220 | handlers = configuration.mediaChannelHandlers 221 | 222 | _connectionTimer = ConnectionTimer( 223 | monitors: [ 224 | .signalingChannel(signalingChannel), 225 | .peerChannel(_peerChannel!), 226 | ], 227 | timeout: configuration.connectionTimeout) 228 | } 229 | 230 | // MARK: - 接続 231 | 232 | private var _handler: ((_ error: Error?) -> Void)? 233 | 234 | private func executeHandler(error: Error?) { 235 | _handler?(error) 236 | _handler = nil 237 | } 238 | 239 | /// サーバーに接続します。 240 | /// 241 | /// - parameter webRTCConfiguration: WebRTC の設定 242 | /// - parameter timeout: タイムアウトまでの秒数 243 | /// - parameter handler: 接続試行後に呼ばれるクロージャー 244 | /// - parameter error: (接続失敗時) エラー 245 | func connect( 246 | webRTCConfiguration: WebRTCConfiguration, 247 | timeout: Int = 30, 248 | handler: @escaping (_ error: Error?) -> Void 249 | ) -> ConnectionTask { 250 | let task = ConnectionTask() 251 | if state.isConnecting { 252 | handler( 253 | SoraError.connectionBusy( 254 | reason: 255 | "MediaChannel is already connected")) 256 | task.complete() 257 | return task 258 | } 259 | 260 | DispatchQueue.global().async { [weak self] in 261 | self?.basicConnect( 262 | connectionTask: task, 263 | webRTCConfiguration: webRTCConfiguration, 264 | timeout: timeout, 265 | handler: handler) 266 | } 267 | return task 268 | } 269 | 270 | private func basicConnect( 271 | connectionTask: ConnectionTask, 272 | webRTCConfiguration: WebRTCConfiguration, 273 | timeout: Int, 274 | handler: @escaping (Error?) -> Void 275 | ) { 276 | Logger.debug(type: .mediaChannel, message: "try connecting") 277 | _handler = handler 278 | state = .connecting 279 | connectionStartTime = nil 280 | connectionTask.peerChannel = peerChannel 281 | 282 | signalingChannel.internalHandlers.onDisconnect = { [weak self] error, reason in 283 | guard let weakSelf = self else { 284 | return 285 | } 286 | if weakSelf.state == .connecting || weakSelf.state == .connected { 287 | weakSelf.internalDisconnect(error: error, reason: reason) 288 | } 289 | connectionTask.complete() 290 | } 291 | 292 | peerChannel.internalHandlers.onDisconnect = { [weak self] error, reason in 293 | guard let weakSelf = self else { 294 | return 295 | } 296 | if weakSelf.state == .connecting || weakSelf.state == .connected { 297 | weakSelf.internalDisconnect(error: error, reason: reason) 298 | } 299 | connectionTask.complete() 300 | } 301 | 302 | peerChannel.internalHandlers.onAddStream = { [weak self] stream in 303 | guard let weakSelf = self else { 304 | return 305 | } 306 | Logger.debug(type: .mediaChannel, message: "added a stream") 307 | Logger.debug(type: .mediaChannel, message: "call onAddStream") 308 | weakSelf.internalHandlers.onAddStream?(stream) 309 | weakSelf.handlers.onAddStream?(stream) 310 | } 311 | 312 | peerChannel.internalHandlers.onRemoveStream = { [weak self] stream in 313 | guard let weakSelf = self else { 314 | return 315 | } 316 | Logger.debug(type: .mediaChannel, message: "removed a stream") 317 | Logger.debug(type: .mediaChannel, message: "call onRemoveStream") 318 | weakSelf.internalHandlers.onRemoveStream?(stream) 319 | weakSelf.handlers.onRemoveStream?(stream) 320 | } 321 | 322 | peerChannel.internalHandlers.onReceiveSignaling = { [weak self] message in 323 | guard let weakSelf = self else { 324 | return 325 | } 326 | Logger.debug(type: .mediaChannel, message: "receive signaling") 327 | switch message { 328 | case .notify(let message): 329 | // connectionCount, channelRecvonlyConnections, channelSendonlyConnections, channelSendrecvConnections 330 | // 全てに値が入っていた時のみプロパティを更新する 331 | if let connectionCount = message.connectionCount, 332 | let sendonlyConnections = message.channelSendonlyConnections, 333 | let recvonlyConnections = message.channelRecvonlyConnections, 334 | let sendrecvConnections = message.channelSendrecvConnections 335 | { 336 | weakSelf.publisherCount = sendonlyConnections + sendrecvConnections 337 | weakSelf.subscriberCount = recvonlyConnections + sendrecvConnections 338 | weakSelf.connectionCount = connectionCount 339 | } else { 340 | } 341 | default: 342 | break 343 | } 344 | 345 | Logger.debug(type: .mediaChannel, message: "call onReceiveSignaling") 346 | weakSelf.internalHandlers.onReceiveSignaling?(message) 347 | weakSelf.handlers.onReceiveSignaling?(message) 348 | } 349 | 350 | peerChannel.connect { [weak self] error in 351 | guard let weakSelf = self else { 352 | return 353 | } 354 | 355 | weakSelf.connectionTimer.stop() 356 | connectionTask.complete() 357 | 358 | if let error { 359 | Logger.error(type: .mediaChannel, message: "failed to connect") 360 | weakSelf.internalDisconnect(error: error, reason: .signalingFailure) 361 | handler(error) 362 | 363 | Logger.debug(type: .mediaChannel, message: "call onConnect") 364 | weakSelf.internalHandlers.onConnect?(error) 365 | weakSelf.handlers.onConnect?(error) 366 | return 367 | } 368 | Logger.debug(type: .mediaChannel, message: "did connect") 369 | weakSelf.state = .connected 370 | handler(nil) 371 | Logger.debug(type: .mediaChannel, message: "call onConnect") 372 | weakSelf.internalHandlers.onConnect?(nil) 373 | weakSelf.handlers.onConnect?(nil) 374 | } 375 | 376 | connectionStartTime = Date() 377 | connectionTimer.run { 378 | Logger.error(type: .mediaChannel, message: "connection timeout") 379 | self.internalDisconnect(error: SoraError.connectionTimeout, reason: .signalingFailure) 380 | } 381 | } 382 | 383 | /// 接続を解除します。 384 | /// 385 | /// - parameter error: 接続解除の原因となったエラー 386 | public func disconnect(error: Error?) { 387 | // reason に .user を指定しているので、 disconnect は SDK 内部では利用しない 388 | internalDisconnect(error: error, reason: .user) 389 | } 390 | 391 | func internalDisconnect(error: Error?, reason: DisconnectReason) { 392 | switch state { 393 | case .disconnecting, .disconnected: 394 | break 395 | 396 | default: 397 | Logger.debug(type: .mediaChannel, message: "try disconnecting") 398 | if let error { 399 | Logger.error( 400 | type: .mediaChannel, 401 | message: "error: \(error.localizedDescription)") 402 | } 403 | 404 | if state == .connecting { 405 | executeHandler(error: error) 406 | } 407 | 408 | state = .disconnecting 409 | connectionTimer.stop() 410 | peerChannel.disconnect(error: error, reason: reason) 411 | Logger.debug(type: .mediaChannel, message: "did disconnect") 412 | state = .disconnected 413 | 414 | Logger.debug(type: .mediaChannel, message: "call onDisconnect") 415 | internalHandlers.onDisconnectLegacy?(error) 416 | handlers.onDisconnectLegacy?(error) 417 | 418 | // クロージャを用いて、エラーの内容に応じた SoraCloseEvent を生成 419 | // error が nil の場合はクライアントからの正常終了 or DataChannel のみのシグナリング利用時の正常終了として .ok にする 420 | // error が SoraError の場合はケースに応じて .ok と .error を切り替える 421 | // error が SoraError の場合はクライアントが disconnect に渡した error のため、そのまま .error とする 422 | let disconnectEvent: SoraCloseEvent = { 423 | guard let error = error else { 424 | return SoraCloseEvent.ok(code: 1000, reason: "NO-ERROR") 425 | } 426 | if let soraError = error as? SoraError { 427 | switch soraError { 428 | case .webSocketClosed(let code, let reason): 429 | // 基本的に reason が nil なるケースはないはずだが、nil の場合は空文字列とする 430 | return SoraCloseEvent.ok(code: code.intValue(), reason: reason ?? "") 431 | case .dataChannelClosed(let code, let reason): 432 | return SoraCloseEvent.ok(code: code, reason: reason) 433 | default: 434 | return SoraCloseEvent.error(error) 435 | } 436 | } else { 437 | return SoraCloseEvent.error(error) 438 | } 439 | }() 440 | 441 | handlers.onDisconnect?(disconnectEvent) 442 | } 443 | } 444 | 445 | /// DataChannel を利用してメッセージを送信します 446 | public func sendMessage(label: String, data: Data) -> Error? { 447 | guard peerChannel.switchedToDataChannel else { 448 | return SoraError.messagingError(reason: "DataChannel is not open yet") 449 | } 450 | 451 | guard label.starts(with: "#") else { 452 | return SoraError.messagingError(reason: "label should start with #") 453 | } 454 | 455 | guard let dc = peerChannel.dataChannels[label] else { 456 | return SoraError.messagingError(reason: "no DataChannel found: label => \(label)") 457 | } 458 | 459 | let readyState = dc.readyState 460 | guard readyState == .open else { 461 | return SoraError.messagingError( 462 | reason: 463 | "readyState of the DataChannel is not open: label => \(label), readyState => \(readyState)" 464 | ) 465 | } 466 | 467 | let result = dc.send(data) 468 | 469 | return result 470 | ? nil : SoraError.messagingError(reason: "failed to send message: label => \(label)") 471 | } 472 | } 473 | 474 | extension MediaChannel: CustomStringConvertible { 475 | /// :nodoc: 476 | public var description: String { 477 | "MediaChannel(clientId: \(clientId ?? "-"), role: \(configuration.role))" 478 | } 479 | } 480 | 481 | /// :nodoc: 482 | extension MediaChannel: Equatable { 483 | public static func == (lhs: MediaChannel, rhs: MediaChannel) -> Bool { 484 | ObjectIdentifier(lhs) == ObjectIdentifier(rhs) 485 | } 486 | } 487 | -------------------------------------------------------------------------------- /Sora/MediaChannelConfiguration.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @available(*, unavailable, message: "このクラスは廃止予定です。廃止後も利用したい場合はこのクラス定義をご自身のソースに組み込んで利用してください。") 4 | public class MediaChannelConfiguration { 5 | public static var maxBitRate = 5000 6 | 7 | public var connectionMetadata: String? 8 | public var connectionTimeout: Int = 30 9 | public var multistreamEnabled: Bool = false 10 | public var videoCodec: VideoCodec = .default 11 | public var audioCodec: AudioCodec = .default 12 | public var videoEnabled: Bool = true 13 | public var audioEnabled: Bool = true 14 | public var snapshotEnabled: Bool = false 15 | 16 | // TODO: 17 | 18 | // TODO: RTCConfiguration 19 | } 20 | -------------------------------------------------------------------------------- /Sora/MediaStream.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebRTC 3 | 4 | /// ストリームの音声のボリュームの定数のリストです。 5 | public enum MediaStreamAudioVolume { 6 | /// 最小値 7 | public static let min: Double = 0 8 | 9 | /// 最大値 10 | public static let max: Double = 10 11 | } 12 | 13 | /// ストリームのイベントハンドラです。 14 | public final class MediaStreamHandlers { 15 | /// 映像トラックが有効または無効にセットされたときに呼ばれるクロージャー 16 | public var onSwitchVideo: ((_ isEnabled: Bool) -> Void)? 17 | 18 | /// 音声トラックが有効または無効にセットされたときに呼ばれるクロージャー 19 | public var onSwitchAudio: ((_ isEnabled: Bool) -> Void)? 20 | 21 | /// 初期化します。 22 | public init() {} 23 | } 24 | 25 | /// メディアストリームの機能を定義したプロトコルです。 26 | /// デフォルトの実装は非公開 (`internal`) であり、カスタマイズはイベントハンドラでのみ可能です。 27 | /// ソースコードは公開していますので、実装の詳細はそちらを参照してください。 28 | /// 29 | /// メディアストリームは映像と音声の送受信を行います。 30 | /// メディアストリーム 1 つにつき、 1 つの映像と 1 つの音声を送受信可能です。 31 | public protocol MediaStream: AnyObject { 32 | // MARK: - イベントハンドラ 33 | 34 | /// イベントハンドラ 35 | var handlers: MediaStreamHandlers { get } 36 | 37 | // MARK: - 接続情報 38 | 39 | /// ストリーム ID 40 | var streamId: String { get } 41 | 42 | /// 接続開始時刻 43 | var creationTime: Date { get } 44 | 45 | /// メディアチャンネル 46 | var mediaChannel: MediaChannel? { get } 47 | 48 | // MARK: - 映像と音声の可否 49 | 50 | /// 映像の可否。 51 | /// ``false`` をセットすると、サーバーへの映像の送受信を停止します。 52 | /// ``true`` をセットすると送受信を再開します。 53 | var videoEnabled: Bool { get set } 54 | 55 | /// 音声の可否。 56 | /// ``false`` をセットすると、サーバーへの音声の送受信を停止します。 57 | /// ``true`` をセットすると送受信を再開します。 58 | /// 59 | /// サーバーへの送受信を停止しても、マイクはミュートされませんので注意してください。 60 | var audioEnabled: Bool { get set } 61 | 62 | /// 受信した音声のボリューム。 0 から 10 (含む) までの値をセットします。 63 | /// このプロパティはロールがサブスクライバーの場合のみ有効です。 64 | var remoteAudioVolume: Double? { get set } 65 | 66 | // MARK: 映像フレームの送信 67 | 68 | /// 映像フィルター 69 | var videoFilter: VideoFilter? { get set } 70 | 71 | /// 映像レンダラー。 72 | var videoRenderer: VideoRenderer? { get set } 73 | 74 | /// 映像フレームをサーバーに送信します。 75 | /// 送信される映像フレームは映像フィルターを通して加工されます。 76 | /// 映像レンダラーがセットされていれば、加工後の映像フレームが 77 | /// 映像レンダラーによって描画されます。 78 | /// 79 | /// - parameter videoFrame: 描画する映像フレーム。 80 | /// `nil` を指定すると空の映像フレームを送信します。 81 | func send(videoFrame: VideoFrame?) 82 | 83 | // MARK: 終了処理 84 | 85 | /// ストリームの終了処理を行います。 86 | func terminate() 87 | } 88 | 89 | class BasicMediaStream: MediaStream { 90 | let handlers = MediaStreamHandlers() 91 | 92 | var peerChannel: PeerChannel 93 | 94 | var streamId: String = "" 95 | var videoTrackId: String = "" 96 | var audioTrackId: String = "" 97 | var creationTime: Date 98 | 99 | var mediaChannel: MediaChannel? { 100 | // MediaChannel は必ず存在するが、 MediaChannel と PeerChannel の循環参照を避けるために、 PeerChannel は MediaChannel を弱参照で保持している 101 | // mediaChannel を force unwrapping することも検討したが、エラーによる切断処理中なども安全である確信が持てなかったため、 102 | // SDK 側で force unwrapping することは避ける 103 | peerChannel.mediaChannel 104 | } 105 | 106 | var videoFilter: VideoFilter? 107 | 108 | var videoRenderer: VideoRenderer? { 109 | get { 110 | videoRendererAdapter?.videoRenderer 111 | } 112 | set { 113 | if let value = newValue { 114 | videoRendererAdapter = 115 | VideoRendererAdapter(videoRenderer: value) 116 | value.onAdded(from: self) 117 | } else { 118 | videoRendererAdapter?.videoRenderer? 119 | .onRemoved(from: self) 120 | videoRendererAdapter = nil 121 | } 122 | } 123 | } 124 | 125 | private var videoRendererAdapter: VideoRendererAdapter? { 126 | willSet { 127 | guard let videoTrack = nativeVideoTrack else { return } 128 | guard let adapter = videoRendererAdapter else { return } 129 | Logger.debug( 130 | type: .videoRenderer, 131 | message: "remove old video renderer \(adapter) from nativeVideoTrack") 132 | videoTrack.remove(adapter) 133 | } 134 | didSet { 135 | guard let videoTrack = nativeVideoTrack else { return } 136 | guard let adapter = videoRendererAdapter else { return } 137 | Logger.debug( 138 | type: .videoRenderer, 139 | message: "add new video renderer \(adapter) to nativeVideoTrack") 140 | videoTrack.add(adapter) 141 | } 142 | } 143 | 144 | var nativeStream: RTCMediaStream 145 | 146 | var nativeVideoTrack: RTCVideoTrack? { 147 | nativeStream.videoTracks.first 148 | } 149 | 150 | var nativeVideoSource: RTCVideoSource? { 151 | nativeVideoTrack?.source 152 | } 153 | 154 | var nativeAudioTrack: RTCAudioTrack? { 155 | nativeStream.audioTracks.first 156 | } 157 | 158 | var videoEnabled: Bool { 159 | get { 160 | nativeVideoTrack?.isEnabled ?? false 161 | } 162 | set { 163 | guard videoEnabled != newValue else { 164 | return 165 | } 166 | if let track = nativeVideoTrack { 167 | track.isEnabled = newValue 168 | handlers.onSwitchVideo?(newValue) 169 | videoRenderer?.onSwitch(video: newValue) 170 | } 171 | } 172 | } 173 | 174 | var audioEnabled: Bool { 175 | get { 176 | nativeAudioTrack?.isEnabled ?? false 177 | } 178 | set { 179 | guard audioEnabled != newValue else { 180 | return 181 | } 182 | if let track = nativeAudioTrack { 183 | track.isEnabled = newValue 184 | handlers.onSwitchAudio?(newValue) 185 | videoRenderer?.onSwitch(audio: newValue) 186 | } 187 | } 188 | } 189 | 190 | var remoteAudioVolume: Double? { 191 | get { 192 | nativeAudioTrack?.source.volume 193 | } 194 | set { 195 | guard let newValue else { 196 | return 197 | } 198 | if let track = nativeAudioTrack { 199 | var volume = newValue 200 | if volume < MediaStreamAudioVolume.min { 201 | volume = MediaStreamAudioVolume.min 202 | } else if volume > MediaStreamAudioVolume.max { 203 | volume = MediaStreamAudioVolume.max 204 | } 205 | track.source.volume = volume 206 | Logger.debug( 207 | type: .mediaStream, 208 | message: "set audio volume \(volume)") 209 | } 210 | } 211 | } 212 | 213 | init(peerChannel: PeerChannel, nativeStream: RTCMediaStream) { 214 | self.peerChannel = peerChannel 215 | self.nativeStream = nativeStream 216 | streamId = nativeStream.streamId 217 | creationTime = Date() 218 | } 219 | 220 | func terminate() { 221 | videoRendererAdapter?.videoRenderer?.onDisconnect(from: peerChannel.mediaChannel ?? nil) 222 | } 223 | 224 | private static let dummyCapturer = RTCVideoCapturer() 225 | func send(videoFrame: VideoFrame?) { 226 | if let frame = videoFrame { 227 | // フィルターを通す 228 | let frame = videoFilter?.filter(videoFrame: frame) ?? frame 229 | switch frame { 230 | case .native(let capturer, let nativeFrame): 231 | // RTCVideoSource.capturer(_:didCapture:) の最初の引数は 232 | // 現在使われてないのでダミーでも可? -> ダミーにしました 233 | nativeVideoSource?.capturer( 234 | capturer ?? BasicMediaStream.dummyCapturer, 235 | didCapture: nativeFrame) 236 | } 237 | } else { 238 | } 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /Sora/NativePeerChannelFactory.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebRTC 3 | 4 | class WrapperVideoEncoderFactory: NSObject, RTCVideoEncoderFactory { 5 | static var shared = WrapperVideoEncoderFactory() 6 | 7 | var defaultEncoderFactory: RTCDefaultVideoEncoderFactory 8 | 9 | var simulcastEncoderFactory: RTCVideoEncoderFactorySimulcast 10 | 11 | var currentEncoderFactory: RTCVideoEncoderFactory { 12 | simulcastEnabled ? simulcastEncoderFactory : defaultEncoderFactory 13 | } 14 | 15 | var simulcastEnabled = false 16 | 17 | override init() { 18 | // Sora iOS SDK では VP8, VP9, H.264 が有効 19 | defaultEncoderFactory = RTCDefaultVideoEncoderFactory() 20 | simulcastEncoderFactory = RTCVideoEncoderFactorySimulcast( 21 | primary: defaultEncoderFactory, fallback: defaultEncoderFactory) 22 | } 23 | 24 | func createEncoder(_ info: RTCVideoCodecInfo) -> RTCVideoEncoder? { 25 | currentEncoderFactory.createEncoder(info) 26 | } 27 | 28 | func supportedCodecs() -> [RTCVideoCodecInfo] { 29 | currentEncoderFactory.supportedCodecs() 30 | } 31 | } 32 | 33 | class NativePeerChannelFactory { 34 | static var `default` = NativePeerChannelFactory() 35 | 36 | var nativeFactory: RTCPeerConnectionFactory 37 | 38 | init() { 39 | Logger.debug(type: .peerChannel, message: "create native peer channel factory") 40 | 41 | // 映像コーデックのエンコーダーとデコーダーを用意する 42 | let encoder = WrapperVideoEncoderFactory.shared 43 | let decoder = RTCDefaultVideoDecoderFactory() 44 | nativeFactory = 45 | RTCPeerConnectionFactory( 46 | encoderFactory: encoder, 47 | decoderFactory: decoder) 48 | 49 | for info in encoder.supportedCodecs() { 50 | Logger.debug( 51 | type: .peerChannel, 52 | message: "supported video encoder: \(info.name) \(info.parameters)") 53 | } 54 | for info in decoder.supportedCodecs() { 55 | Logger.debug( 56 | type: .peerChannel, 57 | message: "supported video decoder: \(info.name) \(info.parameters)") 58 | } 59 | } 60 | 61 | func createNativePeerChannel( 62 | configuration: WebRTCConfiguration, 63 | constraints: MediaConstraints, 64 | proxy: Proxy? = nil, 65 | delegate: RTCPeerConnectionDelegate? 66 | ) -> RTCPeerConnection? { 67 | if let proxy { 68 | return nativeFactory.peerConnection( 69 | with: configuration.nativeValue, 70 | constraints: constraints.nativeValue, 71 | certificateVerifier: nil, 72 | delegate: delegate, 73 | proxyType: RTCProxyType.https, 74 | proxyAgent: proxy.agent, 75 | proxyHostname: proxy.host, 76 | proxyPort: Int32(proxy.port), 77 | proxyUsername: proxy.username ?? "", 78 | proxyPassword: proxy.password ?? "") 79 | } else { 80 | return nativeFactory.peerConnection( 81 | with: configuration.nativeValue, constraints: constraints.nativeValue, 82 | delegate: delegate) 83 | } 84 | } 85 | 86 | func createNativeStream(streamId: String) -> RTCMediaStream { 87 | nativeFactory.mediaStream(withStreamId: streamId) 88 | } 89 | 90 | func createNativeVideoSource() -> RTCVideoSource { 91 | nativeFactory.videoSource() 92 | } 93 | 94 | func createNativeVideoTrack( 95 | videoSource: RTCVideoSource, 96 | trackId: String 97 | ) -> RTCVideoTrack { 98 | nativeFactory.videoTrack(with: videoSource, trackId: trackId) 99 | } 100 | 101 | func createNativeAudioSource(constraints: MediaConstraints?) -> RTCAudioSource { 102 | nativeFactory.audioSource(with: constraints?.nativeValue) 103 | } 104 | 105 | func createNativeAudioTrack( 106 | trackId: String, 107 | constraints: RTCMediaConstraints 108 | ) -> RTCAudioTrack { 109 | let audioSource = nativeFactory.audioSource(with: constraints) 110 | return nativeFactory.audioTrack(with: audioSource, trackId: trackId) 111 | } 112 | 113 | func createNativeSenderStream( 114 | streamId: String, 115 | videoTrackId: String?, 116 | audioTrackId: String?, 117 | constraints: MediaConstraints 118 | ) -> RTCMediaStream { 119 | Logger.debug( 120 | type: .nativePeerChannel, 121 | message: "create native sender stream (\(streamId))") 122 | let nativeStream = createNativeStream(streamId: streamId) 123 | 124 | if let trackId = videoTrackId { 125 | Logger.debug( 126 | type: .nativePeerChannel, 127 | message: "create native video track (\(trackId))") 128 | let videoSource = createNativeVideoSource() 129 | let videoTrack = createNativeVideoTrack( 130 | videoSource: videoSource, 131 | trackId: trackId) 132 | nativeStream.addVideoTrack(videoTrack) 133 | } 134 | 135 | if let trackId = audioTrackId { 136 | Logger.debug( 137 | type: .nativePeerChannel, 138 | message: "create native audio track (\(trackId))") 139 | let audioTrack = createNativeAudioTrack( 140 | trackId: trackId, 141 | constraints: constraints.nativeValue) 142 | nativeStream.addAudioTrack(audioTrack) 143 | } 144 | 145 | return nativeStream 146 | } 147 | 148 | // クライアント情報としての Offer SDP を生成する 149 | func createClientOfferSDP( 150 | configuration: WebRTCConfiguration, 151 | constraints: MediaConstraints, 152 | handler: @escaping (String?, Error?) -> Void 153 | ) { 154 | let peer = createNativePeerChannel( 155 | configuration: configuration, constraints: constraints, delegate: nil) 156 | 157 | // `guard let peer = peer {` と書いた場合、 Xcode 12.5 でビルド・エラーになった 158 | guard let peer2 = peer else { 159 | handler(nil, SoraError.peerChannelError(reason: "createNativePeerChannel failed")) 160 | return 161 | } 162 | 163 | let stream = createNativeSenderStream( 164 | streamId: "offer", 165 | videoTrackId: "video", 166 | audioTrackId: "audio", 167 | constraints: constraints) 168 | peer2.add(stream.videoTracks[0], streamIds: [stream.streamId]) 169 | peer2.add(stream.audioTracks[0], streamIds: [stream.streamId]) 170 | Task { 171 | do { 172 | let sdp = try await peer2.offer(for: constraints.nativeValue) 173 | handler(sdp.sdp, nil) 174 | } catch { 175 | handler(nil, error) 176 | } 177 | peer2.close() 178 | } 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /Sora/PackageInfo.swift: -------------------------------------------------------------------------------- 1 | /// :nodoc: 2 | public enum SDKInfo { 3 | // Sora iOS SDK のバージョンを定義する 4 | public static let version = "2025.2.0-canary.2" 5 | } 6 | 7 | /// WebRTC フレームワークの情報を表します。 8 | public enum WebRTCInfo { 9 | /// WebRTC フレームワークのバージョン 10 | public static let version = "M136" 11 | 12 | /// WebRTC の branch-heads 13 | public static let branch = "7103" 14 | 15 | /// WebRTC フレームワークのコミットポジション 16 | public static let commitPosition = "0" 17 | 18 | /// WebRTC フレームワークのメンテナンスバージョン 19 | public static let maintenanceVersion = "0" 20 | 21 | /// WebRTC フレームワークのソースコードのリビジョン 22 | public static let revision = "2c8f5be6924d507ee74191b1aeadcec07f747f21" 23 | 24 | /// WebRTC フレームワークのソースコードのリビジョン (短縮版) 25 | public static var shortRevision: String { 26 | String( 27 | revision[ 28 | revision 29 | .startIndex.. = 16 | PairTable( 17 | name: "Role", 18 | pairs: [ 19 | ("sendonly", .sendonly), 20 | ("recvonly", .recvonly), 21 | ("sendrecv", .sendrecv), 22 | ]) 23 | 24 | /// :nodoc: 25 | extension Role: Codable { 26 | public init(from decoder: Decoder) throws { 27 | self = try roleTable.decode(from: decoder) 28 | } 29 | 30 | public func encode(to encoder: Encoder) throws { 31 | try roleTable.encode(self, to: encoder) 32 | } 33 | } 34 | 35 | extension Role: CustomStringConvertible { 36 | /// 文字列表現を返します。 37 | public var description: String { 38 | roleTable.left(other: self)! 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sora/SignalingChannel.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import Foundation 3 | 4 | /// ストリームの方向を表します。 5 | /// シグナリングメッセージで使われます。 6 | public enum SignalingRole: String { 7 | /// 送信のみ 8 | case sendonly 9 | 10 | /// 受信のみ 11 | case recvonly 12 | 13 | /// 送受信 14 | case sendrecv 15 | } 16 | 17 | /** 18 | シグナリングチャネルのイベントハンドラです。 19 | */ 20 | 21 | class SignalingChannelInternalHandlers { 22 | /// 接続解除時に呼ばれるクロージャー 23 | var onDisconnect: ((Error?, DisconnectReason) -> Void)? 24 | 25 | /// シグナリング受信時に呼ばれるクロージャー 26 | var onReceive: ((Signaling) -> Void)? 27 | 28 | /// シグナリング送信時に呼ばれるクロージャー 29 | var onSend: ((Signaling) -> Signaling)? 30 | 31 | /// 初期化します。 32 | init() {} 33 | } 34 | 35 | class SignalingChannel { 36 | var internalHandlers = SignalingChannelInternalHandlers() 37 | 38 | var ignoreDisconnectWebSocket: Bool = false 39 | var dataChannelSignaling: Bool = false 40 | 41 | var configuration: Configuration 42 | 43 | var state: ConnectionState = .disconnected { 44 | didSet { 45 | Logger.trace( 46 | type: .signalingChannel, 47 | message: "changed state from \(oldValue) to \(state)") 48 | } 49 | } 50 | 51 | // SignalingChannel で利用する WebSocket 52 | var webSocketChannel: URLSessionWebSocketChannel? 53 | 54 | // SignalingChannel で利用する WebSocket の候補 55 | // 56 | // 接続に失敗した WebSocket は候補から削除される 57 | // SignalingChannel で利用する WebSocket が決定する前に候補が無くなった場合、 58 | // Sora への接続に失敗しているため、 MediaChannel の接続処理を終了する必要がある 59 | // 60 | // また、 SignalingChannel で利用する WebSocket が決定した場合にも空になる 61 | var webSocketChannelCandidates: [URLSessionWebSocketChannel] = [] 62 | 63 | private var onConnect: ((Error?) -> Void)? 64 | 65 | // WebSocket の接続を複数同時に試行する際の排他制御を行うためのキュー 66 | // 67 | // このキューの並行性を1に設定した上で URLSession の delegateQueue に設定することで、 68 | // URLSession のコールバックが同時に発火することを防ぎます 69 | private let queue: OperationQueue 70 | 71 | // 最初に type: connect を送信した URL 72 | var contactUrl: URL? 73 | 74 | // type: offer を Sora から受信したタイミングで設定する 75 | var connectedUrl: URL? 76 | 77 | required init(configuration: Configuration) { 78 | self.configuration = configuration 79 | 80 | let queue = OperationQueue() 81 | queue.name = "jp.shiguredo.sora-ios-sdk.websocket-delegate" 82 | queue.maxConcurrentOperationCount = 1 83 | queue.qualityOfService = .userInteractive 84 | self.queue = queue 85 | } 86 | 87 | private func unique(urls: [URL]) -> [URL] { 88 | var uniqueUrls: [URL] = [] 89 | for url in urls { 90 | var contains = false 91 | for uniqueUrl in uniqueUrls { 92 | if url.absoluteString == uniqueUrl.absoluteString { 93 | contains = true 94 | break 95 | } 96 | } 97 | 98 | if !contains { 99 | uniqueUrls.append(url) 100 | } 101 | } 102 | 103 | return uniqueUrls 104 | } 105 | 106 | private func setUpWebSocketChannel(url: URL, proxy: Proxy?) -> URLSessionWebSocketChannel { 107 | let ws = URLSessionWebSocketChannel(url: url, proxy: proxy) 108 | 109 | // 接続成功時 110 | ws.internalHandlers.onConnect = { [weak self] webSocketChannel in 111 | guard let weakSelf = self else { 112 | return 113 | } 114 | 115 | // 最初に接続に成功した WebSocket 以外は無視する 116 | guard weakSelf.webSocketChannel == nil else { 117 | // (接続に失敗した WebSocket と同様に、) 無視した WebSocket を webSocketChannelCandidates から削除することを検討したが、不要と判断した 118 | // 119 | // 最初の WebSocket が接続に成功した際に webSocketChannelCandidates をクリアするため、 120 | // 既に webSocketChannelCandidates が空になっていることが理由 121 | return 122 | } 123 | 124 | // 接続に成功した WebSocket を SignalingChannel に設定する 125 | Logger.info( 126 | type: .signalingChannel, message: "connected to \(String(describing: ws.host))") 127 | weakSelf.webSocketChannel = webSocketChannel 128 | if weakSelf.contactUrl == nil { 129 | weakSelf.contactUrl = ws.url 130 | } 131 | // 採用された WebSocket 以外を切断してから webSocketChannelCandidates をクリアする 132 | weakSelf.webSocketChannelCandidates.removeAll { $0 == webSocketChannel } 133 | for candidate in weakSelf.webSocketChannelCandidates { 134 | Logger.debug( 135 | type: .signalingChannel, 136 | message: "closeing connection to \(String(describing: candidate.host))") 137 | candidate.disconnect(error: nil) 138 | } 139 | weakSelf.webSocketChannelCandidates.removeAll() 140 | 141 | weakSelf.state = .connected 142 | 143 | if weakSelf.onConnect != nil { 144 | Logger.debug(type: .signalingChannel, message: "call connect(handler:)") 145 | weakSelf.onConnect!(nil) 146 | } 147 | } 148 | 149 | // WebSocket 切断時 150 | // 正常に切断したときも error は nil にならない 151 | ws.internalHandlers.onDisconnectWithError = { [weak self] ws, error in 152 | guard let weakSelf = self else { 153 | return 154 | } 155 | Logger.info( 156 | type: .signalingChannel, message: "disconnected from \(String(describing: ws.host))" 157 | ) 158 | 159 | if weakSelf.state == .connected { 160 | // SignalingChannel で利用する WebSocket が決定した後に、 WebSocket のエラーが発生した場合の処理 161 | // ignoreDisconnectWebSocket の値をチェックして SDK の接続処理を終了する 162 | if !weakSelf.ignoreDisconnectWebSocket { 163 | weakSelf.disconnect(error: error, reason: .webSocket) 164 | } 165 | } else { 166 | // SignalingChannel で利用する WebSocket が決定する前に、 WebSocket のエラーが発生した場合の処理 167 | // state が .disconnecting, .disconnected の場合もここを通るが、既に SignalingChannel の切断を開始しているため、考慮は不要 168 | 169 | // 接続に失敗した WebSocket が候補に残っている場合取り除く 170 | weakSelf.webSocketChannelCandidates.removeAll { 171 | $0.url.absoluteURL == ws.url.absoluteURL 172 | } 173 | 174 | // 候補が無くなり、かつ SignalingChannel で利用する WebSocket が決まっていない場合、 175 | // Sora への接続に失敗したので SDK の接続処理を終了する 176 | if weakSelf.webSocketChannelCandidates.count == 0, weakSelf.webSocketChannel == nil { 177 | Logger.info(type: .signalingChannel, message: "failed to connect to Sora") 178 | if !weakSelf.ignoreDisconnectWebSocket { 179 | weakSelf.disconnect(error: error, reason: .webSocket) 180 | } 181 | } 182 | } 183 | } 184 | 185 | ws.handlers = configuration.webSocketChannelHandlers 186 | // メッセージ受信時 187 | ws.internalHandlers.onReceive = { [weak self] message in 188 | self?.handle(message: message) 189 | } 190 | 191 | return ws 192 | } 193 | 194 | func connect(handler: @escaping (Error?) -> Void) { 195 | if state.isConnecting { 196 | handler( 197 | SoraError.connectionBusy( 198 | reason: 199 | "SignalingChannel is already connected")) 200 | return 201 | } 202 | 203 | Logger.debug(type: .signalingChannel, message: "try connecting") 204 | onConnect = handler 205 | state = .connecting 206 | 207 | let urlCandidates = unique(urls: configuration.urlCandidates) 208 | Logger.info(type: .signalingChannel, message: "urlCandidates: \(urlCandidates)") 209 | for url in urlCandidates { 210 | let ws = setUpWebSocketChannel(url: url, proxy: configuration.proxy) 211 | Logger.info( 212 | type: .signalingChannel, message: "connecting to \(String(describing: ws.url))") 213 | ws.connect(delegateQueue: queue) 214 | webSocketChannelCandidates.append(ws) 215 | } 216 | } 217 | 218 | func redirect(location: String) { 219 | Logger.debug(type: .signalingChannel, message: "try redirecting to \(location)") 220 | state = .connecting 221 | 222 | // 切断 223 | webSocketChannel?.disconnect(error: nil) 224 | webSocketChannel = nil 225 | 226 | // 接続 227 | guard let newUrl = URL(string: location) else { 228 | let message = "invalid message: \(location)" 229 | Logger.error(type: .signalingChannel, message: message) 230 | disconnect( 231 | error: SoraError.signalingChannelError(reason: message), 232 | reason: DisconnectReason.signalingFailure) 233 | return 234 | } 235 | 236 | let ws = setUpWebSocketChannel(url: newUrl, proxy: configuration.proxy) 237 | ws.connect(delegateQueue: queue) 238 | } 239 | 240 | func disconnect(error: Error?, reason: DisconnectReason) { 241 | switch state { 242 | case .disconnecting, .disconnected: 243 | break 244 | default: 245 | Logger.debug(type: .signalingChannel, message: "try disconnecting") 246 | if let error { 247 | Logger.error( 248 | type: .signalingChannel, 249 | message: "error: \(error.localizedDescription)") 250 | } 251 | 252 | state = .disconnecting 253 | webSocketChannel?.disconnect(error: nil) 254 | for candidate in webSocketChannelCandidates { 255 | candidate.disconnect(error: nil) 256 | } 257 | state = .disconnected 258 | 259 | Logger.debug(type: .signalingChannel, message: "call onDisconnect") 260 | internalHandlers.onDisconnect?(error, reason) 261 | 262 | contactUrl = nil 263 | connectedUrl = nil 264 | Logger.debug(type: .signalingChannel, message: "did disconnect") 265 | } 266 | } 267 | 268 | func send(message: Signaling) { 269 | guard let ws = webSocketChannel else { 270 | Logger.info(type: .signalingChannel, message: "failed to unwrap webSocketChannel") 271 | return 272 | } 273 | 274 | Logger.debug(type: .signalingChannel, message: "send message") 275 | let message = internalHandlers.onSend?(message) ?? message 276 | let encoder = JSONEncoder() 277 | do { 278 | var data = try encoder.encode(message) 279 | 280 | // type: connect の data_channels を設定する 281 | // Signaling.encode(to:) では Any を扱えなかったため、文字列に変換する直前に値を設定している 282 | switch message { 283 | case .connect: 284 | if configuration.dataChannels != nil { 285 | var jsonObject = 286 | try (JSONSerialization.jsonObject(with: data, options: [])) 287 | as! [String: Any] 288 | jsonObject["data_channels"] = configuration.dataChannels 289 | data = try JSONSerialization.data(withJSONObject: jsonObject, options: []) 290 | } 291 | default: 292 | break 293 | } 294 | 295 | let str = String(data: data, encoding: .utf8)! 296 | Logger.debug(type: .signalingChannel, message: str) 297 | ws.send(message: .text(str)) 298 | } catch { 299 | Logger.debug( 300 | type: .signalingChannel, 301 | message: "JSON encoding failed") 302 | } 303 | } 304 | 305 | func send(text: String) { 306 | guard let ws = webSocketChannel else { 307 | Logger.info(type: .signalingChannel, message: "failed to unwrap webSocketChannel") 308 | return 309 | } 310 | 311 | ws.send(message: .text(text)) 312 | } 313 | 314 | func handle(message: WebSocketMessage) { 315 | Logger.debug(type: .signalingChannel, message: "receive message") 316 | switch message { 317 | case .binary: 318 | Logger.debug(type: .signalingChannel, message: "discard binary message") 319 | 320 | case .text(let text): 321 | guard let data = text.data(using: .utf8) else { 322 | Logger.error(type: .signalingChannel, message: "invalid encoding") 323 | return 324 | } 325 | 326 | switch Signaling.decode(data) { 327 | case .success(let signaling): 328 | Logger.debug(type: .signalingChannel, message: "call onReceiveSignaling") 329 | internalHandlers.onReceive?(signaling) 330 | case .failure(let error): 331 | Logger.error( 332 | type: .signalingChannel, 333 | message: "decode failed (\(error.localizedDescription)) => \(text)") 334 | } 335 | } 336 | } 337 | 338 | func setConnectedUrl() { 339 | guard let ws = webSocketChannel else { 340 | return 341 | } 342 | connectedUrl = ws.url 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /Sora/Sora.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | //! Project version number for Sora. 4 | FOUNDATION_EXPORT double SoraVersionNumber; 5 | 6 | //! Project version string for Sora. 7 | FOUNDATION_EXPORT const unsigned char SoraVersionString[]; 8 | 9 | // In this header, you should import all the public headers of your framework using statements like #import 10 | 11 | 12 | -------------------------------------------------------------------------------- /Sora/Sora.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import Foundation 3 | import WebRTC 4 | 5 | /// `Sora` オブジェクトのイベントハンドラです。 6 | public final class SoraHandlers { 7 | /// 接続成功時に呼ばれるクロージャー 8 | public var onConnect: ((MediaChannel?, Error?) -> Void)? 9 | 10 | /// 接続解除時に呼ばれるクロージャー 11 | public var onDisconnect: ((MediaChannel, Error?) -> Void)? 12 | 13 | /// メディアチャネルが追加されたときに呼ばれるクロージャー 14 | public var onAddMediaChannel: ((MediaChannel) -> Void)? 15 | 16 | /// メディアチャネルが除去されたときに呼ばれるクロージャー 17 | public var onRemoveMediaChannel: ((MediaChannel) -> Void)? 18 | 19 | /// 初期化します。 20 | public init() {} 21 | } 22 | 23 | /// サーバーへのインターフェースです。 24 | /// `Sora` オブジェクトを使用してサーバーへの接続を行います。 25 | public final class Sora { 26 | // MARK: - SDK の操作 27 | 28 | private static let isInitialized: Bool = { 29 | initialize() 30 | return true 31 | }() 32 | 33 | private static func initialize() { 34 | Logger.debug(type: .sora, message: "initialize SDK") 35 | RTCInitializeSSL() 36 | RTCEnableMetrics() 37 | } 38 | 39 | /// SDK の終了処理を行います。 40 | /// アプリケーションの終了と同時に SDK の使用を終了する場合、 41 | /// この関数を呼ぶ必要はありません。 42 | public static func finish() { 43 | Logger.debug(type: .sora, message: "finish SDK") 44 | RTCShutdownInternalTracer() 45 | RTCCleanupSSL() 46 | } 47 | 48 | /// ログレベル。指定したレベルより高いログは出力されません。 49 | /// デフォルトは `info` です。 50 | public static var logLevel: LogLevel { 51 | get { 52 | Logger.shared.level 53 | } 54 | set { 55 | Logger.shared.level = newValue 56 | } 57 | } 58 | 59 | // MARK: - プロパティ 60 | 61 | /// 接続中のメディアチャネルのリスト 62 | public private(set) var mediaChannels: [MediaChannel] = [] 63 | 64 | /// イベントハンドラ 65 | public let handlers = SoraHandlers() 66 | 67 | // MARK: - インスタンスの生成と取得 68 | 69 | /// シングルトンインスタンス 70 | public static let shared = Sora() 71 | 72 | /// 初期化します。 73 | /// 大抵の用途ではシングルトンインスタンスで問題なく、 74 | /// インスタンスを生成する必要はないでしょう。 75 | /// メディアチャネルのリストをグループに分けたい、 76 | /// または複数のイベントハンドラを使いたいなどの場合に 77 | /// インスタンスを生成してください。 78 | public init() { 79 | // This will guarantee that `Sora.initialize()` is called only once. 80 | // - It works even if user initialized `Sora` directly 81 | // - It works even if user directly use `Sora.shared` 82 | // - It guarantees `initialize()` is called only once thanks to the `static let` https://developer.apple.com/library/content/documentation/Swift/Conceptual/Swift_Programming_Language/Properties.html#//apple_ref/doc/uid/TP40014097-CH14-ID254 83 | let initialized = Sora.isInitialized 84 | // This looks silly, but this will ensure `Sora.isInitialized` is not be omitted, 85 | // no matter how clang optimizes compilation. 86 | // If we go for `let _ = Sora.isInitialized`, clang may omit this line, 87 | // which is fatal to the initialization logic. 88 | // The following line will NEVER fail. 89 | if !initialized { fatalError() } 90 | } 91 | 92 | // MARK: - メディアチャネルの管理 93 | 94 | func add(mediaChannel: MediaChannel) { 95 | DispatchQueue.global().sync { 96 | if !mediaChannels.contains(mediaChannel) { 97 | Logger.debug(type: .sora, message: "add media channel") 98 | mediaChannels.append(mediaChannel) 99 | handlers.onAddMediaChannel?(mediaChannel) 100 | } 101 | } 102 | } 103 | 104 | func remove(mediaChannel: MediaChannel) { 105 | DispatchQueue.global().sync { 106 | if mediaChannels.contains(mediaChannel) { 107 | Logger.debug(type: .sora, message: "remove media channel") 108 | mediaChannels.remove(mediaChannel) 109 | handlers.onRemoveMediaChannel?(mediaChannel) 110 | } 111 | } 112 | } 113 | 114 | // MARK: - 接続 115 | 116 | /// サーバーに接続します。 117 | /// 118 | /// - parameter configuration: クライアントの設定 119 | /// - parameter webRTCConfiguration: WebRTC の設定 120 | /// - parameter handler: 接続試行後に呼ばれるクロージャー。 121 | /// - parameter mediaChannel: (接続成功時のみ) メディアチャネル 122 | /// - parameter error: (接続失敗時のみ) エラー 123 | /// - returns: 接続試行中の状態 124 | public func connect( 125 | configuration: Configuration, 126 | webRTCConfiguration: WebRTCConfiguration = WebRTCConfiguration(), 127 | handler: @escaping ( 128 | _ mediaChannel: MediaChannel?, 129 | _ error: Error? 130 | ) -> Void 131 | ) -> ConnectionTask { 132 | let mediaChan = MediaChannel(manager: self, configuration: configuration) 133 | mediaChan.internalHandlers.onDisconnectLegacy = { [weak self, weak mediaChan] error in 134 | guard let weakSelf = self else { 135 | return 136 | } 137 | guard let mediaChan else { 138 | return 139 | } 140 | weakSelf.remove(mediaChannel: mediaChan) 141 | weakSelf.handlers.onDisconnect?(mediaChan, error) 142 | } 143 | 144 | // MediaChannel.connect() の引数のハンドラが実行されるまで 145 | // 解放されないように先にリストに追加しておく 146 | // ただ、 mediaChannels を weak array にすべきかもしれない 147 | add(mediaChannel: mediaChan) 148 | 149 | return mediaChan.connect(webRTCConfiguration: webRTCConfiguration) { 150 | [weak self, weak mediaChan] error in 151 | guard let weakSelf = self else { 152 | return 153 | } 154 | guard let mediaChan else { 155 | return 156 | } 157 | 158 | if let error { 159 | handler(nil, error) 160 | weakSelf.handlers.onConnect?(nil, error) 161 | return 162 | } 163 | 164 | handler(mediaChan, nil) 165 | weakSelf.handlers.onConnect?(mediaChan, nil) 166 | } 167 | } 168 | 169 | // MARK: - 音声ユニットの操作 170 | 171 | /// 音声ユニットの手動による初期化の可否。 172 | /// ``false`` をセットした場合、音声トラックの生成時に音声ユニットが自動的に初期化されます。 173 | /// (音声ユニットを使用するには ``audioEnabled`` に ``true`` をセットして初期化する必要があります) 174 | /// ``true`` をセットした場合、音声ユニットは自動的に初期化されません。 175 | /// デフォルトは ``false`` です。 176 | public var usesManualAudio: Bool { 177 | get { 178 | RTCAudioSession.sharedInstance().useManualAudio 179 | } 180 | set { 181 | RTCAudioSession.sharedInstance().useManualAudio = newValue 182 | } 183 | } 184 | 185 | /// 音声ユニットの使用の可否。 186 | /// このプロパティは ``usesManualAudio`` が ``true`` の場合のみ有効です。 187 | /// デフォルトは ``false`` です。 188 | /// 189 | /// ``true`` をセットした場合、音声ユニットは必要に応じて初期化されます。 190 | /// ``false`` をセットした場合、すでに音声ユニットが初期化済みで起動されていれば、 191 | /// 音声ユニットを停止します。 192 | /// 193 | /// このプロパティを使用すると、音声ユニットの初期化によって 194 | /// AVPlayer などによる再生中の音声が中断されてしまうことを防げます。 195 | public var audioEnabled: Bool { 196 | get { 197 | RTCAudioSession.sharedInstance().isAudioEnabled 198 | } 199 | set { 200 | RTCAudioSession.sharedInstance().isAudioEnabled = newValue 201 | } 202 | } 203 | 204 | /// ``AVAudioSession`` の設定を変更する際に使います。 205 | /// WebRTC で使用中のスレッドをロックします。 206 | /// このメソッドは次のプロパティとメソッドの使用時に使ってください。 207 | /// 208 | /// - ``category`` 209 | /// - ``categoryOptions`` 210 | /// - ``mode`` 211 | /// - ``secondaryAudioShouldBeSilencedHint`` 212 | /// - ``currentRoute`` 213 | /// - ``maximumInputNumberOfChannels`` 214 | /// - ``maximumOutputNumberOfChannels`` 215 | /// - ``inputGain`` 216 | /// - ``inputGainSettable`` 217 | /// - ``inputAvailable`` 218 | /// - ``inputDataSources`` 219 | /// - ``inputDataSource`` 220 | /// - ``outputDataSources`` 221 | /// - ``outputDataSource`` 222 | /// - ``sampleRate`` 223 | /// - ``preferredSampleRate`` 224 | /// - ``inputNumberOfChannels`` 225 | /// - ``outputNumberOfChannels`` 226 | /// - ``outputVolume`` 227 | /// - ``inputLatency`` 228 | /// - ``outputLatency`` 229 | /// - ``ioBufferDuration`` 230 | /// - ``preferredIOBufferDuration`` 231 | /// - ``setCategory(_:withOptions:)`` 232 | /// - ``setMode(_:)`` 233 | /// - ``setInputGain(_:)`` 234 | /// - ``setPreferredSampleRate(_:)`` 235 | /// - ``setPreferredIOBufferDuration(_:)`` 236 | /// - ``setPreferredInputNumberOfChannels(_:)`` 237 | /// - ``setPreferredOutputNumberOfChannels(_:)`` 238 | /// - ``overrideOutputAudioPort(_:)`` 239 | /// - ``setPreferredInput(_:)`` 240 | /// - ``setInputDataSource(_:)`` 241 | /// - ``setOutputDataSource(_:)`` 242 | /// 243 | /// - parameter block: ロック中に実行されるクロージャー 244 | public func configureAudioSession(block: () -> Void) { 245 | let session = RTCAudioSession.sharedInstance() 246 | session.lockForConfiguration() 247 | block() 248 | session.unlockForConfiguration() 249 | } 250 | 251 | /// 音声モードを変更します。 252 | /// このメソッドは **接続完了後** に実行してください。 253 | /// 254 | /// - parameter mode: 音声モード 255 | /// - returns: 変更の成否 256 | public func setAudioMode( 257 | _ mode: AudioMode, 258 | options: AVAudioSession.CategoryOptions = [ 259 | .allowBluetooth, .allowBluetoothA2DP, .allowAirPlay, 260 | ] 261 | ) -> Result { 262 | do { 263 | var options = options 264 | let session = RTCAudioSession.sharedInstance() 265 | session.lockForConfiguration() 266 | switch mode { 267 | case .default(let category, let output): 268 | if output == .speaker { 269 | options = [options, .defaultToSpeaker] 270 | } 271 | try session.setCategory(category, with: options) 272 | try session.setMode(.default) 273 | case .videoChat: 274 | try session.setCategory(.playAndRecord, with: options) 275 | try session.setMode(.videoChat) 276 | case .voiceChat(let output): 277 | if output == .speaker { 278 | options = [options, .defaultToSpeaker] 279 | } 280 | try session.setCategory(.playAndRecord, with: options) 281 | try session.setMode(.voiceChat) 282 | if output == .speaker { 283 | try session.overrideOutputAudioPort(.speaker) 284 | } 285 | } 286 | session.unlockForConfiguration() 287 | return .success(()) 288 | } catch { 289 | return .failure(error) 290 | } 291 | } 292 | 293 | // MARK: - libwebrtc のログ出力 294 | 295 | private static var webRTCCallbackLogger: RTCCallbackLogger = { 296 | let logger = RTCCallbackLogger() 297 | logger.severity = .none 298 | return logger 299 | }() 300 | 301 | private static let webRTCLoggingDateFormatter: DateFormatter = { 302 | let formatter = DateFormatter() 303 | formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" 304 | return formatter 305 | }() 306 | 307 | /// libwebrtc のログレベルを指定します。 308 | /// ログは `RTCSetMinDebugLogLevel()` でも指定可能ですが、 `RTCSetMinDebugLogLevel()` ではログの時刻が表示されません。 309 | /// 本メソッドでログレベルを指定すると、時刻を含むログを出力します。 310 | public static func setWebRTCLogLevel(_ severity: RTCLoggingSeverity) { 311 | // RTCSetMinDebugLogLevel() でログレベルを指定すると 312 | // RTCCallbackLogger 以外のログも出力されてしまい、 313 | // ログ出力が二重になるので RTCSetMinDebugLogLevel() は使わない。 314 | webRTCCallbackLogger.severity = severity 315 | webRTCCallbackLogger.stop() 316 | webRTCCallbackLogger.start { message, callbackSeverity in 317 | let severityName: String 318 | switch callbackSeverity { 319 | case .info: 320 | severityName = "INFO" 321 | case .verbose: 322 | severityName = "VERBOSE" 323 | case .warning: 324 | severityName = "WARNING" 325 | case .error: 326 | severityName = "ERROR" 327 | case .none: 328 | return 329 | @unknown default: 330 | return 331 | } 332 | let timestamp = Date() 333 | print( 334 | "\(webRTCLoggingDateFormatter.string(from: timestamp)) libwebrtc \(severityName): \(message.trimmingCharacters(in: .whitespacesAndNewlines))" 335 | ) 336 | } 337 | } 338 | } 339 | 340 | /// サーバーへの接続試行中の状態を表します。 341 | /// `cancel()` で接続をキャンセル可能です。 342 | public final class ConnectionTask { 343 | /// 接続状態を表します。 344 | public enum State { 345 | /// 接続試行中 346 | case connecting 347 | 348 | /// 接続済み 349 | case completed 350 | 351 | /// キャンセル済み 352 | case canceled 353 | } 354 | 355 | weak var peerChannel: PeerChannel? 356 | 357 | /// 接続状態 358 | public private(set) var state: State 359 | 360 | init() { 361 | state = .connecting 362 | } 363 | 364 | /// 接続試行をキャンセルします。 365 | /// すでに接続済みであれば何もしません。 366 | public func cancel() { 367 | if state == .connecting { 368 | Logger.debug(type: .mediaChannel, message: "connection task cancelled") 369 | // reason: .user としているため、 cancel は SDK 内部で使用してはならない 370 | peerChannel?.disconnect(error: SoraError.connectionCancelled, reason: .user) 371 | state = .canceled 372 | } 373 | } 374 | 375 | func complete() { 376 | if state != .completed { 377 | Logger.debug(type: .mediaChannel, message: "connection task completed") 378 | state = .completed 379 | } 380 | } 381 | } 382 | -------------------------------------------------------------------------------- /Sora/SoraDispatcher.swift: -------------------------------------------------------------------------------- 1 | import WebRTC 2 | 3 | /// libwebrtc の内部で利用されているキューを表します。 4 | public enum SoraDispatcher { 5 | /// カメラ用のキュー 6 | case camera 7 | 8 | /// 音声処理用のキュー 9 | case audio 10 | 11 | /// 指定されたキューを利用して、 block を非同期で実行します。 12 | public static func async(on queue: SoraDispatcher, block: @escaping () -> Void) { 13 | let native: RTCDispatcherQueueType 14 | switch queue { 15 | case .camera: 16 | native = .typeCaptureSession 17 | case .audio: 18 | native = .typeAudioSession 19 | } 20 | RTCDispatcher.dispatchAsync(on: native, block: block) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sora/SoraError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// SDK に関するエラーを表します。 4 | public enum SoraError: Error { 5 | /// 接続試行中に処理がキャンセルされたことを示します。 6 | case connectionCancelled 7 | 8 | /// 接続タイムアウト。 9 | /// 接続試行開始から一定時間内に接続できなかったことを示します。 10 | case connectionTimeout 11 | 12 | /// 何らかの処理の実行中で、指定された処理を実行できないことを示します。 13 | case connectionBusy(reason: String) 14 | 15 | /// ``WebSocketChannel`` が接続解除されたことを示します。 16 | /// 導入当初は Sora から受信したクローズフレームのステータスコードが 1000 以外のときにこの Error を返していたが 17 | /// 2025.2.0 から、ステータスコードが 1000 のときも onDisconnect に切断理由を返すためにこの Error を使うようになった 18 | /// また、この Error は onDisconnect では Error ではなく、SoraCloseEvent.ok(code, reason) としてユーザーに通知される 19 | case webSocketClosed(statusCode: WebSocketStatusCode, reason: String?) 20 | 21 | /// ``WebSocketChannel`` で発生したエラー 22 | case webSocketError(Error) 23 | 24 | /// ``SignalingChannel`` で発生したエラー 25 | case signalingChannelError(reason: String) 26 | 27 | /// シグナリングメッセージのフォーマットが無効 28 | case invalidSignalingMessage 29 | 30 | /// 非対応のシグナリングメッセージ種別 31 | case unknownSignalingMessageType(type: String) 32 | 33 | /// ``PeerChannel`` で発生したエラー 34 | case peerChannelError(reason: String) 35 | 36 | /// カメラに関するエラー 37 | case cameraError(reason: String) 38 | 39 | /// メッセージング機能のエラー 40 | case messagingError(reason: String) 41 | 42 | /// DataChannel 経由のシグナリングで type: close を受信し、接続が解除されたことを示します。 43 | /// - statusCode: ステータスコード 44 | /// - reason: 切断理由 45 | case dataChannelClosed(statusCode: Int, reason: String) 46 | } 47 | 48 | /// :nodoc: 49 | extension SoraError: LocalizedError { 50 | public var errorDescription: String? { 51 | switch self { 52 | case .connectionCancelled: 53 | return "Connection is cancelled" 54 | case .connectionTimeout: 55 | return "Connection is timeout" 56 | case .connectionBusy(let reason): 57 | return "Connection is busy (\(reason))" 58 | case .webSocketClosed(let statusCode, let reason): 59 | var desc = "WebSocket is closed (\(statusCode.intValue()) " 60 | if let reason { 61 | desc.append(reason) 62 | } else { 63 | desc.append("Unknown reason") 64 | } 65 | desc.append(")") 66 | return desc 67 | case .webSocketError(let error): 68 | return "WebSocket error (\(error.localizedDescription))" 69 | case .signalingChannelError(let reason): 70 | return "SignalingChannel error (\(reason))" 71 | case .invalidSignalingMessage: 72 | return "Invalid signaling message format" 73 | case .unknownSignalingMessageType(let type): 74 | return "Unknown signaling message type \(type)" 75 | case .peerChannelError(let reason): 76 | return "PeerChannel error (\(reason))" 77 | case .cameraError(let reason): 78 | return "Camera error: \(reason)" 79 | case .messagingError(let reason): 80 | return "Messaging error: \(reason)" 81 | case .dataChannelClosed(let statusCode, let reason): 82 | return "DataChannel is closed (\(statusCode) \(reason))" 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sora/Statistics.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebRTC 3 | 4 | /// :nodoc: 5 | public class Statistics { 6 | public var timestamp: CFTimeInterval 7 | public var entries: [StatisticsEntry] = [] 8 | 9 | init(contentsOf report: RTCStatisticsReport) { 10 | timestamp = report.timestamp_us 11 | for (_, statistics) in report.statistics { 12 | let entry = StatisticsEntry(contentsOf: statistics) 13 | entries.append(entry) 14 | } 15 | } 16 | 17 | public var jsonObject: Any { 18 | let json = NSMutableArray() 19 | for entry in entries { 20 | var map: [String: Any] = [:] 21 | map["id"] = entry.id 22 | map["type"] = entry.type 23 | map["timestamp"] = entry.timestamp 24 | map.merge(entry.values, uniquingKeysWith: { a, _ in a }) 25 | json.add(map as NSDictionary) 26 | } 27 | return json 28 | } 29 | } 30 | 31 | /// :nodoc: 32 | public class StatisticsEntry { 33 | public var id: String 34 | public var type: String 35 | public var timestamp: CFTimeInterval 36 | public var values: [String: NSObject] 37 | 38 | init(contentsOf statistics: RTCStatistics) { 39 | id = statistics.id 40 | type = statistics.type 41 | timestamp = statistics.timestamp_us 42 | values = statistics.values 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sora/TLSSecurityPolicy.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebRTC 3 | 4 | private var tlsSecurityPolicyTable: [TLSSecurityPolicy: RTCTlsCertPolicy] = 5 | [.secure: .secure, .insecure: .insecureNoCheck] 6 | 7 | /// TLS のセキュリティポリシーを表します。 8 | public enum TLSSecurityPolicy { 9 | /// サーバー証明書を確認します。 10 | case secure 11 | 12 | /// サーバー証明書を確認しません。 13 | case insecure 14 | 15 | var nativeValue: RTCTlsCertPolicy { 16 | tlsSecurityPolicyTable[self]! 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sora/URLSessionWebSocketChannel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @available(iOS 13, *) 4 | class URLSessionWebSocketChannel: NSObject, URLSessionDelegate, URLSessionTaskDelegate, 5 | URLSessionWebSocketDelegate 6 | { 7 | let url: URL 8 | let proxy: Proxy? 9 | var handlers = WebSocketChannelHandlers() 10 | var internalHandlers = WebSocketChannelInternalHandlers() 11 | var isClosing = false 12 | 13 | var host: String { 14 | guard let host = url.host else { 15 | return url.absoluteString 16 | } 17 | return host 18 | } 19 | 20 | var urlSession: URLSession? 21 | var webSocketTask: URLSessionWebSocketTask? 22 | 23 | init(url: URL, proxy: Proxy?) { 24 | self.url = url 25 | self.proxy = proxy 26 | } 27 | 28 | func connect(delegateQueue: OperationQueue?) { 29 | let configuration = URLSessionConfiguration.ephemeral 30 | 31 | if let proxy { 32 | configuration.connectionProxyDictionary = [ 33 | kCFNetworkProxiesHTTPProxy: proxy.host, 34 | kCFNetworkProxiesHTTPPort: proxy.port, 35 | kCFNetworkProxiesHTTPEnable: 1, 36 | 37 | // NOTE: `kCFStreamPropertyHTTPS` から始まるキーは deprecated になっているが、 38 | // それらを置き換える形で導入されたと思われる `kCFNetworkProxiesHTTPS` は、2022年6月時点で macOS からしか利用できない 39 | // https://developer.apple.com/documentation/cfnetwork/kcfnetworkproxieshttpsproxy 40 | // 41 | // 以下のページによるとバグではないか? とのこと 42 | // https://developer.apple.com/forums/thread/19356 43 | // 44 | // "HTTPSProxy", "HTTPSPort" などの文字列をキーの代わりに指定して Xcode の警告を消すことも可能 45 | kCFStreamPropertyHTTPSProxyHost: proxy.host, 46 | kCFStreamPropertyHTTPSProxyPort: proxy.port, 47 | 48 | // NOTE: kCFNetworkProxiesHTTPSProxy に相当するキーが `kCFStreamPropertyHTTPS` から始まるキーとして存在しなかったので、直接文字列で指定する 49 | // https://developer.apple.com/documentation/cfnetwork 50 | "HTTPSEnable": 1, 51 | ] 52 | 53 | Logger.info( 54 | type: .webSocketChannel, 55 | message: 56 | "proxy: \(String(describing: configuration.connectionProxyDictionary.debugDescription))" 57 | ) 58 | } 59 | 60 | Logger.debug(type: .webSocketChannel, message: "[\(host)] connecting") 61 | urlSession = URLSession( 62 | configuration: configuration, 63 | delegate: self, 64 | delegateQueue: delegateQueue) 65 | 66 | webSocketTask = urlSession?.webSocketTask(with: url) 67 | 68 | webSocketTask?.resume() 69 | receive() 70 | } 71 | 72 | /// WebSocket を切断するメソッド 73 | /// 74 | /// クライアントから切断する場合は error を nil にする 75 | /// Sora から切断されたり、ネットワークエラーが起こったりした場合は error がセットされ、onDisconnectWithError コールバックが発火する 76 | func disconnect(error: Error?) { 77 | guard !isClosing else { 78 | return 79 | } 80 | 81 | isClosing = true 82 | Logger.debug(type: .webSocketChannel, message: "[\(host)] disconnecting") 83 | 84 | if let error { 85 | Logger.debug( 86 | type: .webSocketChannel, 87 | message: "[\(host)] error: \(error.localizedDescription)") 88 | internalHandlers.onDisconnectWithError?(self, error) 89 | } 90 | 91 | webSocketTask?.cancel(with: .normalClosure, reason: nil) 92 | urlSession?.invalidateAndCancel() 93 | 94 | // メモリー・リークを防ぐために空の Handlers を設定する 95 | internalHandlers = WebSocketChannelInternalHandlers() 96 | 97 | Logger.debug(type: .webSocketChannel, message: "[\(host)] disconnected") 98 | } 99 | 100 | func send(message: WebSocketMessage) { 101 | var nativeMessage: URLSessionWebSocketTask.Message! 102 | switch message { 103 | case .text(let text): 104 | Logger.debug(type: .webSocketChannel, message: "[\(host)] sending text: \(text)") 105 | nativeMessage = .string(text) 106 | case .binary(let data): 107 | Logger.debug(type: .webSocketChannel, message: "[\(host)] sending binary: \(data)") 108 | nativeMessage = .data(data) 109 | } 110 | webSocketTask!.send(nativeMessage) { [weak self] error in 111 | guard let weakSelf = self else { 112 | return 113 | } 114 | 115 | // 余計なログを出力しないために、 disconnect の前にチェックする 116 | guard !weakSelf.isClosing else { 117 | return 118 | } 119 | 120 | if let error { 121 | Logger.debug( 122 | type: .webSocketChannel, 123 | message: "[\(weakSelf.host)] failed to send message: \(error.localizedDescription)") 124 | weakSelf.disconnect(error: SoraError.webSocketError(error)) 125 | } 126 | } 127 | } 128 | 129 | func receive() { 130 | webSocketTask?.receive { [weak self] result in 131 | guard let weakSelf = self else { 132 | return 133 | } 134 | 135 | switch result { 136 | case .success(let message): 137 | Logger.debug( 138 | type: .webSocketChannel, 139 | message: "[\(weakSelf.host)] receive message => \(message)") 140 | 141 | var newMessage: WebSocketMessage? 142 | switch message { 143 | case .string(let string): 144 | newMessage = .text(string) 145 | case .data(let data): 146 | newMessage = .binary(data) 147 | @unknown default: 148 | break 149 | } 150 | 151 | if let message = newMessage { 152 | Logger.debug( 153 | type: .webSocketChannel, message: "[\(weakSelf.host)] call onReceive") 154 | weakSelf.handlers.onReceive?(message) 155 | weakSelf.internalHandlers.onReceive?(message) 156 | } else { 157 | Logger.debug( 158 | type: .webSocketChannel, 159 | message: 160 | "[\(weakSelf.host)] received message is not string or binary (discarded)" 161 | ) 162 | // discard 163 | } 164 | 165 | weakSelf.receive() 166 | case .failure(let error): 167 | // メッセージ受信に失敗以上のエラーは urlSession の didCompleteWithError で検知できるのでここではログを出して break する 168 | Logger.debug( 169 | type: .webSocketChannel, message: "[\(weakSelf.host)] message receive error: \(error)") 170 | } 171 | } 172 | } 173 | 174 | func urlSession( 175 | _ session: URLSession, 176 | webSocketTask: URLSessionWebSocketTask, 177 | didOpenWithProtocol protocol: String? 178 | ) { 179 | guard !isClosing else { 180 | return 181 | } 182 | Logger.debug(type: .webSocketChannel, message: "[\(host)] \(#function)") 183 | if let onConnect = internalHandlers.onConnect { 184 | onConnect(self) 185 | } 186 | } 187 | 188 | func reason2string(reason: Data?) -> String? { 189 | guard let reason else { 190 | return nil 191 | } 192 | 193 | return String(data: reason, encoding: .utf8) 194 | } 195 | 196 | func urlSession( 197 | _ session: URLSession, 198 | webSocketTask: URLSessionWebSocketTask, 199 | didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, 200 | reason: Data? 201 | ) { 202 | guard !isClosing else { 203 | return 204 | } 205 | 206 | Logger.debug(type: .webSocketChannel, message: "close frame received") 207 | var message = "[\(host)] \(#function) closeCode => \(closeCode)" 208 | 209 | let reasonString = reason2string(reason: reason) 210 | if reasonString != nil { 211 | message += " and reason => \(String(describing: reasonString))" 212 | } 213 | 214 | Logger.debug(type: .webSocketChannel, message: message) 215 | 216 | // 2025.2.x から、ステータスコード 1000 の場合でも error として上位層に伝搬させることにする (上位層が error 前提で組まれているためこのような方針にした) 217 | // TODO(zztkm): 改修範囲が広くはなるが Sora から正常に Close Frame を受け取った場合は error とは区別して伝搬させる 218 | let statusCode = WebSocketStatusCode(rawValue: closeCode.rawValue) 219 | let error = SoraError.webSocketClosed( 220 | statusCode: statusCode, 221 | reason: reasonString) 222 | disconnect(error: error) 223 | } 224 | 225 | func urlSession( 226 | _ session: URLSession, task: URLSessionTask, 227 | didReceive challenge: URLAuthenticationChallenge, 228 | completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void 229 | ) { 230 | // コードを短くするために変数を定義 231 | let ps = challenge.protectionSpace 232 | let previousFailureCount = challenge.previousFailureCount 233 | 234 | // 既に失敗している場合はチャレンジを中止する 235 | guard previousFailureCount == 0 else { 236 | let message = 237 | "[\(host)] \(#function): Basic authentication failed. proxy => \(String(describing: proxy))" 238 | Logger.info(type: .webSocketChannel, message: message) 239 | completionHandler(.cancelAuthenticationChallenge, nil) 240 | 241 | // WebSocket 接続完了前のエラーなので webSocketError ではなく signalingChannelError として扱っている 242 | // webSocketError の場合、条件によっては Sora に type: disconnect を送信する必要があるが、今回は接続完了前なので不要 243 | disconnect(error: SoraError.signalingChannelError(reason: message)) 244 | return 245 | } 246 | 247 | Logger.debug( 248 | type: .webSocketChannel, 249 | message: 250 | "[\(host)] \(#function): challenge=\(ps.host):\(ps.port), \(ps.authenticationMethod) previousFailureCount: \(previousFailureCount)" 251 | ) 252 | 253 | // Basic 認証のみに対応している 254 | // それ以外の認証方法は .performDefaultHandling で処理を続ける 255 | guard ps.authenticationMethod == NSURLAuthenticationMethodHTTPBasic else { 256 | completionHandler(.performDefaultHandling, nil) 257 | return 258 | } 259 | 260 | // username と password をチェック 261 | guard let username = proxy?.username, let password = proxy?.password else { 262 | let message = 263 | "[\(host)] \(#function): Basic authentication required, but authentication information is insufficient. proxy => \(String(describing: proxy))" 264 | Logger.info(type: .webSocketChannel, message: message) 265 | completionHandler(.cancelAuthenticationChallenge, nil) 266 | 267 | // WebSocket 接続完了前のエラーなので webSocketError ではなく signalingChannelError として扱っている 268 | // webSocketError の場合、条件によっては Sora に type: disconnect を送信する必要があるが、今回は接続完了前なので不要 269 | disconnect(error: SoraError.signalingChannelError(reason: message)) 270 | return 271 | } 272 | 273 | let credential = URLCredential(user: username, password: password, persistence: .forSession) 274 | completionHandler(.useCredential, credential) 275 | } 276 | 277 | func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { 278 | // エラーが発生したときだけ disconnect 処理を投げる 279 | // ここで検知されるエラーの原因例: インターネット切断、Sora がダウン 280 | guard let error = error else { return } 281 | Logger.debug( 282 | type: .webSocketChannel, message: "didCompleteWithError \(error.localizedDescription)") 283 | disconnect(error: SoraError.webSocketError(error)) 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /Sora/Utilities.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebRTC 3 | 4 | /// :nodoc: 5 | public enum Utilities { 6 | fileprivate static let randomBaseString = 7 | "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ0123456789" 8 | fileprivate static let randomBaseChars = 9 | randomBaseString.map { c in String(c) } 10 | 11 | public static func randomString(length: Int = 8) -> String { 12 | var chars: [String] = [] 13 | chars.reserveCapacity(length) 14 | for _ in 0.. Void 25 | 26 | public init(handler: @escaping (String) -> Void) { 27 | seconds = 0 28 | self.handler = handler 29 | timer = Timer(timeInterval: 1, repeats: true) { _ in 30 | let text = String( 31 | format: "%02d:%02d:%02d", 32 | arguments: [ 33 | self.seconds / (60 * 60), 34 | self.seconds / 60, 35 | self.seconds % 60, 36 | ]) 37 | self.handler(text) 38 | self.seconds += 1 39 | } 40 | } 41 | 42 | public func run() { 43 | seconds = 0 44 | RunLoop.main.add(timer, forMode: RunLoop.Mode.common) 45 | timer.fire() 46 | } 47 | 48 | public func stop() { 49 | timer.invalidate() 50 | seconds = 0 51 | } 52 | } 53 | } 54 | 55 | final class PairTable { 56 | var name: String 57 | 58 | private var pairs: [(T, U)] 59 | 60 | init(name: String, pairs: [(T, U)]) { 61 | self.name = name 62 | self.pairs = pairs 63 | } 64 | 65 | func left(other: U) -> T? { 66 | let found = pairs.first { pair in other == pair.1 } 67 | return found.map { pair in pair.0 } 68 | } 69 | 70 | func right(other: T) -> U? { 71 | let found = pairs.first { pair in other == pair.0 } 72 | return found.map { pair in pair.1 } 73 | } 74 | } 75 | 76 | /// :nodoc: 77 | extension PairTable where T == String { 78 | func decode(from decoder: Decoder) throws -> U { 79 | let container = try decoder.singleValueContainer() 80 | let key = try container.decode(String.self) 81 | return try right(other: key).unwrap { 82 | throw DecodingError.dataCorruptedError( 83 | in: container, 84 | debugDescription: "\(self.name) cannot decode '\(key)'") 85 | } 86 | } 87 | 88 | func encode(_ value: U, to encoder: Encoder) throws { 89 | var container = encoder.singleValueContainer() 90 | if let key = left(other: value) { 91 | try container.encode(key) 92 | } else { 93 | throw EncodingError.invalidValue( 94 | value, 95 | EncodingError.Context( 96 | codingPath: [], debugDescription: "\(name) cannot encode \(value)")) 97 | } 98 | } 99 | } 100 | 101 | /// :nodoc: 102 | extension Optional { 103 | public func unwrap(ifNone: () throws -> Wrapped) rethrows -> Wrapped { 104 | switch self { 105 | case .some(let value): 106 | return value 107 | case .none: 108 | return try ifNone() 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Sora/VideoCapturer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebRTC 3 | 4 | /// 映像フィルターの機能を定義したプロトコルです。 5 | /// `MediaStream.videoFilter` にセットすると、 6 | /// 生成された映像フレームはこのプロトコルの実装によって加工されます。 7 | public protocol VideoFilter: AnyObject { 8 | /// 映像フレームを加工します。 9 | /// - parameter videoFrame: 加工前の映像フレーム 10 | /// - returns: 加工後の映像フレーム 11 | func filter(videoFrame: VideoFrame) -> VideoFrame 12 | } 13 | -------------------------------------------------------------------------------- /Sora/VideoCodec.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | private let descriptionTable: PairTable = 4 | PairTable( 5 | name: "VideoCodec", 6 | pairs: [ 7 | ("default", .default), 8 | ("VP8", .vp8), 9 | ("VP9", .vp9), 10 | ("H264", .h264), 11 | ("H265", .h265), 12 | ("AV1", .av1), 13 | ]) 14 | 15 | /// 映像コーデックを表します。 16 | public enum VideoCodec { 17 | /** 18 | サーバーが指定するデフォルトのコーデック。 19 | 現在のデフォルトのコーデックは VP9 です。 20 | */ 21 | case `default` 22 | 23 | /// VP8 24 | case vp8 25 | 26 | /// VP9 27 | case vp9 28 | 29 | /// H.264 30 | case h264 31 | 32 | /// H.265 33 | case h265 34 | 35 | /// AV1 36 | case av1 37 | } 38 | 39 | extension VideoCodec: CustomStringConvertible { 40 | /// 文字列表現を返します。 41 | public var description: String { 42 | descriptionTable.left(other: self)! 43 | } 44 | } 45 | 46 | /// :nodoc: 47 | extension VideoCodec: Codable { 48 | public init(from decoder: Decoder) throws { 49 | self = try descriptionTable.decode(from: decoder) 50 | } 51 | 52 | public func encode(to encoder: Encoder) throws { 53 | try descriptionTable.encode(self, to: encoder) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sora/VideoFrame.swift: -------------------------------------------------------------------------------- 1 | import CoreMedia 2 | import Foundation 3 | import WebRTC 4 | 5 | /// 映像フレームの種別です。 6 | /// 現在の実装では次の映像フレームに対応しています。 7 | /// 8 | /// - ネイティブの映像フレーム (`RTCVideoFrame`) 9 | /// - `CMSampleBuffer` (映像のみ、音声は非対応。 `RTCVideoFrame` に変換されます) 10 | public enum VideoFrame { 11 | // MARK: - 定義 12 | 13 | /// ネイティブの映像フレーム。 14 | /// `CMSampleBuffer` から生成した映像フレームは、ネイティブの映像フレームに変換されます。 15 | case native(capturer: RTCVideoCapturer?, frame: RTCVideoFrame) 16 | 17 | // MARK: - プロパティ 18 | 19 | /// 映像フレームの幅 20 | public var width: Int { 21 | switch self { 22 | case .native(capturer: _, let frame): 23 | return Int(frame.width) 24 | } 25 | } 26 | 27 | /// 映像フレームの高さ 28 | public var height: Int { 29 | switch self { 30 | case .native(capturer: _, let frame): 31 | return Int(frame.height) 32 | } 33 | } 34 | 35 | /// 映像フレームの生成時刻 36 | public var timestamp: CMTime? { 37 | switch self { 38 | case .native(capturer: _, let frame): 39 | return CMTimeMake(value: frame.timeStampNs, timescale: 1_000_000_000) 40 | } 41 | } 42 | 43 | // MARK: - 初期化 44 | 45 | /// 初期化します。 46 | /// 指定されたサンプルバッファーからピクセル画像データを取得できなければ 47 | /// `nil` を返します。 48 | /// 49 | /// 音声データを含むサンプルバッファーには対応していません。 50 | /// 51 | /// - parameter sampleBuffer: ピクセルバッファーを含むサンプルバッファー 52 | public init?(from sampleBuffer: CMSampleBuffer) { 53 | guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { 54 | return nil 55 | } 56 | let timeStamp = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sampleBuffer)) 57 | let timeStampNs = Int64(timeStamp * 1_000_000_000) 58 | let frame = RTCVideoFrame( 59 | buffer: RTCCVPixelBuffer(pixelBuffer: pixelBuffer), 60 | rotation: RTCVideoRotation._0, 61 | timeStampNs: timeStampNs) 62 | self = .native(capturer: nil, frame: frame) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sora/VideoRenderer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebRTC 3 | 4 | /// 映像の描画に必要な機能を定義したプロトコルです。 5 | public protocol VideoRenderer: AnyObject { 6 | /// 映像のサイズが変更されたときに呼ばれます。 7 | /// - parameter size: 変更後のサイズ 8 | func onChange(size: CGSize) 9 | /// 映像フレームを描画します。 10 | /// - parameter videoFrame: 描画する映像フレーム 11 | func render(videoFrame: VideoFrame?) 12 | /// 接続解除時に呼ばれます。 13 | /// - parameter from: 接続解除するメディアチャンネル 14 | func onDisconnect(from: MediaChannel?) 15 | /// ストリームへの追加時に呼ばれます。 16 | /// - parameter from: 追加されるストリーム 17 | func onAdded(from: MediaStream) 18 | /// ストリームからの除去時に呼ばれます。 19 | /// - parameter from: 除去されるストリーム 20 | func onRemoved(from: MediaStream) 21 | /// 映像の可否の設定の変更時に呼ばれます。 22 | /// - parameter video: 映像の可否 23 | func onSwitch(video: Bool) 24 | /// 音声の可否の設定の変更時に呼ばれます。 25 | /// - parameter audio: 音声の可否 26 | func onSwitch(audio: Bool) 27 | } 28 | 29 | class VideoRendererAdapter: NSObject, RTCVideoRenderer { 30 | private(set) weak var videoRenderer: VideoRenderer? 31 | 32 | init(videoRenderer: VideoRenderer) { 33 | self.videoRenderer = videoRenderer 34 | } 35 | 36 | func setSize(_ size: CGSize) { 37 | if let renderer = videoRenderer { 38 | Logger.debug( 39 | type: .videoRenderer, 40 | message: "set size \(size) for \(renderer)") 41 | DispatchQueue.main.async { 42 | renderer.onChange(size: size) 43 | } 44 | } else { 45 | Logger.debug( 46 | type: .videoRenderer, 47 | message: "set size \(size) IGNORED, no renderer set") 48 | } 49 | } 50 | 51 | func renderFrame(_ frame: RTCVideoFrame?) { 52 | DispatchQueue.main.async { 53 | if let renderer = self.videoRenderer { 54 | if let frame { 55 | let frame = VideoFrame.native(capturer: nil, frame: frame) 56 | renderer.render(videoFrame: frame) 57 | } else { 58 | renderer.render(videoFrame: nil) 59 | } 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sora/VideoView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import WebRTC 3 | 4 | /// VideoView における、映像ソースの停止時の処理を表します。 5 | public enum VideoViewConnectionMode { 6 | /// サーバー及びストリームとの接続解除時に描画処理を停止します。 7 | case auto 8 | 9 | /// サーバー及びストリームとの接続解除時に描画処理を停止し、 ``clear()`` を実行します。 10 | case autoClear 11 | 12 | /// サーバー及びストリームと接続が解除されても描画処理を停止しません。 13 | case manual 14 | } 15 | 16 | /// VideoRenderer プロトコルのデフォルト実装となる UIView です。 17 | /// 18 | /// MediaStream.videoRenderer にセットすることで、その MediaStream 19 | /// に流れている映像をそのまま画面に表示することができます。 20 | /// 21 | /// ## contentModeの設定 22 | /// 23 | /// VideoView は contentMode の設定に対応しており、 contentMode 24 | /// プロパティに任意の値を設定することで映像のレンダリングのされ方を変更することができます。 25 | /// 26 | /// - コード上からプログラム的に VideoView を生成した場合、デフォルト値は 27 | /// `scaleAspectFit` になります。 28 | /// - Storyboard や Interface Builder 経由で VideoView を生成した場合、 29 | /// Storyboard や Interface Builder 上で設定した Content Mode の値が使用されます。 30 | public class VideoView: UIView { 31 | // キーウィンドウ外で RTCEAGLVideoView を生成すると次のエラーが発生するため、 32 | // contentView を Nib ファイルでセットせずに遅延プロパティで初期化する 33 | // "Failed to bind EAGLDrawable: to GL_RENDERBUFFER 1" 34 | // ただし、このエラーは無視しても以降の描画に問題はなく、クラッシュもしない 35 | // また、遅延プロパティでもキーウィンドウ外で初期化すれば 36 | // エラーが発生するため、根本的な解決策ではないので注意 37 | private lazy var contentView: VideoViewContentView = { 38 | #if SWIFT_PACKAGE 39 | guard 40 | let topLevel = Bundle.module 41 | .loadNibNamed("VideoView", owner: self, options: nil) 42 | else { 43 | fatalError("cannot load VideoView's nib file") 44 | } 45 | #else 46 | guard 47 | let topLevel = Bundle(for: VideoView.self) 48 | .loadNibNamed("VideoView", owner: self, options: nil) 49 | else { 50 | fatalError("cannot load VideoView's nib file") 51 | } 52 | #endif 53 | 54 | let view: VideoViewContentView = topLevel[0] as! VideoViewContentView 55 | view.frame = self.bounds 56 | self.addSubview(view) 57 | return view 58 | }() 59 | 60 | // MARK: - インスタンスの生成 61 | 62 | /// 初期化します。 63 | /// - parameter frame: ビューのサイズ 64 | override public init(frame: CGRect) { 65 | super.init(frame: frame) 66 | // init() ないし init(frame:) 経由でコードからVideoViewが生成された場合は、 67 | // 過去との互換性のため、contentModeの初期値を設定する必要がある 68 | contentMode = .scaleAspectFit 69 | } 70 | 71 | /// コーダーを使用して初期化します。 72 | /// - parameter coder: コーダー 73 | public required init?(coder: NSCoder) { 74 | super.init(coder: coder) 75 | // init?(coder:) 経由でVideoViewが生成された場合は、 76 | // Storyboard/Interface Builder経由でViewが生成されているので、 77 | // 設定をそのまま反映させる必要があるため、contentModeの初期値を設定しない 78 | } 79 | 80 | // MARK: - レイアウト 81 | 82 | /// レイアウトを調整します。 83 | override public func layoutSubviews() { 84 | super.layoutSubviews() 85 | contentView.frame = bounds 86 | } 87 | 88 | // MARK: - 映像の描画 89 | 90 | /// 映像ソース停止時の処理 91 | public var connectionMode: VideoViewConnectionMode = .autoClear 92 | 93 | /// 描画処理の実行中であれば ``true`` 94 | public private(set) var isRendering: Bool = false 95 | 96 | /// 描画停止時に ``clear()`` を実行すると表示されるビュー 97 | public var backgroundView: UIView? { 98 | didSet { 99 | if let view = oldValue { 100 | view.removeFromSuperview() 101 | } 102 | if let view = backgroundView { 103 | addSubview(view) 104 | } 105 | } 106 | } 107 | 108 | // backgroundView の未設定時、 clear() を実行すると表示される黒画面のビュー 109 | private lazy var defaultBackgroundView: UIView = { 110 | let view = UIView( 111 | frame: CGRect( 112 | x: 0, 113 | y: 0, 114 | width: self.frame.width, 115 | height: self.frame.height)) 116 | view.autoresizingMask = [.flexibleWidth, .flexibleHeight] 117 | view.backgroundColor = UIColor.black 118 | self.addSubview(view) 119 | return view 120 | }() 121 | 122 | /// 現在 VideoView が表示している映像の元々のフレームサイズを返します。 123 | /// 124 | /// まだ VideoView が映像のフレームを一度も表示していない場合は `nil` を返します。 125 | /// 126 | /// VideoView はこの映像のフレームサイズを元にして、自身の contentMode 127 | /// に従ってフレームを変形させ、映像を画面に表示します。 128 | /// 129 | /// - 例えば currentVideoFrameSize が VideoView.frame よりも小さく、 130 | /// contentMode に `scaleAspectFit` が指定されている場合は、 131 | /// contentMode の指定に従って元映像は引き伸ばされて、拡大表示される事になります。 132 | /// 133 | /// このプロパティを使用することで、例えば元映像が横長の場合は横長なUIにし、 134 | /// 縦長の場合は縦長なUIにする、といった調整を行うことができます。 135 | /// 136 | /// 注意点として、このプロパティは直前の映像のフレームサイズを返すため、 137 | /// 既に映像は表示されていない場合でも、最後に表示していた映像フレームをサイズを返します。 138 | public var currentVideoFrameSize: CGSize? { 139 | contentView.currentVideoFrameSize 140 | } 141 | 142 | /// 画面を ``backgroundView`` のビューに切り替えます。 143 | /// ``backgroundView`` が指定されていなければ画面を黒で塗り潰します。 144 | /// このメソッドは描画停止時のみ有効です。 145 | public func clear() { 146 | if !isRendering { 147 | DispatchQueue.main.async { 148 | if let bgView = self.backgroundView { 149 | self.bringSubviewToFront(bgView) 150 | } else { 151 | self.bringSubviewToFront(self.defaultBackgroundView) 152 | } 153 | } 154 | } 155 | } 156 | 157 | /// 映像フレームの描画を開始します。 158 | public func start() { 159 | if !isRendering { 160 | DispatchQueue.main.async { 161 | self.bringSubviewToFront(self.contentView) 162 | self.isRendering = true 163 | } 164 | } 165 | } 166 | 167 | /// 映像フレームの描画を停止します。 168 | /// 描画の停止中は ``render(videoFrame:)`` が実行されません。 169 | public func stop() { 170 | isRendering = false 171 | } 172 | 173 | /// デバッグモードを有効にします。 174 | /// 有効にすると、映像の上部に解像度とフレームレートを表示します。 175 | public var debugMode: Bool { 176 | get { contentView.debugMode } 177 | set { contentView.debugMode = newValue } 178 | } 179 | } 180 | 181 | // MARK: - VideoRenderer 182 | 183 | /// :nodoc: 184 | extension VideoView: VideoRenderer { 185 | /// :nodoc: 186 | public func onChange(size: CGSize) { 187 | contentView.onVideoFrameSizeUpdated(size) 188 | } 189 | 190 | /// :nodoc: 191 | public func render(videoFrame: VideoFrame?) { 192 | if isRendering { 193 | contentView.render(videoFrame: videoFrame) 194 | } 195 | } 196 | 197 | private func autoStop() { 198 | switch connectionMode { 199 | case .auto: 200 | stop() 201 | case .autoClear: 202 | stop() 203 | clear() 204 | case .manual: 205 | break 206 | } 207 | } 208 | 209 | public func onDisconnect(from: MediaChannel?) { 210 | autoStop() 211 | } 212 | 213 | public func onAdded(from: MediaStream) { 214 | switch connectionMode { 215 | case .auto, .autoClear: 216 | start() 217 | case .manual: 218 | break 219 | } 220 | } 221 | 222 | public func onRemoved(from: MediaStream) { 223 | autoStop() 224 | } 225 | 226 | public func onSwitch(video: Bool) { 227 | autoStop() 228 | } 229 | 230 | public func onSwitch(audio: Bool) { 231 | // 何もしない 232 | } 233 | } 234 | 235 | // MARK: - 236 | 237 | class VideoViewContentView: UIView { 238 | @IBOutlet private weak var nativeVideoView: RTCMTLVideoView! 239 | @IBOutlet private weak var debugInfoLabel: UILabel! 240 | 241 | fileprivate var currentVideoFrameSize: CGSize? 242 | private var videoFrameSizeToChange: CGSize? 243 | 244 | private var frameCount: Int = 0 245 | 246 | var debugMode: Bool = false { 247 | didSet { 248 | if debugMode { 249 | DispatchQueue.main.async { 250 | self.debugInfoLabel.text = "" 251 | self.debugInfoLabel.isHidden = false 252 | } 253 | 254 | frameCount = 0 255 | debugMonitor = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { 256 | [weak self] _ in 257 | DispatchQueue.main.async { [weak self] in 258 | self?.updateDebugInfo() 259 | } 260 | } 261 | } else { 262 | DispatchQueue.main.async { 263 | self.debugInfoLabel.isHidden = true 264 | } 265 | 266 | debugMonitor?.invalidate() 267 | debugMonitor = nil 268 | } 269 | } 270 | } 271 | 272 | private var debugMonitor: Timer? 273 | 274 | // MARK: - Init/deinit 275 | 276 | required init?(coder aDecoder: NSCoder) { 277 | super.init(coder: aDecoder) 278 | clipsToBounds = true 279 | } 280 | 281 | override init(frame: CGRect) { 282 | super.init(frame: frame) 283 | clipsToBounds = true 284 | } 285 | 286 | // MARK: - UIView 287 | 288 | override func didMoveToWindow() { 289 | super.didMoveToWindow() 290 | // onChange(size:) が呼ばれて RTCEAGLVideoView にサイズの変更がある場合、 291 | // このビューがウィンドウに表示されたタイミングでサイズの変更を行う 292 | // これも前述のエラーを回避するため 293 | if window != nil { 294 | updateSizeIfNeeded() 295 | } 296 | } 297 | 298 | override func layoutSubviews() { 299 | super.layoutSubviews() 300 | // 自分自身のサイズが変化したとき、既に描画された video frame size に合わせて再レイアウトを行う 301 | if let videoFrameSize = currentVideoFrameSize { 302 | updateNativeVideoViewSize(videoFrameSize) 303 | } 304 | } 305 | 306 | // MARK: - Methods 307 | 308 | fileprivate func onVideoFrameSizeUpdated(_ videoFrameSize: CGSize) { 309 | // ここも前述のエラーと同様の理由で処理を後回しにする 310 | if allowsRender { 311 | updateNativeVideoViewSize(videoFrameSize) 312 | } else { 313 | videoFrameSizeToChange = videoFrameSize 314 | } 315 | } 316 | 317 | fileprivate func render(videoFrame: VideoFrame?) { 318 | guard allowsRender else { return } 319 | updateSizeIfNeeded() 320 | 321 | if let frame = videoFrame { 322 | if debugMode { 323 | frameCount += 1 324 | } 325 | 326 | switch frame { 327 | case .native(capturer: _, let frame): 328 | nativeVideoView.isHidden = false 329 | nativeVideoView.renderFrame(frame) 330 | } 331 | } else { 332 | nativeVideoView.renderFrame(nil) 333 | } 334 | } 335 | 336 | // MARK: - Private Methods 337 | 338 | private var allowsRender: Bool { 339 | // 前述のエラーはキーウィンドウ外での描画でも発生するので、 340 | // ビューがキーウィンドウに表示されている場合のみ描画を許可する 341 | !(isHidden || window == nil || !window!.isKeyWindow) 342 | } 343 | 344 | private var renderingContentMode: UIView.ContentMode { 345 | // superView に指定されている contentMode を優先的に使用する。 346 | // 万一指定がない場合はデフォルトの aspect fit を使用する。 347 | superview?.contentMode ?? .scaleAspectFit 348 | } 349 | 350 | private func updateSizeIfNeeded() { 351 | if let videoFrameSize = videoFrameSizeToChange { 352 | if allowsRender { 353 | updateNativeVideoViewSize(videoFrameSize) 354 | videoFrameSizeToChange = nil 355 | } 356 | } 357 | } 358 | 359 | private func updateNativeVideoViewSize(_ videoFrameSize: CGSize) { 360 | // 指定された映像のサイズ・現在の自分自身の描画領域のサイズ・描画モードの指定に合わせて、 361 | // RTCEAGLVideoView のサイズと位置を変更し、うまい具合に映像が描画されるようにする。 362 | let adjustSize = viewSize( 363 | for: videoFrameSize, 364 | containerSize: bounds.size, 365 | mode: renderingContentMode) 366 | 367 | // setSize(_:) の呼び出しと nativeVideoView.frame の設定について 368 | // setSize(_:) は RTCVideoRenderer.h にて定義されているメソッドだが、 369 | // https://chromium.googlesource.com/external/webrtc/+/master/webrtc/sdk/objc/Framework/Headers/WebRTC/RTCVideoRenderer.h#26 370 | // その実装は RTCEAGLVideoView.m に存在し、実際には delegate に対して通知を行っているだけである。 371 | // https://chromium.googlesource.com/external/webrtc/+/master/webrtc/sdk/objc/Framework/Classes/UI/RTCEAGLVideoView.m#263 372 | // 名前からして setSize(_:) を呼び出すことで nativeVideoView の描画フレームや内部状態が綺麗に設定されるものだと期待してしまうが、 373 | // そのような挙動は一切なく、 nativeVideoView は自分自身の frame 一杯に合わせて単に映像フレームを描画する処理しか行ってくれない。 374 | // 正直なところ、この WebRTC.framework 側の実装に大いに疑問があるが・・・ 375 | // したがって setSize(_:) は自分で nativeVideoView.frame を適切にセットした後に、手動で呼び出してやらないとならない。 376 | // nativeVideoView.frame のセットより先に setSize(_:) を呼び出すと、まだ自分自身のサイズが更新されていないにも関わらず delegate に対する通知が発生して挙動がおかしくなる 377 | nativeVideoView.frame = 378 | CGRect( 379 | x: (bounds.size.width - adjustSize.width) / 2, 380 | y: (bounds.size.height - adjustSize.height) / 2, 381 | width: adjustSize.width, 382 | height: adjustSize.height) 383 | nativeVideoView.setSize(adjustSize) 384 | currentVideoFrameSize = videoFrameSize 385 | setNeedsDisplay() 386 | } 387 | 388 | private func updateDebugInfo() { 389 | var info: String 390 | if let size = currentVideoFrameSize { 391 | info = "\(Int(size.width))x\(Int(size.height)) / " 392 | } else { 393 | info = "" 394 | } 395 | 396 | info += "\(frameCount) fps" 397 | frameCount = 0 398 | 399 | debugInfoLabel.text = info 400 | debugInfoLabel.isHidden = false 401 | 402 | Logger.debug(type: .videoView, message: "\(superview ?? self): \(info)") 403 | } 404 | } 405 | 406 | private func viewSize(for videoFrameSize: CGSize, containerSize: CGSize, mode: UIView.ContentMode) 407 | -> CGSize 408 | { 409 | switch mode { 410 | case .scaleToFill: 411 | // scale to fill モードの場合はアスペクト比を尊重する必要が無いので、 412 | // 何も考えず単純に containerSize を返せば良い。 413 | return containerSize 414 | case .scaleAspectFill: 415 | // scale aspect fill モードの場合は video frame を拡大して container size を埋めつくすように返せばよい。 416 | let baseW = CGSize( 417 | width: containerSize.width, 418 | height: containerSize.width * (videoFrameSize.height / videoFrameSize.width)) 419 | let baseH = CGSize( 420 | width: containerSize.height * (videoFrameSize.width / videoFrameSize.height), 421 | height: containerSize.height) 422 | return 423 | ([baseW, baseH].first { size in 424 | size.width >= containerSize.width && size.height >= containerSize.height 425 | }) ?? baseW 426 | default: 427 | // デフォルトは aspect fit モード。 428 | // 特別に対応しているモード以外はすべて aspect fit として扱います。 429 | // この場合は container size にちょうどフィットする中で最も大きいサイズを返せばよい。 430 | let baseW = CGSize( 431 | width: containerSize.width, 432 | height: containerSize.width * (videoFrameSize.height / videoFrameSize.width)) 433 | let baseH = CGSize( 434 | width: containerSize.height * (videoFrameSize.width / videoFrameSize.height), 435 | height: containerSize.height) 436 | return 437 | ([baseW, baseH].first { size in 438 | size.width <= containerSize.width && size.height <= containerSize.height 439 | }) ?? baseW 440 | } 441 | } 442 | -------------------------------------------------------------------------------- /Sora/VideoView.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 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 | -------------------------------------------------------------------------------- /Sora/WebRTCConfiguration.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WebRTC 3 | 4 | /// メディア制約を表します。 5 | public struct MediaConstraints { 6 | /// 必須の制約 7 | public var mandatory: [String: String] = [:] 8 | 9 | /// オプションの制約 10 | public var optional: [String: String] = [:] 11 | 12 | // MARK: - ネイティブ 13 | 14 | var nativeValue: RTCMediaConstraints { 15 | RTCMediaConstraints( 16 | mandatoryConstraints: mandatory, 17 | optionalConstraints: optional) 18 | } 19 | } 20 | 21 | /// SDP でのマルチストリームの記述方式です。 22 | public enum SDPSemantics { 23 | /// Unified Plan 24 | case unifiedPlan 25 | 26 | // MARK: - ネイティブ 27 | 28 | var nativeValue: RTCSdpSemantics { 29 | switch self { 30 | case .unifiedPlan: 31 | return RTCSdpSemantics.unifiedPlan 32 | } 33 | } 34 | } 35 | 36 | /// (リソースの逼迫により) 送信する映像の品質が維持できない場合の挙動です。 37 | public enum DegradationPreference { 38 | /// 何もしない 39 | case disabled 40 | 41 | /// バランスを取る 42 | case balanced 43 | 44 | /// フレームレートの維持を優先する 45 | case maintainFramerate 46 | 47 | /// 解像度の維持を優先する 48 | case maintainResolution 49 | 50 | // MARK: - ネイティブ 51 | 52 | var nativeValue: RTCDegradationPreference { 53 | switch self { 54 | case .balanced: 55 | return RTCDegradationPreference.balanced 56 | case .disabled: 57 | return RTCDegradationPreference.disabled 58 | case .maintainFramerate: 59 | return RTCDegradationPreference.maintainFramerate 60 | case .maintainResolution: 61 | return RTCDegradationPreference.maintainResolution 62 | } 63 | } 64 | } 65 | 66 | /// WebRTC に関する設定です。 67 | public struct WebRTCConfiguration { 68 | // MARK: メディア制約に関する設定 69 | 70 | /// メディア制約 71 | public var constraints = MediaConstraints() 72 | 73 | // MARK: ICE サーバーに関する設定 74 | 75 | /// ICE サーバー情報のリスト 76 | public var iceServerInfos: [ICEServerInfo] 77 | 78 | /// ICE 通信ポリシー 79 | public var iceTransportPolicy: ICETransportPolicy = .relay 80 | 81 | // MARK: SDP に関する設定 82 | 83 | /// SDP でのマルチストリームの記述方式 84 | public var sdpSemantics: SDPSemantics = .unifiedPlan 85 | 86 | /// (リソースの逼迫により) 送信する映像の品質が維持できない場合の挙動 87 | public var degradationPreference: DegradationPreference? 88 | 89 | // MARK: - インスタンスの生成 90 | 91 | /// 初期化します。 92 | public init() { 93 | iceServerInfos = [] 94 | } 95 | 96 | // MARK: - ネイティブ 97 | 98 | var nativeValue: RTCConfiguration { 99 | let config = RTCConfiguration() 100 | config.iceServers = iceServerInfos.map { info in 101 | info.nativeValue 102 | } 103 | config.iceTransportPolicy = iceTransportPolicy.nativeValue 104 | config.sdpSemantics = sdpSemantics.nativeValue 105 | 106 | // AES-GCM を有効にする 107 | config.cryptoOptions = RTCCryptoOptions( 108 | srtpEnableGcmCryptoSuites: true, 109 | srtpEnableAes128Sha1_32CryptoCipher: false, 110 | srtpEnableEncryptedRtpHeaderExtensions: false, 111 | sframeRequireFrameEncryption: false) 112 | return config 113 | } 114 | 115 | var nativeConstraints: RTCMediaConstraints { constraints.nativeValue } 116 | } 117 | 118 | private var sdpSemanticsTable: PairTable = 119 | PairTable( 120 | name: "SDPSemantics", 121 | pairs: [("unifiedPlan", .unifiedPlan)]) 122 | 123 | /// :nodoc: 124 | extension SDPSemantics: Codable { 125 | public init(from decoder: Decoder) throws { 126 | self = try sdpSemanticsTable.decode(from: decoder) 127 | } 128 | 129 | public func encode(to encoder: Encoder) throws { 130 | try sdpSemanticsTable.encode(self, to: encoder) 131 | } 132 | } 133 | 134 | /// :nodoc: 135 | extension MediaConstraints: Codable { 136 | enum CodingKeys: String, CodingKey { 137 | case mandatory 138 | case optional 139 | } 140 | 141 | public init(from decoder: Decoder) throws { 142 | let container = try decoder.container(keyedBy: CodingKeys.self) 143 | mandatory = try container.decode( 144 | [String: String].self, 145 | forKey: .mandatory) 146 | optional = try container.decode( 147 | [String: String].self, 148 | forKey: .optional) 149 | } 150 | 151 | public func encode(to encoder: Encoder) throws { 152 | var container = encoder.container(keyedBy: CodingKeys.self) 153 | try container.encode(mandatory, forKey: .mandatory) 154 | try container.encode(optional, forKey: .optional) 155 | } 156 | } 157 | 158 | /// :nodoc: 159 | extension WebRTCConfiguration: Codable { 160 | enum CodingKeys: String, CodingKey { 161 | case constraints 162 | case iceServerInfos 163 | case iceTransportPolicy 164 | case sdpSemantics 165 | } 166 | 167 | public init(from decoder: Decoder) throws { 168 | self.init() 169 | let container = try decoder.container(keyedBy: CodingKeys.self) 170 | constraints = try container.decode( 171 | MediaConstraints.self, 172 | forKey: .constraints) 173 | iceServerInfos = try container.decode( 174 | [ICEServerInfo].self, 175 | forKey: .iceServerInfos) 176 | iceTransportPolicy = try container.decode( 177 | ICETransportPolicy.self, 178 | forKey: .iceTransportPolicy) 179 | sdpSemantics = try container.decode( 180 | SDPSemantics.self, 181 | forKey: .sdpSemantics) 182 | } 183 | 184 | public func encode(to encoder: Encoder) throws { 185 | var container = encoder.container(keyedBy: CodingKeys.self) 186 | try container.encode(constraints, forKey: .constraints) 187 | try container.encode(iceServerInfos, forKey: .iceServerInfos) 188 | try container.encode(iceTransportPolicy, forKey: .iceTransportPolicy) 189 | try container.encode(sdpSemantics, forKey: .sdpSemantics) 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /Sora/WebSocketChannel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// WebSocket のステータスコードを表します。 4 | public enum WebSocketStatusCode { 5 | /// 1000 6 | case normal 7 | 8 | /// 1001 9 | case goingAway 10 | 11 | /// 1002 12 | case protocolError 13 | 14 | /// 1003 15 | case unhandledType 16 | 17 | /// 1005 18 | case noStatusReceived 19 | 20 | /// 1006 21 | case abnormal 22 | 23 | /// 1007 24 | case invalidUTF8 25 | 26 | /// 1008 27 | case policyViolated 28 | 29 | /// 1009 30 | case messageTooBig 31 | 32 | /// 1010 33 | case missingExtension 34 | 35 | /// 1011 36 | case internalError 37 | 38 | /// 1012 39 | case serviceRestart 40 | 41 | /// 1013 42 | case tryAgainLater 43 | 44 | /// 1015 45 | case tlsHandshake 46 | 47 | /// その他のコード 48 | case other(Int) 49 | 50 | static let table: [(WebSocketStatusCode, Int)] = [ 51 | (.normal, 1000), 52 | (.goingAway, 1001), 53 | (.protocolError, 1002), 54 | (.unhandledType, 1003), 55 | (.noStatusReceived, 1005), 56 | (.abnormal, 1006), 57 | (.invalidUTF8, 1007), 58 | (.policyViolated, 1008), 59 | (.messageTooBig, 1009), 60 | (.missingExtension, 1010), 61 | (.internalError, 1011), 62 | (.serviceRestart, 1012), 63 | (.tryAgainLater, 1013), 64 | (.tlsHandshake, 1015), 65 | ] 66 | 67 | // MARK: - インスタンスの生成 68 | 69 | /// 初期化します。 70 | /// 71 | /// - parameter rawValue: ステータスコード 72 | public init(rawValue: Int) { 73 | for pair in WebSocketStatusCode.table { 74 | if pair.1 == rawValue { 75 | self = pair.0 76 | return 77 | } 78 | } 79 | self = .other(rawValue) 80 | } 81 | 82 | // MARK: 変換 83 | 84 | /// 整数で表されるステータスコードを返します。 85 | /// 86 | /// - returns: ステータスコード 87 | public func intValue() -> Int { 88 | switch self { 89 | case .normal: 90 | return 1000 91 | case .goingAway: 92 | return 1001 93 | case .protocolError: 94 | return 1002 95 | case .unhandledType: 96 | return 1003 97 | case .noStatusReceived: 98 | return 1005 99 | case .abnormal: 100 | return 1006 101 | case .invalidUTF8: 102 | return 1007 103 | case .policyViolated: 104 | return 1008 105 | case .messageTooBig: 106 | return 1009 107 | case .missingExtension: 108 | return 1010 109 | case .internalError: 110 | return 1011 111 | case .serviceRestart: 112 | return 1012 113 | case .tryAgainLater: 114 | return 1013 115 | case .tlsHandshake: 116 | return 1015 117 | case .other(let value): 118 | return value 119 | } 120 | } 121 | } 122 | 123 | /// WebSocket の通信で送受信されるメッセージを表します。 124 | public enum WebSocketMessage { 125 | /// テキスト 126 | case text(String) 127 | 128 | /// バイナリ 129 | case binary(Data) 130 | } 131 | 132 | /// WebSocket チャネルのイベントハンドラです。 133 | public final class WebSocketChannelHandlers { 134 | /// 初期化します。 135 | public init() {} 136 | 137 | /// メッセージ受信時に呼ばれるクロージャー 138 | public var onReceive: ((WebSocketMessage) -> Void)? 139 | } 140 | 141 | final class WebSocketChannelInternalHandlers { 142 | public var onConnect: ((URLSessionWebSocketChannel) -> Void)? 143 | public var onDisconnectWithError: ((URLSessionWebSocketChannel, Error) -> Void)? 144 | public var onReceive: ((WebSocketMessage) -> Void)? 145 | public init() {} 146 | } 147 | -------------------------------------------------------------------------------- /SoraTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | 24 | 25 | -------------------------------------------------------------------------------- /SoraTests/SoraTests.swift: -------------------------------------------------------------------------------- 1 | import WebRTC 2 | import XCTest 3 | 4 | @testable import Sora 5 | 6 | class SoraTests: XCTestCase { 7 | override func setUp() { 8 | super.setUp() 9 | // Put setup code here. This method is called before the invocation of each test method in the class. 10 | } 11 | 12 | override func tearDown() { 13 | // Put teardown code here. This method is called after the invocation of each test method in the class. 14 | super.tearDown() 15 | } 16 | 17 | func testPerformanceExample() { 18 | // This is an example of a performance test case. 19 | measure { 20 | // Put the code you want to measure the time of here. 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /THANKS: -------------------------------------------------------------------------------- 1 | 以下の人が Sora iOS SDK に貢献しています (アルファベット順) : 2 | 3 | akisute 4 | daihase 5 | m-yoshimo 6 | FromAtom 7 | hkk 8 | tamiyoshi-naka 9 | tsuba-h 10 | soudegesu 11 | -------------------------------------------------------------------------------- /canary.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import re 3 | import subprocess 4 | 5 | 6 | # 更新対象のPackageInfoファイル 7 | PACKAGEINFO_FILE = "Sora/PackageInfo.swift" 8 | 9 | 10 | def update_packageinfo_version(packageinfo_content): 11 | """ 12 | PackageInfo.swiftファイルの内容からバージョンを更新する 13 | 14 | Args: 15 | packageinfo_content (list): PackageInfo.swiftファイルの各行を要素とするリスト 16 | 17 | Returns: 18 | tuple: (更新後のファイル内容のリスト, 新しいバージョン文字列) 19 | 20 | Raises: 21 | ValueError: バージョン指定が見つからない場合 22 | """ 23 | updated_content = [] 24 | sdk_version_updated = False 25 | new_version = None 26 | 27 | for line in packageinfo_content: 28 | line = line.rstrip() # 末尾の改行のみを削除 29 | if "public static let version" in line: 30 | # バージョン行のパターンマッチング 31 | version_match = re.match( 32 | r'\s*public\s+static\s+let\s+version\s*=\s*[\'"](\d+\.\d+\.\d+)(-canary\.(\d+))?[\'"]', 33 | line, 34 | ) 35 | if version_match: 36 | major_minor_patch = version_match.group(1) # 基本バージョン (例: 1.0.0) 37 | canary_suffix = version_match.group(2) # canaryサフィックス部分 38 | 39 | # canaryサフィックスが無い場合は.0から開始、ある場合は番号をインクリメント 40 | if canary_suffix is None: 41 | new_version = f"{major_minor_patch}-canary.0" 42 | else: 43 | canary_number = int(version_match.group(3)) 44 | new_version = f"{major_minor_patch}-canary.{canary_number + 1}" 45 | 46 | # PackageInfoのバージョン行を更新 47 | updated_content.append(f' public static let version = "{new_version}"') 48 | sdk_version_updated = True 49 | else: 50 | updated_content.append(line) 51 | else: 52 | updated_content.append(line) 53 | 54 | if not sdk_version_updated: 55 | raise ValueError("Version specification not found in PackageInfo.swift file.") 56 | 57 | return updated_content, new_version 58 | 59 | 60 | def write_file(filename, updated_content, dry_run): 61 | """ 62 | 更新後の内容をファイルに書き込む 63 | 64 | Args: 65 | filename (str): 書き込み対象のファイル名 66 | updated_content (list): 更新後のファイル内容 67 | dry_run (bool): True の場合は実際の書き込みを行わない 68 | """ 69 | if dry_run: 70 | print(f"Dry run: The following changes would be written to {filename}:") 71 | print("\n".join(updated_content)) 72 | else: 73 | with open(filename, "w", encoding="utf-8") as file: 74 | file.write("\n".join(updated_content) + "\n") 75 | print(f"{filename} updated.") 76 | 77 | 78 | def git_operations(new_version, dry_run): 79 | """ 80 | Git操作(コミット、タグ付け、プッシュ)を実行 81 | 82 | Args: 83 | new_version (str): 新しいバージョン文字列(タグとして使用) 84 | dry_run (bool): True の場合は実際のGit操作を行わない 85 | """ 86 | commit_message = ( 87 | f"[canary] Update PackageInfo.swift version to {new_version}" 88 | ) 89 | 90 | if dry_run: 91 | # dry-run時は実行されるコマンドを表示のみ 92 | print(f"Dry run: Would execute git add {PACKAGEINFO_FILE}") 93 | print(f"Dry run: Would execute git commit -m '{commit_message}'") 94 | print(f"Dry run: Would execute git tag {new_version}") 95 | print(f"Dry run: Would execute git push origin develop") 96 | print(f"Dry run: Would execute git push origin {new_version}") 97 | else: 98 | # ファイルをステージング 99 | print(f"Executing: git add {PACKAGEINFO_FILE}") 100 | subprocess.run(["git", "add", PACKAGEINFO_FILE], check=True) 101 | 102 | # 変更をコミット 103 | print(f"Executing: git commit -m '{commit_message}'") 104 | subprocess.run(["git", "commit", "-m", commit_message], check=True) 105 | 106 | # バージョンタグを作成 107 | print(f"Executing: git tag {new_version}") 108 | subprocess.run(["git", "tag", new_version], check=True) 109 | 110 | # developブランチをプッシュ 111 | print("Executing: git push origin develop") 112 | subprocess.run(["git", "push", "origin", "develop"], check=True) 113 | 114 | # タグをプッシュ 115 | print(f"Executing: git push origin {new_version}") 116 | subprocess.run(["git", "push", "origin", new_version], check=True) 117 | 118 | 119 | def main(): 120 | """ 121 | メイン処理: 122 | 1. コマンドライン引数の解析 123 | 2. PackageInfo.swiftファイルの読み込みと更新 124 | 3. Git操作の実行 125 | """ 126 | parser = argparse.ArgumentParser( 127 | description="Update PackageInfo.swift version and push changes with git." 128 | ) 129 | parser.add_argument( 130 | "--dry-run", 131 | action="store_true", 132 | help="Perform a dry run without making any changes.", 133 | ) 134 | args = parser.parse_args() 135 | 136 | # PackageInfoファイルを読み込んでバージョンを更新 137 | with open(PACKAGEINFO_FILE, "r", encoding="utf-8") as file: 138 | packageinfo_content = file.readlines() 139 | updated_packageinfo_content, new_version = update_packageinfo_version(packageinfo_content) 140 | write_file(PACKAGEINFO_FILE, updated_packageinfo_content, args.dry_run) 141 | 142 | # Git操作の実行 143 | git_operations(new_version, args.dry_run) 144 | 145 | 146 | if __name__ == "__main__": 147 | main() 148 | --------------------------------------------------------------------------------