diff --git a/.github/workflows/testing-matrix.yaml b/.github/workflows/testing-matrix.yaml index 671f4d369..b5c289523 100644 --- a/.github/workflows/testing-matrix.yaml +++ b/.github/workflows/testing-matrix.yaml @@ -3,9 +3,9 @@ name: Testing Matrix on: workflow_dispatch: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -17,13 +17,24 @@ jobs: fail-fast: false matrix: xcode-version: [14.2, 15.2] - destination: ['platform=iOS Simulator,OS=17.2,name=iPhone 14 Pro', 'platform=macOS', 'platform=macOS,variant=Mac Catalyst'] + destination: + [ + "platform=iOS Simulator,OS=17.2,name=iPhone 14 Pro", + "platform=macOS", + "platform=macOS,variant=Mac Catalyst", + ] runs-on: macos-13 steps: - uses: actions/checkout@v4 + - name: Install LiveKit Server + run: brew install livekit + + - name: Run LiveKit Server + run: livekit-server --dev & + - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: ${{ matrix.xcode-version }} diff --git a/LiveKit.xctestplan b/LiveKit.xctestplan new file mode 100644 index 000000000..c79a61dbf --- /dev/null +++ b/LiveKit.xctestplan @@ -0,0 +1,49 @@ +{ + "configurations" : [ + { + "id" : "C13DBD7E-A26D-4166-987B-8BB0E3A8A56F", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "environmentVariableEntries" : [ + { + "key" : "LIVEKIT_TESTING_URL", + "value" : "$(LIVEKIT_TESTING_URL)" + }, + { + "key" : "LIVEKIT_TESTING_API_KEY", + "value" : "$(LIVEKIT_TESTING_API_KEY)" + }, + { + "key" : "LIVEKIT_TESTING_API_SECRET", + "value" : "$(LIVEKIT_TESTING_API_SECRET)" + } + ], + "targetForVariableExpansion" : { + "containerPath" : "container:", + "identifier" : "LiveKit", + "name" : "LiveKit" + } + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:", + "identifier" : "LiveKitTests", + "name" : "LiveKitTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "LiveKitTestsObjC", + "name" : "LiveKitTestsObjC" + } + } + ], + "version" : 1 +} diff --git a/Package.swift b/Package.swift index de85225df..7ea53752b 100644 --- a/Package.swift +++ b/Package.swift @@ -20,8 +20,11 @@ let package = Package( // LK-Prefixed Dynamic WebRTC XCFramework .package(url: "https://github.com/livekit/webrtc-xcframework.git", exact: "114.5735.13"), .package(url: "https://github.com/apple/swift-protobuf.git", .upToNextMajor(from: "1.25.2")), - .package(url: "https://github.com/apple/swift-log.git", .upToNextMajor(from: "1.5.3")), - .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.3.0"), + .package(url: "https://github.com/apple/swift-log.git", .upToNextMajor(from: "1.5.4")), + // Only used for DocC generation + .package(url: "https://github.com/apple/swift-docc-plugin", .upToNextMajor(from: "1.3.0")), + // Only used for Testing + .package(url: "https://github.com/vapor/jwt-kit.git", .upToNextMajor(from: "4.13.2")), ], targets: [ .systemLibrary(name: "CHeaders"), @@ -37,11 +40,17 @@ let package = Package( ), .testTarget( name: "LiveKitTests", - dependencies: ["LiveKit"] + dependencies: [ + "LiveKit", + .product(name: "JWTKit", package: "jwt-kit"), + ] ), .testTarget( name: "LiveKitTestsObjC", - dependencies: ["LiveKit"] + dependencies: [ + "LiveKit", + .product(name: "JWTKit", package: "jwt-kit"), + ] ), ] ) diff --git a/Sources/LiveKit/Core/Transport.swift b/Sources/LiveKit/Core/Transport.swift index 9a430c9a9..b4cd6ffe0 100644 --- a/Sources/LiveKit/Core/Transport.swift +++ b/Sources/LiveKit/Core/Transport.swift @@ -15,7 +15,6 @@ */ import Foundation -import SwiftProtobuf @_implementationOnly import LiveKitWebRTC diff --git a/Tests/LiveKitTests/Basic.swift b/Tests/LiveKitTests/Basic.swift index d34e38c5a..61eb09007 100644 --- a/Tests/LiveKitTests/Basic.swift +++ b/Tests/LiveKitTests/Basic.swift @@ -17,4 +17,8 @@ @testable import LiveKit import XCTest -class Basic: XCTestCase {} +class Basic: XCTestCase { + func testReadVersion() { + print("LiveKitSDK.version: \(LiveKitSDK.version)") + } +} diff --git a/Tests/LiveKitTests/PublishTests.swift b/Tests/LiveKitTests/PublishTests.swift new file mode 100644 index 000000000..36cf15a5c --- /dev/null +++ b/Tests/LiveKitTests/PublishTests.swift @@ -0,0 +1,108 @@ +/* + * Copyright 2024 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Combine +import CoreMedia +@testable import LiveKit +import XCTest + +class PublishTests: XCTestCase { + let room1 = Room() + let room2 = Room() + + var watchRoom1: AnyCancellable? + var watchRoom2: AnyCancellable? + + override func setUp() async throws { + let url = testUrl() + + let token1 = try testToken(for: "room01", identity: "identity01") + try await room1.connect(url: url, token: token1) + + let token2 = try testToken(for: "room01", identity: "identity02") + try await room2.connect(url: url, token: token2) + + let room1ParticipantCountIs2 = expectation(description: "Room1 Participant count is 2") + room1ParticipantCountIs2.assertForOverFulfill = false + + let room2ParticipantCountIs2 = expectation(description: "Room2 Participant count is 2") + room2ParticipantCountIs2.assertForOverFulfill = false + + watchRoom1 = room1.objectWillChange.sink { _ in + if self.room1.allParticipants.count == 2 { + room1ParticipantCountIs2.fulfill() + } + } + + watchRoom2 = room2.objectWillChange.sink { _ in + if self.room2.allParticipants.count == 2 { + room2ParticipantCountIs2.fulfill() + } + } + + // Wait until both room's participant count is 2 + await fulfillment(of: [room1ParticipantCountIs2, room2ParticipantCountIs2], timeout: 30) + } + + override func tearDown() async throws { + await room1.disconnect() + await room2.disconnect() + watchRoom1?.cancel() + watchRoom2?.cancel() + } + + func testResolveSid() async throws { + XCTAssert(room1.connectionState == .connected) + + let sid = try await room1.sid() + print("Room.sid(): \(String(describing: sid))") + XCTAssert(sid.stringValue.starts(with: "RM_")) + } + + func testConcurrentMicPublish() async throws { + // Lock + struct State { + var firstMicPublication: LocalTrackPublication? + } + + let _state = StateSync(State()) + + // Run Tasks concurrently + try await withThrowingTaskGroup(of: Void.self) { group in + for _ in 1 ... 100 { + group.addTask { + let result = try await self.room1.localParticipant.setMicrophone(enabled: true) + + if let result { + _state.mutate { + if let firstMicPublication = $0.firstMicPublication { + XCTAssert(result == firstMicPublication, "Duplicate mic track has been published") + } else { + $0.firstMicPublication = result + print("Did publish first mic track: \(String(describing: result))") + } + } + } + } + } + + try await group.waitForAll() + } + + // Reset + await room1.localParticipant.unpublishAll() + } +} diff --git a/Tests/LiveKitTests/Support/TokenGenerator.swift b/Tests/LiveKitTests/Support/TokenGenerator.swift new file mode 100644 index 000000000..7f8d9fdf0 --- /dev/null +++ b/Tests/LiveKitTests/Support/TokenGenerator.swift @@ -0,0 +1,154 @@ +/* + * Copyright 2024 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation +import JWTKit + +public func testUrl() -> String { + ProcessInfo.processInfo.environment["LIVEKIT_TESTING_URL"] ?? "ws://localhost:7880" +} + +public func testToken(for room: String, identity: String) throws -> String { + let apiKey = ProcessInfo.processInfo.environment["LIVEKIT_TESTING_API_KEY"] ?? "devkey" + let apiSecret = ProcessInfo.processInfo.environment["LIVEKIT_TESTING_API_SECRET"] ?? "secret" + + let tokenGenerator = TokenGenerator(apiKey: apiKey, + apiSecret: apiSecret, + identity: identity) + + tokenGenerator.videoGrant = VideoGrant(room: room, + roomJoin: true, + canPublish: true) + return try tokenGenerator.sign() +} + +public struct VideoGrant: Codable, Equatable { + /** name of the room, must be set for admin or join permissions */ + let room: String? + /** permission to create a room */ + let roomCreate: Bool? + /** permission to join a room as a participant, room must be set */ + let roomJoin: Bool? + /** permission to list rooms */ + let roomList: Bool? + /** permission to start a recording */ + let roomRecord: Bool? + /** permission to control a specific room, room must be set */ + let roomAdmin: Bool? + + /** + * allow participant to publish. If neither canPublish or canSubscribe is set, + * both publish and subscribe are enabled + */ + let canPublish: Bool? + /** allow participant to subscribe to other tracks */ + let canSubscribe: Bool? + /** + * allow participants to publish data, defaults to true if not set + */ + let canPublishData: Bool? + /** participant isn't visible to others */ + let hidden: Bool? + /** participant is recording the room, when set, allows room to indicate it's being recorded */ + let recorder: Bool? + + init(room: String? = nil, + roomCreate: Bool? = nil, + roomJoin: Bool? = nil, + roomList: Bool? = nil, + roomRecord: Bool? = nil, + roomAdmin: Bool? = nil, + canPublish: Bool? = nil, + canSubscribe: Bool? = nil, + canPublishData: Bool? = nil, + hidden: Bool? = nil, + recorder: Bool? = nil) + { + self.room = room + self.roomCreate = roomCreate + self.roomJoin = roomJoin + self.roomList = roomList + self.roomRecord = roomRecord + self.roomAdmin = roomAdmin + self.canPublish = canPublish + self.canSubscribe = canSubscribe + self.canPublishData = canPublishData + self.hidden = hidden + self.recorder = recorder + } +} + +public class TokenGenerator { + private struct Payload: JWTPayload, Equatable { + let exp: ExpirationClaim + let iss: IssuerClaim + let nbf: NotBeforeClaim + let sub: SubjectClaim + + let name: String? + let metadata: String? + let video: VideoGrant? + + func verify(using _: JWTSigner) throws { + fatalError("not implemented") + } + } + + // 30 mins + static let defaultTTL: TimeInterval = 30 * 60 + + // MARK: - Public + + public var apiKey: String + public var apiSecret: String + public var identity: String + public var ttl: TimeInterval + public var name: String? + public var metadata: String? + public var videoGrant: VideoGrant? + + // MARK: - Private + + private let signers = JWTSigners() + + init(apiKey: String, + apiSecret: String, + identity: String, + ttl: TimeInterval = defaultTTL) + { + self.apiKey = apiKey + self.apiSecret = apiSecret + self.identity = identity + self.ttl = ttl + } + + func sign() throws -> String { + // Add HMAC with SHA-256 signer. + signers.use(.hs256(key: apiSecret)) + + let n = Date().timeIntervalSince1970 + + let p = Payload(exp: .init(value: Date(timeIntervalSince1970: floor(n + ttl))), + iss: .init(stringLiteral: apiKey), + nbf: .init(value: Date(timeIntervalSince1970: floor(n))), + sub: .init(stringLiteral: identity), + name: name, + metadata: metadata, + video: videoGrant) + + return try signers.sign(p) + } +} diff --git a/Tests/LiveKitTests/Support/Xcode14.2Backport.swift b/Tests/LiveKitTests/Support/Xcode14.2Backport.swift new file mode 100644 index 000000000..64145a575 --- /dev/null +++ b/Tests/LiveKitTests/Support/Xcode14.2Backport.swift @@ -0,0 +1,35 @@ +/* + * Copyright 2024 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation +import XCTest + +/// Support for Xcode 14.2 +#if !compiler(>=5.8) +extension XCTestCase { + func fulfillment(of expectations: [XCTestExpectation], timeout: TimeInterval, enforceOrder: Bool = false) async { + await withCheckedContinuation { continuation in + // This function operates by blocking a background thread instead of one owned by libdispatch or by the + // Swift runtime (as used by Swift concurrency.) To ensure we use a thread owned by neither subsystem, use + // Foundation's Thread.detachNewThread(_:). + Thread.detachNewThread { [self] in + wait(for: expectations, timeout: timeout, enforceOrder: enforceOrder) + continuation.resume() + } + } + } +} +#endif diff --git a/Tests/LiveKitTestsObjC/Basic.m b/Tests/LiveKitTestsObjC/Basic.m index 05aa25532..c286b5dee 100644 --- a/Tests/LiveKitTestsObjC/Basic.m +++ b/Tests/LiveKitTestsObjC/Basic.m @@ -14,15 +14,16 @@ * limitations under the License. */ -#import +@import XCTest; @import LiveKit; +// Simple ObjC test just to ensure ObjC SDK code compiles. @interface Basic : XCTestCase @end @implementation Basic -- (void)sdkVersion { +- (void)testSdkVersion { NSLog(@"%@", LiveKitSDK.sdkVersion); }