diff --git a/.changes/uniffi b/.changes/uniffi new file mode 100644 index 000000000..53cc6f042 --- /dev/null +++ b/.changes/uniffi @@ -0,0 +1 @@ +minor type="added" "Experimental support and bindings for shared Rust crates using UniFFI" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f4932fa18..e880d83bd 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -59,12 +59,13 @@ jobs: platform: "tvOS Simulator,name=Apple TV,OS=18.5" # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md + # Use latest-stable temporarily to avoid missing sims - os: macos-26 - xcode: latest + xcode: latest-stable platform: "iOS Simulator,name=iPhone 17 Pro,OS=26.1" symbol-graph: true - os: macos-26 - xcode: latest + xcode: latest-stable platform: "iOS Simulator,name=iPhone 17 Pro,OS=26.1" extension-api-only: true - os: macos-26 @@ -83,10 +84,10 @@ jobs: xcode: latest platform: "macOS,variant=Mac Catalyst" - os: macos-26 - xcode: latest + xcode: latest-stable platform: "visionOS Simulator,name=Apple Vision Pro,OS=26.1" - os: macos-26 - xcode: latest + xcode: latest-stable platform: "tvOS Simulator,name=Apple TV,OS=26.1" runs-on: ${{ matrix.os }} diff --git a/LiveKitClient.podspec b/LiveKitClient.podspec index 34ec9cf07..c9ff2bfb6 100644 --- a/LiveKitClient.podspec +++ b/LiveKitClient.podspec @@ -18,7 +18,6 @@ Pod::Spec.new do |spec| spec.dependency("SwiftProtobuf") spec.dependency("DequeModule", "= 1.1.4") spec.dependency("OrderedCollections", " = 1.1.4") - spec.dependency("JWTKit", "= 4.13.5") spec.resource_bundles = {"Privacy" => ["Sources/LiveKit/PrivacyInfo.xcprivacy"]} diff --git a/Package.swift b/Package.swift index 4ba4edc21..8825f72ee 100644 --- a/Package.swift +++ b/Package.swift @@ -20,9 +20,9 @@ let package = Package( dependencies: [ // LK-Prefixed Dynamic WebRTC XCFramework .package(url: "https://github.com/livekit/webrtc-xcframework.git", exact: "137.7151.10"), + .package(url: "https://github.com/livekit/livekit-uniffi-xcframework.git", exact: "0.0.2"), .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.31.0"), .package(url: "https://github.com/apple/swift-collections.git", "1.1.0" ..< "1.3.0"), - .package(url: "https://github.com/vapor/jwt-kit.git", from: "4.13.5"), // Only used for DocC generation .package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.3.0"), ], @@ -35,10 +35,10 @@ let package = Package( name: "LiveKit", dependencies: [ .product(name: "LiveKitWebRTC", package: "webrtc-xcframework"), + .product(name: "LiveKitUniFFI", package: "livekit-uniffi-xcframework"), .product(name: "SwiftProtobuf", package: "swift-protobuf"), .product(name: "DequeModule", package: "swift-collections"), .product(name: "OrderedCollections", package: "swift-collections"), - .product(name: "JWTKit", package: "jwt-kit"), "LKObjCHelpers", ], exclude: [ diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index d6cc50024..db8bb8534 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -21,9 +21,9 @@ let package = Package( dependencies: [ // LK-Prefixed Dynamic WebRTC XCFramework .package(url: "https://github.com/livekit/webrtc-xcframework.git", exact: "137.7151.10"), + .package(url: "https://github.com/livekit/livekit-uniffi-xcframework.git", exact: "0.0.2"), .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.31.0"), .package(url: "https://github.com/apple/swift-collections.git", "1.1.0" ..< "1.3.0"), - .package(url: "https://github.com/vapor/jwt-kit.git", from: "4.13.5"), // Only used for DocC generation .package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.3.0"), ], @@ -36,10 +36,10 @@ let package = Package( name: "LiveKit", dependencies: [ .product(name: "LiveKitWebRTC", package: "webrtc-xcframework"), + .product(name: "LiveKitUniFFI", package: "livekit-uniffi-xcframework"), .product(name: "SwiftProtobuf", package: "swift-protobuf"), .product(name: "DequeModule", package: "swift-collections"), .product(name: "OrderedCollections", package: "swift-collections"), - .product(name: "JWTKit", package: "jwt-kit"), "LKObjCHelpers", ], exclude: [ diff --git a/Sources/LiveKit/Core/Room.swift b/Sources/LiveKit/Core/Room.swift index bf75245de..2f133eb7b 100644 --- a/Sources/LiveKit/Core/Room.swift +++ b/Sources/LiveKit/Core/Room.swift @@ -226,7 +226,7 @@ public class Room: NSObject, @unchecked Sendable, ObservableObject, Loggable { super.init() // log sdk & os versions - log("sdk: \(LiveKitSDK.version), os: \(String(describing: Utils.os()))(\(Utils.osVersionString())), modelId: \(String(describing: Utils.modelIdentifier() ?? "unknown"))") + log("sdk: \(LiveKitSDK.version), ffi: \(LiveKitSDK.ffiVersion), os: \(String(describing: Utils.os()))(\(Utils.osVersionString())), modelId: \(String(describing: Utils.modelIdentifier() ?? "unknown"))") signalClient._delegate.set(delegate: self) diff --git a/Sources/LiveKit/LiveKit.swift b/Sources/LiveKit/LiveKit.swift index 8f5cad70d..d1d3ed36b 100644 --- a/Sources/LiveKit/LiveKit.swift +++ b/Sources/LiveKit/LiveKit.swift @@ -16,6 +16,7 @@ import Foundation internal import LiveKitWebRTC +internal import LiveKitUniFFI /// The open source platform for real-time communication. /// @@ -33,6 +34,7 @@ public class LiveKitSDK: NSObject, Loggable { @objc(sdkVersion) public static let version = "2.10.1" + public static let ffiVersion = buildVersion() fileprivate struct State { var logger: Logger = OSLogger() diff --git a/Sources/LiveKit/Support/Logger.swift b/Sources/LiveKit/Support/Logger.swift index 4a263c692..7ed8223da 100644 --- a/Sources/LiveKit/Support/Logger.swift +++ b/Sources/LiveKit/Support/Logger.swift @@ -16,6 +16,7 @@ import OSLog internal import LiveKitWebRTC +internal import LiveKitUniFFI // MARK: - Logger @@ -108,6 +109,7 @@ public struct PrintLogger: Logger { /// A logger that logs to OSLog /// - Parameter minLevel: The minimum level to log /// - Parameter rtc: Whether to log WebRTC output +/// - Parameter ffi: Whether to log Rust FFI output open class OSLogger: Logger, @unchecked Sendable { private static let subsystem = "io.livekit.sdk" @@ -118,15 +120,15 @@ open class OSLogger: Logger, @unchecked Sendable { private let minLevel: LogLevel - public init(minLevel: LogLevel = .info, rtc: Bool = false) { + public init(minLevel: LogLevel = .info, rtc: Bool = false, ffi: Bool = true) { self.minLevel = minLevel - guard rtc else { return } + if rtc { + startRTCLogForwarding(minLevel: minLevel) + } - let rtcLog = OSLog(subsystem: Self.subsystem, category: "WebRTC") - rtcLogger.severity = minLevel.rtcSeverity - rtcLogger.start { message, severity in - os_log("%{public}@", log: rtcLog, type: severity.osLogType, message) + if ffi { + startFFILogForwarding(minLevel: minLevel) } } @@ -171,6 +173,33 @@ open class OSLogger: Logger, @unchecked Sendable { os_log("%{public}@", log: getOSLog(for: type), type: level.osLogType, "\(type).\(function) \(message)\(metadata)") } } + + private func startRTCLogForwarding(minLevel: LogLevel) { + let rtcLog = OSLog(subsystem: Self.subsystem, category: "WebRTC") + + rtcLogger.severity = minLevel.rtcSeverity + rtcLogger.start { message, severity in + os_log("%{public}@", log: rtcLog, type: severity.osLogType, message) + } + } + + private func startFFILogForwarding(minLevel: LogLevel) { + Task(priority: .utility) { [weak self] in + guard self != nil else { return } // don't initialize global level when releasing + logForwardBootstrap(level: minLevel.logForwardFilter) + + let ffiLog = OSLog(subsystem: Self.subsystem, category: "FFI") + let ffiStream = AsyncStream(unfolding: logForwardReceive) + + for await entry in ffiStream { + guard self != nil else { return } + + let message = "\(entry.target) \(entry.message)" + + os_log("%{public}@", log: ffiLog, type: entry.level.osLogType, message) + } + } + } } // MARK: - Loggable @@ -236,6 +265,15 @@ public enum LogLevel: Int, Sendable, Comparable, CustomStringConvertible { } } + var logForwardFilter: LogForwardFilter { + switch self { + case .debug: .debug + case .info: .info + case .warning: .warn + case .error: .error + } + } + @inlinable public static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { lhs.rawValue < rhs.rawValue @@ -263,3 +301,15 @@ extension LKRTCLoggingSeverity { } } } + +extension LogForwardLevel { + var osLogType: OSLogType { + switch self { + case .error: .error + case .warn: .default + case .info: .info + case .debug, .trace: .debug + @unknown default: .debug + } + } +} diff --git a/Sources/LiveKit/Token/CachingTokenSource.swift b/Sources/LiveKit/Token/CachingTokenSource.swift index 280f66df2..49ebff99e 100644 --- a/Sources/LiveKit/Token/CachingTokenSource.swift +++ b/Sources/LiveKit/Token/CachingTokenSource.swift @@ -148,13 +148,20 @@ public extension TokenSourceResponse { return false } - do { - try jwt.nbf.verifyNotBefore() - try jwt.exp.verifyNotExpired(currentDate: Date().addingTimeInterval(tolerance)) - } catch { - return false - } + return jwt.nbf.verifyNotBefore() && jwt.exp.verifyNotExpired(currentDate: Date().addingTimeInterval(tolerance)) + } +} + +private extension UInt64 { + var asDate: Date { + Date(timeIntervalSince1970: TimeInterval(self)) + } + + func verifyNotBefore(currentDate: Date = Date()) -> Bool { + currentDate >= asDate + } - return true + func verifyNotExpired(currentDate: Date = Date()) -> Bool { + currentDate < asDate } } diff --git a/Sources/LiveKit/Token/JWT.swift b/Sources/LiveKit/Token/JWT.swift deleted file mode 100644 index 061cac9fb..000000000 --- a/Sources/LiveKit/Token/JWT.swift +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright 2025 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. - */ - -// To be swapped with ffi -internal import JWTKit - -/// JWT payload structure for LiveKit authentication tokens. -struct LiveKitJWTPayload: JWTPayload, Codable, Equatable { - /// Room configuration embedded in the JWT token. - struct RoomConfiguration: Codable, Equatable { - /// Agent dispatch configuration. - struct AgentDispatch: Codable, Equatable { - let agentName: String? - let metadata: String? - } - - let agents: [AgentDispatch]? - } - - /// Video-specific permissions and room access grants for the participant. - struct VideoGrant: Codable, Equatable { - /// Name of the room. Required for admin or join permissions. - let room: String? - /// Permission to create new rooms. - let roomCreate: Bool? - /// Permission to join a room as a participant. Requires `room` to be set. - let roomJoin: Bool? - /// Permission to list available rooms. - let roomList: Bool? - /// Permission to start recording sessions. - let roomRecord: Bool? - /// Permission to control a specific room. Requires `room` to be set. - let roomAdmin: Bool? - - /// Allow participant to publish tracks. If neither `canPublish` or `canSubscribe` is set, both are enabled. - let canPublish: Bool? - /// Allow participant to subscribe to other participants' tracks. - let canSubscribe: Bool? - /// Allow participant to publish data messages. Defaults to `true` if not set. - let canPublishData: Bool? - /// Allowed track sources for publishing (e.g., "camera", "microphone", "screen_share"). - let canPublishSources: [String]? - /// Hide participant from other participants in the room. - let hidden: Bool? - /// Mark participant as a recorder. 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, - canPublishSources: [String]? = 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.canPublishSources = canPublishSources - self.hidden = hidden - self.recorder = recorder - } - } - - /// JWT expiration time claim (when the token expires). - let exp: ExpirationClaim - /// JWT issuer claim (who issued the token). - let iss: IssuerClaim - /// JWT not-before claim (when the token becomes valid). - let nbf: NotBeforeClaim - /// JWT subject claim (the participant identity). - let sub: SubjectClaim - - /// Display name for the participant in the room. - let name: String? - /// Custom metadata associated with the participant. - let metadata: String? - /// Video-specific permissions and room access grants. - let video: VideoGrant? - /// Room configuration, including agent dispatch information. - let roomConfiguration: RoomConfiguration? - - enum CodingKeys: String, CodingKey { - case exp, iss, nbf, sub - case name, metadata, video - case roomConfiguration = "roomConfig" - } - - /// Verifies the JWT token's validity by checking expiration and not-before claims. - func verify(using _: JWTSigner) throws { - try nbf.verifyNotBefore() - try exp.verifyNotExpired() - } - - /// Creates a JWT payload from an unverified token string. - /// - /// - Parameter token: The JWT token string to parse - /// - Returns: The parsed JWT payload if successful, nil otherwise - static func fromUnverified(token: String) -> Self? { - try? JWTSigners().unverified(token, as: Self.self) - } -} diff --git a/Sources/LiveKit/Token/TokenSource.swift b/Sources/LiveKit/Token/TokenSource.swift index fa260c949..dd0ba842a 100644 --- a/Sources/LiveKit/Token/TokenSource.swift +++ b/Sources/LiveKit/Token/TokenSource.swift @@ -15,6 +15,7 @@ */ import Foundation +internal import LiveKitUniFFI // MARK: - Source @@ -144,8 +145,8 @@ public extension TokenSourceResponse { /// Extracts the JWT payload from the participant token. /// /// - Returns: The JWT payload if successfully parsed, nil otherwise - internal func jwt() -> LiveKitJWTPayload? { - LiveKitJWTPayload.fromUnverified(token: participantToken) + internal func jwt() -> Claims? { + try? tokenClaimsFromUnverified(token: participantToken) } /// Checks if the JWT token contains agent dispatch configuration. diff --git a/Tests/LiveKitCoreTests/Token/TokenSourceTests.swift b/Tests/LiveKitCoreTests/Token/TokenSourceTests.swift index bcf08c0d3..8b6400d96 100644 --- a/Tests/LiveKitCoreTests/Token/TokenSourceTests.swift +++ b/Tests/LiveKitCoreTests/Token/TokenSourceTests.swift @@ -18,6 +18,7 @@ #if canImport(LiveKitTestSupport) import LiveKitTestSupport #endif +import LiveKitUniFFI class TokenSourceTests: LKTestCase { actor MockValidJWTSource: TokenSourceConfigurable { @@ -38,7 +39,17 @@ class TokenSourceTests: LKTestCase { identity: options.participantIdentity ?? "test-identity" ) tokenGenerator.name = options.participantName ?? participantName - tokenGenerator.videoGrant = LiveKitJWTPayload.VideoGrant(room: options.roomName ?? "test-room", roomJoin: true) + tokenGenerator.roomConfiguration = RoomConfiguration( + name: options.roomName ?? "test-room", + emptyTimeout: 0, + departureTimeout: 0, + maxParticipants: 0, + metadata: "", + minPlayoutDelay: 0, + maxPlayoutDelay: 0, + syncStreams: false, + agents: [] + ) let token = try tokenGenerator.sign() @@ -74,10 +85,20 @@ class TokenSourceTests: LKTestCase { apiKey: "test-api-key", apiSecret: "test-api-secret", identity: options.participantIdentity ?? "test-identity", - ttl: -60 + ttl: 0 ) tokenGenerator.name = options.participantName ?? "test-participant" - tokenGenerator.videoGrant = LiveKitJWTPayload.VideoGrant(room: options.roomName ?? "test-room", roomJoin: true) + tokenGenerator.roomConfiguration = RoomConfiguration( + name: options.roomName ?? "test-room", + emptyTimeout: 0, + departureTimeout: 0, + maxParticipants: 0, + metadata: "", + minPlayoutDelay: 0, + maxPlayoutDelay: 0, + syncStreams: false, + agents: [] + ) let token = try tokenGenerator.sign() diff --git a/Tests/LiveKitTestSupport/Room.swift b/Tests/LiveKitTestSupport/Room.swift index 92e8e516b..bedc94867 100644 --- a/Tests/LiveKitTestSupport/Room.swift +++ b/Tests/LiveKitTestSupport/Room.swift @@ -15,6 +15,7 @@ */ @testable import LiveKit +import LiveKitUniFFI public struct RoomTestingOptions { public let delegate: RoomDelegate? @@ -78,12 +79,24 @@ public extension LKTestCase { apiSecret: apiSecret, identity: identity) - tokenGenerator.videoGrant = LiveKitJWTPayload.VideoGrant(room: room, - roomJoin: true, - canPublish: canPublish, - canSubscribe: canSubscribe, - canPublishData: canPublishData, - canPublishSources: canPublishSources.map(String.init)) + tokenGenerator.videoGrants = VideoGrants( + roomCreate: false, + roomList: false, + roomRecord: false, + roomAdmin: false, + roomJoin: true, + room: room, + destinationRoom: "", + canPublish: canPublish, + canSubscribe: canSubscribe, + canPublishData: canPublishData, + canPublishSources: canPublishSources.map(String.init), + canUpdateOwnMetadata: false, + ingressAdmin: false, + hidden: false, + recorder: false + ) + return try tokenGenerator.sign() } diff --git a/Tests/LiveKitTestSupport/TokenGenerator.swift b/Tests/LiveKitTestSupport/TokenGenerator.swift index 67d99c306..02df2d8c4 100644 --- a/Tests/LiveKitTestSupport/TokenGenerator.swift +++ b/Tests/LiveKitTestSupport/TokenGenerator.swift @@ -14,8 +14,8 @@ * limitations under the License. */ -import JWTKit @testable import LiveKit +import LiveKitUniFFI public class TokenGenerator { // 30 mins @@ -29,12 +29,9 @@ public class TokenGenerator { public var ttl: TimeInterval public var name: String? public var metadata: String? - public var videoGrant: LiveKitJWTPayload.VideoGrant? - public var roomConfiguration: LiveKitJWTPayload.RoomConfiguration? - // MARK: - Private - - private let signers = JWTSigners() + public var videoGrants: LiveKitUniFFI.VideoGrants? + public var roomConfiguration: LiveKitUniFFI.RoomConfiguration? public init(apiKey: String, apiSecret: String, @@ -48,20 +45,19 @@ public class TokenGenerator { } public func sign() throws -> String { - // Add HMAC with SHA-256 signer. - signers.use(.hs256(key: apiSecret)) - - let n = Date().timeIntervalSince1970 - - let p = LiveKitJWTPayload(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, - roomConfiguration: roomConfiguration) - - return try signers.sign(p) + let credentials = ApiCredentials(key: apiKey, secret: apiSecret) + let options = TokenOptions( + ttl: ttl, + videoGrants: videoGrants, + sipGrants: nil, + identity: identity, + name: name, + metadata: metadata, + attributes: nil, + sha256: nil, + roomConfiguration: roomConfiguration + ) + + return try tokenGenerate(options: options, credentials: credentials) } }