diff --git a/Package.swift b/Package.swift index 4ba4edc21..2a35a3d10 100644 --- a/Package.swift +++ b/Package.swift @@ -25,6 +25,8 @@ let package = Package( .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"), + // Local uniffi bindings + .package(path: "../rust-sdks/livekit-uniffi"), ], targets: [ .target( @@ -39,6 +41,7 @@ let package = Package( .product(name: "DequeModule", package: "swift-collections"), .product(name: "OrderedCollections", package: "swift-collections"), .product(name: "JWTKit", package: "jwt-kit"), + .product(name: "LiveKitFFI", package: "livekit-uniffi"), "LKObjCHelpers", ], exclude: [ diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index d6cc50024..3f2eacba6 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -26,6 +26,8 @@ let package = Package( .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"), + // Local uniffi bindings + .package(path: "../rust-sdks/livekit-uniffi"), ], targets: [ .target( @@ -40,6 +42,7 @@ let package = Package( .product(name: "DequeModule", package: "swift-collections"), .product(name: "OrderedCollections", package: "swift-collections"), .product(name: "JWTKit", package: "jwt-kit"), + .product(name: "LiveKitFFI", package: "livekit-uniffi"), "LKObjCHelpers", ], exclude: [ diff --git a/Sources/LiveKit/Support/Logger.swift b/Sources/LiveKit/Support/Logger.swift index 4a263c692..a16d8ec95 100644 --- a/Sources/LiveKit/Support/Logger.swift +++ b/Sources/LiveKit/Support/Logger.swift @@ -16,6 +16,7 @@ import OSLog internal import LiveKitWebRTC +internal import LiveKitFFI // 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,14 @@ extension LKRTCLoggingSeverity { } } } + +extension LogForwardLevel { + var osLogType: OSLogType { + switch self { + case .error: .error + case .warn: .default + case .info: .info + case .debug, .trace: .debug + } + } +} diff --git a/Sources/LiveKit/Token/CachingTokenSource.swift b/Sources/LiveKit/Token/CachingTokenSource.swift index 18f23499d..73d37e74a 100644 --- a/Sources/LiveKit/Token/CachingTokenSource.swift +++ b/Sources/LiveKit/Token/CachingTokenSource.swift @@ -14,6 +14,7 @@ * limitations under the License. */ +internal import LiveKitFFI import Foundation /// A token source that caches credentials from any other ``TokenSourceConfigurable`` using a configurable store. @@ -148,20 +149,27 @@ public extension TokenSourceResponse { return false } - do { - try jwt.nbf.verifyNotBefore() - try jwt.exp.verifyNotExpired(currentDate: Date().addingTimeInterval(tolerance)) - } catch { - return false - } - - return true + return jwt.nbf.verifyNotBefore() && jwt.exp.verifyNotExpired(currentDate: Date().addingTimeInterval(tolerance)) } /// 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) + } +} + +private extension UInt64 { + var asDate: Date { + Date(timeIntervalSince1970: TimeInterval(self)) + } + + func verifyNotBefore(currentDate: Date = Date()) -> Bool { + currentDate >= asDate + } + + 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 c7a51df3b..000000000 --- a/Sources/LiveKit/Token/JWT.swift +++ /dev/null @@ -1,107 +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 { - /// 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? - - /// 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/Tests/LiveKitCoreTests/Token/TokenSourceTests.swift b/Tests/LiveKitCoreTests/Token/TokenSourceTests.swift index bcf08c0d3..1c6c20c0c 100644 --- a/Tests/LiveKitCoreTests/Token/TokenSourceTests.swift +++ b/Tests/LiveKitCoreTests/Token/TokenSourceTests.swift @@ -18,6 +18,7 @@ #if canImport(LiveKitTestSupport) import LiveKitTestSupport #endif +import LiveKitFFI class TokenSourceTests: LKTestCase { actor MockValidJWTSource: TokenSourceConfigurable { @@ -38,7 +39,23 @@ 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.videoGrants = VideoGrants( + roomCreate: false, + roomList: false, + roomRecord: false, + roomAdmin: false, + roomJoin: true, + room: options.roomName ?? "test-room", + destinationRoom: "", + canPublish: false, + canSubscribe: false, + canPublishData: false, + canPublishSources: [], + canUpdateOwnMetadata: false, + ingressAdmin: false, + hidden: false, + recorder: false + ) let token = try tokenGenerator.sign() @@ -74,10 +91,26 @@ 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.videoGrants = VideoGrants( + roomCreate: false, + roomList: false, + roomRecord: false, + roomAdmin: false, + roomJoin: true, + room: options.roomName ?? "test-room", + destinationRoom: "", + canPublish: false, + canSubscribe: false, + canPublishData: false, + canPublishSources: [], + canUpdateOwnMetadata: false, + ingressAdmin: false, + hidden: false, + recorder: false + ) let token = try tokenGenerator.sign() diff --git a/Tests/LiveKitTestSupport/Room.swift b/Tests/LiveKitTestSupport/Room.swift index 92e8e516b..152d79b04 100644 --- a/Tests/LiveKitTestSupport/Room.swift +++ b/Tests/LiveKitTestSupport/Room.swift @@ -15,6 +15,7 @@ */ @testable import LiveKit +import LiveKitFFI 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 bfeac2f80..5dd462836 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 LiveKitFFI public class TokenGenerator { // 30 mins @@ -29,11 +29,7 @@ public class TokenGenerator { public var ttl: TimeInterval public var name: String? public var metadata: String? - public var videoGrant: LiveKitJWTPayload.VideoGrant? - - // MARK: - Private - - private let signers = JWTSigners() + public var videoGrants: VideoGrants? public init(apiKey: String, apiSecret: String, @@ -47,19 +43,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) - - 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, + roomName: videoGrants?.room + ) + + return try tokenGenerate(options: options, credentials: credentials) } }