├── .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 | [](https://chromium.googlesource.com/external/webrtc/+/branch-heads/7103)
4 | [](https://github.com/shiguredo/sora-ios-sdk)
5 | [](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 |
--------------------------------------------------------------------------------