From 85d298c6989ba7345f56c15ebca7e41f0ea4f6fc Mon Sep 17 00:00:00 2001 From: Daniel Rochetti Date: Mon, 27 Nov 2023 22:56:40 -0800 Subject: [PATCH] feat: realtime support (#2) * feat(wip): initial realtime impl * chore: move sample app to samples folder * feat: realtime + pencilkit sample * chore: update readme --- Package.resolved | 4 +- Package.swift | 20 +- README.md | 43 +- Sources/FalClient/Client+Codable.swift | 19 +- Sources/FalClient/Client+Request.swift | 5 +- Sources/FalClient/Client.swift | 36 +- Sources/FalClient/FalClient.swift | 27 +- Sources/FalClient/FalTimeInterval.swift | 19 - Sources/FalClient/Realtime+Codable.swift | 30 ++ Sources/FalClient/Realtime.swift | 365 ++++++++++++++++ .../FalClient/TimeInterval+Extensions.swift | 24 ++ .../FalSampleApp.xcodeproj/project.pbxproj | 406 ------------------ .../AccentColor.colorset/Contents.json | 0 .../AppIcon.appiconset/Contents.json | 0 .../Assets.xcassets/Contents.json | 0 .../FalRealtimeSampleApp/ContentView.swift | 60 +++ .../DrawingCanvasView.swift | 103 +++++ .../FalRealtimeSampleApp.entitlements | 10 + .../FalRealtimeSampleAppApp.swift | 10 + .../Preview Assets.xcassets/Contents.json | 0 .../FalRealtimeSampleApp/ViewModel.swift | 66 +++ .../FalRealtimeSampleApp/fal.swift | 4 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 63 +++ .../Assets.xcassets/Contents.json | 6 + .../FalSampleApp/ContentView.swift | 2 +- .../FalSampleApp/FalSampleApp.entitlements | 0 .../FalSampleApp/FalSampleAppApp.swift | 0 .../Preview Assets.xcassets/Contents.json | 6 + .../FalSampleApp/FalSampleApp/fal.swift | 0 30 files changed, 864 insertions(+), 475 deletions(-) delete mode 100644 Sources/FalClient/FalTimeInterval.swift create mode 100644 Sources/FalClient/Realtime+Codable.swift create mode 100644 Sources/FalClient/Realtime.swift create mode 100644 Sources/FalClient/TimeInterval+Extensions.swift delete mode 100644 Sources/FalSampleApp/FalSampleApp.xcodeproj/project.pbxproj rename Sources/{FalSampleApp/FalSampleApp => Samples/FalRealtimeSampleApp/FalRealtimeSampleApp}/Assets.xcassets/AccentColor.colorset/Contents.json (100%) rename Sources/{FalSampleApp/FalSampleApp => Samples/FalRealtimeSampleApp/FalRealtimeSampleApp}/Assets.xcassets/AppIcon.appiconset/Contents.json (100%) rename Sources/{FalSampleApp/FalSampleApp => Samples/FalRealtimeSampleApp/FalRealtimeSampleApp}/Assets.xcassets/Contents.json (100%) create mode 100644 Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/ContentView.swift create mode 100644 Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/DrawingCanvasView.swift create mode 100644 Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/FalRealtimeSampleApp.entitlements create mode 100644 Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/FalRealtimeSampleAppApp.swift rename Sources/{FalSampleApp/FalSampleApp => Samples/FalRealtimeSampleApp/FalRealtimeSampleApp}/Preview Content/Preview Assets.xcassets/Contents.json (100%) create mode 100644 Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/ViewModel.swift create mode 100644 Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/fal.swift create mode 100644 Sources/Samples/FalSampleApp/FalSampleApp/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Sources/Samples/FalSampleApp/FalSampleApp/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Sources/Samples/FalSampleApp/FalSampleApp/Assets.xcassets/Contents.json rename Sources/{ => Samples}/FalSampleApp/FalSampleApp/ContentView.swift (98%) rename Sources/{ => Samples}/FalSampleApp/FalSampleApp/FalSampleApp.entitlements (100%) rename Sources/{ => Samples}/FalSampleApp/FalSampleApp/FalSampleAppApp.swift (100%) create mode 100644 Sources/Samples/FalSampleApp/FalSampleApp/Preview Content/Preview Assets.xcassets/Contents.json rename Sources/{ => Samples}/FalSampleApp/FalSampleApp/fal.swift (100%) diff --git a/Package.resolved b/Package.resolved index 9133dde..dce572f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/nicklockwood/SwiftFormat", "state" : { - "revision" : "d37a477177d5d4ff2a3ae6328eaaab5bf793e702", - "version" : "0.52.9" + "revision" : "cac06079ce883170ab44cb021faad298daeec2a5", + "version" : "0.52.10" } } ], diff --git a/Package.swift b/Package.swift index cf76a52..6b4de7f 100644 --- a/Package.swift +++ b/Package.swift @@ -6,8 +6,8 @@ import PackageDescription let package = Package( name: "FalClient", platforms: [ - .iOS(.v13), - .macOS(.v11), + .iOS(.v15), + .macOS(.v12), .macCatalyst(.v13), .tvOS(.v13), .watchOS(.v8), @@ -20,17 +20,25 @@ let package = Package( ), ], dependencies: [ - .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.50.4"), + .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.52.10"), ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. - .target( - name: "FalClient"), + .target(name: "FalClient"), .testTarget( name: "FalClientTests", dependencies: ["FalClient"] ), - .target(name: "FalSampleApp", dependencies: ["FalClient"]), + .target( + name: "FalSampleApp", + dependencies: ["FalClient"], + path: "Sources/Samples/FalSampleApp" + ), + .target( + name: "FalRealtimeSampleApp", + dependencies: ["FalClient"], + path: "Sources/Samples/FalRealtimeSampleApp" + ), ] ) diff --git a/README.md b/README.md index 08a5170..c1ff3f7 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,10 @@ This Swift client library is crafted as a lightweight layer atop Swift's network 2. Set up the client instance: ```swift import FalClient - let fal = FalClient(withCredentials: "FAL_KEY_ID:FAL_KEY_SECRET") + let fal = FalClient.withCredentials(.keyPair("FAL_KEY_ID:FAL_KEY_SECRET")) + + // You can also use a proxy to protect your credentials + // let fal = FalClient.withProxy("http://localhost:3333/api/fal/proxy") ``` 3. Use `fal.subscribe` to dispatch requests to the model API: @@ -40,11 +43,49 @@ This Swift client library is crafted as a lightweight layer atop Swift's network ``` **Notes:** + - Replace `text-to-image` with a valid model id. Check [fal.ai/models](https://fal.ai/models) for all available models. - It fully relies on `async/await` for asynchronous programming. - The result type in Swift will be a `[String: Any]` and the entries depend on the API output schema. - The Swift client also supports typed inputs and outputs through `Codable`. +## Real-time + +The client supports real-time model APIs. Checkout the [FalRealtimeSampleApp](./Sources/Samples/FalRealtimeSampleApp/) for more details. + +```swift +let connection = try fal.realtime.connect( + to: OptimizedLatentConsistency, + connectionKey: "PencilKitDemo", + throttleInterval: .milliseconds(128) +) { (result: Result) in + if case let .success(data) = result, + let image = data.images.first { + let data = try? Data(contentsOf: URL(string: image.url)!) + DispatchQueue.main.async { + self.currentImage = data + } + } +} + +try connection.send(LcmInput( + prompt: prompt, + imageUrl: "data:image/jpeg;base64,\(drawing.base64EncodedString())", + seed: 6_252_023, + syncMode: true +)) +``` + +## Sample apps + +Check the `Sources/Samples` folder for a handful of sample applications using the `FalClient`. + +Open them with `xed` to quickly start playing with + +```bash +xed Sources/Sample/FalSampleApp +``` + ## Roadmap See the [open feature requests](https://github.com/fal-ai/serverless-client-swift/labels/enhancement) for a list of proposed features and join the discussion. diff --git a/Sources/FalClient/Client+Codable.swift b/Sources/FalClient/Client+Codable.swift index 961657d..b774aee 100644 --- a/Sources/FalClient/Client+Codable.swift +++ b/Sources/FalClient/Client+Codable.swift @@ -1,3 +1,4 @@ +import Dispatch import Foundation public struct EmptyInput: Encodable { @@ -18,7 +19,7 @@ public extension Client { } func run( - _ id: String, + _ app: String, input: (some Encodable) = EmptyInput.empty, options: RunOptions = DefaultRunOptions ) async throws -> Output { @@ -27,25 +28,25 @@ public extension Client { ? try JSONSerialization.jsonObject(with: inputData!) as? [String: Any] : nil - let data = try await sendRequest(id, input: inputData, queryParams: queryParams, options: options) + let url = buildUrl(fromId: app, path: options.path) + let data = try await sendRequest(url, input: inputData, queryParams: queryParams, options: options) return try decoder.decode(Output.self, from: data) } func subscribe( - _ id: String, + to app: String, input: (some Encodable) = EmptyInput.empty, - pollInterval: FalTimeInterval = .seconds(1), - timeout: FalTimeInterval = .minutes(3), + pollInterval: DispatchTimeInterval = .seconds(1), + timeout: DispatchTimeInterval = .minutes(3), includeLogs: Bool = false, - options _: RunOptions = DefaultRunOptions, onQueueUpdate: OnQueueUpdate? = nil ) async throws -> Output { - let requestId = try await queue.submit(id, input: input) + let requestId = try await queue.submit(app, input: input) let start = Int(Date().timeIntervalSince1970 * 1000) var elapsed = 0 var isCompleted = false while elapsed < timeout.milliseconds { - let update = try await queue.status(id, of: requestId, includeLogs: includeLogs) + let update = try await queue.status(app, of: requestId, includeLogs: includeLogs) if let onQueueUpdateCallback = onQueueUpdate { onQueueUpdateCallback(update) } @@ -59,6 +60,6 @@ public extension Client { if !isCompleted { throw FalError.queueTimeout } - return try await queue.response(id, of: requestId) + return try await queue.response(app, of: requestId) } } diff --git a/Sources/FalClient/Client+Request.swift b/Sources/FalClient/Client+Request.swift index 4fad8bc..f028232 100644 --- a/Sources/FalClient/Client+Request.swift +++ b/Sources/FalClient/Client+Request.swift @@ -1,8 +1,7 @@ import Foundation extension Client { - func sendRequest(_ id: String, input: Data?, queryParams: [String: Any]? = nil, options: RequestOptions) async throws -> Data { - let urlString = buildUrl(fromId: id, path: options.path) + func sendRequest(_ urlString: String, input: Data?, queryParams: [String: Any]? = nil, options: RunOptions) async throws -> Data { guard var url = URL(string: urlString) else { throw FalError.invalidUrl(url: urlString) } @@ -49,6 +48,6 @@ extension Client { var userAgent: String { let osVersion = ProcessInfo.processInfo.operatingSystemVersionString - return "fal.ai/swift-client 0.0.1 - \(osVersion)" + return "fal.ai/swift-client 0.1.0 - \(osVersion)" } } diff --git a/Sources/FalClient/Client.swift b/Sources/FalClient/Client.swift index 33be025..92040c7 100644 --- a/Sources/FalClient/Client.swift +++ b/Sources/FalClient/Client.swift @@ -1,26 +1,27 @@ +import Dispatch import Foundation -enum HttpMethod: String { +public enum HttpMethod: String { case get case post case put case delete } -protocol RequestOptions { +public protocol RequestOptions { var httpMethod: HttpMethod { get } var path: String { get } } public struct RunOptions: RequestOptions { - let path: String - let httpMethod: HttpMethod + public let path: String + public let httpMethod: HttpMethod - static func withMethod(_ method: HttpMethod) -> Self { + static func withMethod(_ method: HttpMethod) -> RunOptions { RunOptions(path: "", httpMethod: method) } - static func route(_ path: String, withMethod method: HttpMethod = .post) -> Self { + static func route(_ path: String, withMethod method: HttpMethod = .post) -> RunOptions { RunOptions(path: path, httpMethod: method) } } @@ -34,39 +35,38 @@ public protocol Client { var queue: Queue { get } + var realtime: Realtime { get } + func run(_ id: String, input: [String: Any]?, options: RunOptions) async throws -> [String: Any] func subscribe( - _ id: String, + to app: String, input: [String: Any]?, - pollInterval: FalTimeInterval, - timeout: FalTimeInterval, + pollInterval: DispatchTimeInterval, + timeout: DispatchTimeInterval, includeLogs: Bool, - options: RunOptions, onQueueUpdate: OnQueueUpdate? ) async throws -> [String: Any] } public extension Client { - func run(_ id: String, input: [String: Any]? = nil, options: RunOptions = DefaultRunOptions) async throws -> [String: Any] { - return try await run(id, input: input, options: options) + func run(_ app: String, input: [String: Any]? = nil, options: RunOptions = DefaultRunOptions) async throws -> [String: Any] { + return try await run(app, input: input, options: options) } func subscribe( - _ id: String, + to app: String, input: [String: Any]? = nil, - pollInterval: FalTimeInterval = .seconds(1), - timeout: FalTimeInterval = .minutes(3), + pollInterval: DispatchTimeInterval = .seconds(1), + timeout: DispatchTimeInterval = .minutes(3), includeLogs: Bool = false, - options: RunOptions = DefaultRunOptions, onQueueUpdate: OnQueueUpdate? = nil ) async throws -> [String: Any] { - return try await subscribe(id, + return try await subscribe(to: app, input: input, pollInterval: pollInterval, timeout: timeout, includeLogs: includeLogs, - options: options, onQueueUpdate: onQueueUpdate) } } diff --git a/Sources/FalClient/FalClient.swift b/Sources/FalClient/FalClient.swift index 2b28654..1cd7a7a 100644 --- a/Sources/FalClient/FalClient.swift +++ b/Sources/FalClient/FalClient.swift @@ -1,3 +1,4 @@ +import Dispatch import Foundation func buildUrl(fromId id: String, path: String? = nil) -> String { @@ -28,10 +29,13 @@ public struct FalClient: Client { public var queue: Queue { QueueClient(client: self) } - public func run(_ id: String, input: [String: Any]?, options: RunOptions) async throws -> [String: Any] { + public var realtime: Realtime { RealtimeClient(client: self) } + + public func run(_ app: String, input: [String: Any]?, options: RunOptions) async throws -> [String: Any] { let inputData = input != nil ? try JSONSerialization.data(withJSONObject: input as Any) : nil let queryParams = options.httpMethod == .get ? input : nil - let data = try await sendRequest(id, input: inputData, queryParams: queryParams, options: options) + let url = buildUrl(fromId: app, path: options.path) + let data = try await sendRequest(url, input: inputData, queryParams: queryParams, options: options) guard let result = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw FalError.invalidResultFormat } @@ -39,20 +43,19 @@ public struct FalClient: Client { } public func subscribe( - _ id: String, + to app: String, input: [String: Any]?, - pollInterval: FalTimeInterval, - timeout: FalTimeInterval, + pollInterval: DispatchTimeInterval, + timeout: DispatchTimeInterval, includeLogs: Bool, - options _: RunOptions, onQueueUpdate: OnQueueUpdate? ) async throws -> [String: Any] { - let requestId = try await queue.submit(id, input: input) + let requestId = try await queue.submit(app, input: input) let start = Int(Date().timeIntervalSince1970 * 1000) var elapsed = 0 var isCompleted = false while elapsed < timeout.milliseconds { - let update = try await queue.status(id, of: requestId, includeLogs: includeLogs) + let update = try await queue.status(app, of: requestId, includeLogs: includeLogs) if let onQueueUpdateCallback = onQueueUpdate { onQueueUpdateCallback(update) } @@ -66,12 +69,16 @@ public struct FalClient: Client { if !isCompleted { throw FalError.queueTimeout } - return try await queue.response(id, of: requestId) + return try await queue.response(app, of: requestId) } } public extension FalClient { - static func withProxy(_ url: String) -> FalClient { + static func withProxy(_ url: String) -> Client { return FalClient(config: ClientConfig(requestProxy: url)) } + + static func withCredentials(_ credentials: ClientCredentials) -> Client { + return FalClient(config: ClientConfig(credentials: credentials)) + } } diff --git a/Sources/FalClient/FalTimeInterval.swift b/Sources/FalClient/FalTimeInterval.swift deleted file mode 100644 index 647037e..0000000 --- a/Sources/FalClient/FalTimeInterval.swift +++ /dev/null @@ -1,19 +0,0 @@ -public enum FalTimeInterval { - case milliseconds(Int) - case seconds(Int) - case minutes(Int) - case hours(Int) - - var milliseconds: Int { - switch self { - case let .milliseconds(value): - return value - case let .seconds(value): - return value * 1000 - case let .minutes(value): - return value * 60 * 1000 - case let .hours(value): - return value * 60 * 60 * 1000 - } - } -} diff --git a/Sources/FalClient/Realtime+Codable.swift b/Sources/FalClient/Realtime+Codable.swift new file mode 100644 index 0000000..513815a --- /dev/null +++ b/Sources/FalClient/Realtime+Codable.swift @@ -0,0 +1,30 @@ +import Dispatch +import Foundation + +class CodableRealtimeConnection: RealtimeConnection { + override public func send(_ data: Input) throws { + let json = try JSONEncoder().encode(data) + try sendReference(json) + } +} + +public extension Realtime { + func connect( + to app: String, + connectionKey: String, + throttleInterval: DispatchTimeInterval, + onResult completion: @escaping (Result) -> Void + ) throws -> RealtimeConnection { + return handleConnection( + to: app, connectionKey: connectionKey, throttleInterval: throttleInterval, + resultConverter: { data in + let result = try JSONDecoder().decode(Output.self, from: data) + return result + }, + connectionFactory: { send, close in + CodableRealtimeConnection(send, close) + }, + onResult: completion + ) + } +} diff --git a/Sources/FalClient/Realtime.swift b/Sources/FalClient/Realtime.swift new file mode 100644 index 0000000..eb75598 --- /dev/null +++ b/Sources/FalClient/Realtime.swift @@ -0,0 +1,365 @@ + +import Dispatch +import Foundation + +func throttle(_ function: @escaping (T) -> Void, throttleInterval: DispatchTimeInterval) -> ((T) -> Void) { + var lastExecution = DispatchTime.now() + + let throttledFunction: ((T) -> Void) = { input in + if DispatchTime.now() > lastExecution + throttleInterval { + lastExecution = DispatchTime.now() + function(input) + } + } + + return throttledFunction +} + +public enum FalRealtimeError: Error { + case connectionError + case unauthorized + case invalidResult +} + +public class RealtimeConnection { + var sendReference: SendFunction + var closeReference: CloseFunction + + init(_ send: @escaping SendFunction, _ close: @escaping CloseFunction) { + sendReference = send + closeReference = close + } + + public func close() { + closeReference() + } + + public func send(_: Input) throws { + preconditionFailure("This method must be overridden to handle \(Input.self)") + } +} + +typealias SendFunction = (Data) throws -> Void +typealias CloseFunction = () -> Void + +class UntypedRealtimeConnection: RealtimeConnection<[String: Any]> { + override public func send(_ data: [String: Any]) throws { + let json = try JSONSerialization.data(withJSONObject: data) + try sendReference(json) + } +} + +func buildRealtimeUrl(forApp app: String, host: String, token: String? = nil) -> URL { + var components = URLComponents() + components.scheme = "wss" + components.host = "\(app).\(host)" + components.path = "/ws" + + if let token = token { + components.queryItems = [URLQueryItem(name: "fal_jwt_token", value: token)] + } + // swiftlint:disable:next force_unwrapping + return components.url! +} + +typealias RefreshTokenFunction = (String, (Result) -> Void) -> Void + +private let TokenExpirationInterval: DispatchTimeInterval = .minutes(1) + +class WebSocketConnection: NSObject, URLSessionWebSocketDelegate { + let app: String + let client: Client + let onMessage: (Data) -> Void + let onError: (Error) -> Void + + private let queue = DispatchQueue(label: "ai.fal.WebSocketConnection.\(UUID().uuidString)") + private let session = URLSession(configuration: .default) + private var enqueuedMessages: [Data] = [] + private var task: URLSessionWebSocketTask? + private var token: String? + + private var isConnecting = false + private var isRefreshingToken = false + + init( + app: String, + client: Client, + onMessage: @escaping (Data) -> Void, + onError: @escaping (Error) -> Void + ) { + self.app = app + self.client = client + self.onMessage = onMessage + self.onError = onError + } + + func connect() { + if task == nil && !isConnecting && !isRefreshingToken { + isConnecting = true + if token == nil && !isRefreshingToken { + isRefreshingToken = true + refreshToken(app) { result in + switch result { + case let .success(token): + self.token = token + self.isRefreshingToken = false + self.isConnecting = false + + // Very simple token expiration handling for now + // Create the deadline 90% of the way through the token's lifetime + let tokenExpirationDeadline: DispatchTime = .now() + TokenExpirationInterval - .seconds(20) + DispatchQueue.main.asyncAfter(deadline: tokenExpirationDeadline) { + self.token = nil + } + + self.connect() + case let .failure(error): + self.isConnecting = false + self.isRefreshingToken = false + self.onError(error) + } + } + return + } + + // TODO: get host from config + let url = buildRealtimeUrl(forApp: app, host: "gateway.alpha.fal.ai", token: token) + let webSocketTask = session.webSocketTask(with: url) + webSocketTask.delegate = self + task = webSocketTask + // connect and keep the task reference + task?.resume() + isConnecting = false + receiveMessage() + } + } + + func refreshToken(_ app: String, completion: @escaping (Result) -> Void) { + Task { + // TODO: improve app alias resolution + let appAlias = app.split(separator: "-").dropFirst().joined(separator: "-") + let url = "https://rest.alpha.fal.ai/tokens/" + let body = try? JSONSerialization.data(withJSONObject: [ + "allowed_apps": [appAlias], + "token_expiration": 300, + ]) + do { + let response = try await self.client.sendRequest( + url, + input: body, + options: .withMethod(.post) + ) + if let token = String(data: response, encoding: .utf8) { + completion(.success(token.replacingOccurrences(of: "\"", with: ""))) + } else { + completion(.failure(FalRealtimeError.unauthorized)) + } + } catch { + completion(.failure(error)) + } + } + } + + func receiveMessage() { + task?.receive { [weak self] incomingMessage in + switch incomingMessage { + case let .success(message): + do { + let data = try message.data() + guard let parsedMessage = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + self?.onError(FalRealtimeError.invalidResult) + return + } + if isSuccessResult(parsedMessage) { + self?.onMessage(data) + } +// if (parsedMessage["status"] as? String != "error") { +// self?.task?.cancel() +// } + + } catch { + self?.onError(error) + } + case let .failure(error): + self?.onError(error) + } + self?.receiveMessage() + } + } + + func send(_ data: Data) throws { + if let task = task { + guard let message = String(data: data, encoding: .utf8) else { + return + } + task.send(.string(message)) { [weak self] error in + if let error = error { + self?.onError(error) + } + } + } else { + enqueuedMessages.append(data) + queue.sync { + if !isConnecting { + connect() + } + } + } + } + + func close() { + task?.cancel(with: .normalClosure, reason: "Programmatically closed".data(using: .utf8)) + } + + func urlSession( + _: URLSession, + webSocketTask _: URLSessionWebSocketTask, + didOpenWithProtocol _: String? + ) { + if let lastMessage = enqueuedMessages.last { + do { + try send(lastMessage) + } catch { + onError(error) + } + } + enqueuedMessages.removeAll() + } + + func urlSession( + _: URLSession, + webSocketTask _: URLSessionWebSocketTask, + didCloseWith _: URLSessionWebSocketTask.CloseCode, + reason _: Data? + ) { + task = nil + } +} + +var connectionPool: [String: WebSocketConnection] = [:] + +public protocol Realtime { + + var client: Client { get } + + func connect( + to app: String, + connectionKey: String, + throttleInterval: DispatchTimeInterval, + onResult completion: @escaping (Result<[String: Any], Error>) -> Void + ) throws -> RealtimeConnection<[String: Any]> +} + +func isSuccessResult(_ message: [String: Any]) -> Bool { + return message["status"] as? String != "error" && message["type"] as? String != "x-fal-message" +} + +extension URLSessionWebSocketTask.Message { + func data() throws -> Data { + switch self { + case let .data(data): + return data + case let .string(string): + guard let data = string.data(using: .utf8) else { + throw FalRealtimeError.invalidResult + } + return data + @unknown default: + preconditionFailure("Unknown URLSessionWebSocketTask.Message case") + } + } +} + +public struct RealtimeClient: Realtime { + + // TODO in the future make this non-public + // External APIs should not use it + public let client: Client + + init(client: Client) { + self.client = client + } + + public func connect( + to app: String, + connectionKey: String, + throttleInterval: DispatchTimeInterval, + onResult completion: @escaping (Result<[String: Any], Error>) -> Void + ) throws -> RealtimeConnection<[String: Any]> { + return handleConnection( + to: app, + connectionKey: connectionKey, + throttleInterval: throttleInterval, + resultConverter: { data in + guard let result = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw FalRealtimeError.invalidResult + } + return result + }, + connectionFactory: { send, close in + UntypedRealtimeConnection(send, close) + }, + onResult: completion + ) + } +} + +extension Realtime { + internal func handleConnection( + to app: String, + connectionKey: String, + throttleInterval: DispatchTimeInterval, + resultConverter convertToResultType: @escaping (Data) throws -> ResultType, + connectionFactory createRealtimeConnection: @escaping (@escaping SendFunction, @escaping CloseFunction) -> RealtimeConnection, + onResult completion: @escaping (Result) -> Void + ) -> RealtimeConnection { + let key = "\(app):\(connectionKey)" + let ws = connectionPool[key] ?? WebSocketConnection( + app: app, + client: self.client, + onMessage: { data in + do { + let result = try convertToResultType(data) + completion(.success(result)) + } catch { + completion(.failure(error)) + } + }, + onError: { error in + completion(.failure(error)) + } + ) + if connectionPool[key] == nil { + connectionPool[key] = ws + } + + let sendData = { (data: Data) in + do { + try ws.send(data) + } catch { + completion(.failure(error)) + } + } + let send: SendFunction = throttleInterval.milliseconds > 0 ? throttle(sendData, throttleInterval: throttleInterval) : sendData + let close: CloseFunction = { + ws.close() + } + return createRealtimeConnection(send, close) + } +} + +public extension Realtime { + func connect( + to app: String, + connectionKey: String = UUID().uuidString, + throttleInterval: DispatchTimeInterval = .milliseconds(64), + onResult completion: @escaping (Result<[String: Any], Error>) -> Void + ) throws -> RealtimeConnection<[String: Any]> { + return try connect( + to: app, + connectionKey: connectionKey, + throttleInterval: throttleInterval, + onResult: completion + ) + } +} diff --git a/Sources/FalClient/TimeInterval+Extensions.swift b/Sources/FalClient/TimeInterval+Extensions.swift new file mode 100644 index 0000000..fd828c9 --- /dev/null +++ b/Sources/FalClient/TimeInterval+Extensions.swift @@ -0,0 +1,24 @@ +import Dispatch + +extension DispatchTimeInterval { + public static func minutes(_ value: Int) -> DispatchTimeInterval { + return .seconds(value * 60) + } + + var milliseconds: Int { + switch self { + case let .milliseconds(value): + return value + case let .seconds(value): + return value * 1000 + case let .microseconds(value): + return value / 1000 + case let .nanoseconds(value): + return value / 1_000_000 + case .never: + return 0 + @unknown default: + return 0 + } + } +} diff --git a/Sources/FalSampleApp/FalSampleApp.xcodeproj/project.pbxproj b/Sources/FalSampleApp/FalSampleApp.xcodeproj/project.pbxproj deleted file mode 100644 index 2c86873..0000000 --- a/Sources/FalSampleApp/FalSampleApp.xcodeproj/project.pbxproj +++ /dev/null @@ -1,406 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 60; - objects = { - -/* Begin PBXBuildFile section */ - 345BF2522AF7980C00BAA969 /* FalSampleAppApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 345BF2512AF7980C00BAA969 /* FalSampleAppApp.swift */; }; - 345BF2542AF7980C00BAA969 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 345BF2532AF7980C00BAA969 /* ContentView.swift */; }; - 345BF2562AF7980D00BAA969 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 345BF2552AF7980D00BAA969 /* Assets.xcassets */; }; - 345BF25A2AF7980D00BAA969 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 345BF2592AF7980D00BAA969 /* Preview Assets.xcassets */; }; - 347ECB5E2AFF82220044CCC1 /* fal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 347ECB5D2AFF82220044CCC1 /* fal.swift */; }; - 347ECB612AFF83B10044CCC1 /* FalClient in Frameworks */ = {isa = PBXBuildFile; productRef = 347ECB602AFF83B10044CCC1 /* FalClient */; }; - 347ECB642AFF84B50044CCC1 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 347ECB632AFF84B50044CCC1 /* Kingfisher */; }; -/* End PBXBuildFile section */ - -/* Begin PBXFileReference section */ - 345BF24E2AF7980C00BAA969 /* FalSampleApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FalSampleApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 345BF2512AF7980C00BAA969 /* FalSampleAppApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FalSampleAppApp.swift; sourceTree = ""; }; - 345BF2532AF7980C00BAA969 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; - 345BF2552AF7980D00BAA969 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 345BF2572AF7980D00BAA969 /* FalSampleApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FalSampleApp.entitlements; sourceTree = ""; }; - 345BF2592AF7980D00BAA969 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; - 347ECB5D2AFF82220044CCC1 /* fal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = fal.swift; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 345BF24B2AF7980C00BAA969 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 347ECB642AFF84B50044CCC1 /* Kingfisher in Frameworks */, - 347ECB612AFF83B10044CCC1 /* FalClient in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 345BF2452AF7980C00BAA969 = { - isa = PBXGroup; - children = ( - 345BF2502AF7980C00BAA969 /* FalSampleApp */, - 345BF24F2AF7980C00BAA969 /* Products */, - ); - sourceTree = ""; - }; - 345BF24F2AF7980C00BAA969 /* Products */ = { - isa = PBXGroup; - children = ( - 345BF24E2AF7980C00BAA969 /* FalSampleApp.app */, - ); - name = Products; - sourceTree = ""; - }; - 345BF2502AF7980C00BAA969 /* FalSampleApp */ = { - isa = PBXGroup; - children = ( - 345BF2552AF7980D00BAA969 /* Assets.xcassets */, - 345BF2532AF7980C00BAA969 /* ContentView.swift */, - 347ECB5D2AFF82220044CCC1 /* fal.swift */, - 345BF2572AF7980D00BAA969 /* FalSampleApp.entitlements */, - 345BF2512AF7980C00BAA969 /* FalSampleAppApp.swift */, - 345BF2582AF7980D00BAA969 /* Preview Content */, - ); - path = FalSampleApp; - sourceTree = ""; - }; - 345BF2582AF7980D00BAA969 /* Preview Content */ = { - isa = PBXGroup; - children = ( - 345BF2592AF7980D00BAA969 /* Preview Assets.xcassets */, - ); - path = "Preview Content"; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 345BF24D2AF7980C00BAA969 /* FalSampleApp */ = { - isa = PBXNativeTarget; - buildConfigurationList = 345BF25D2AF7980D00BAA969 /* Build configuration list for PBXNativeTarget "FalSampleApp" */; - buildPhases = ( - 345BF24A2AF7980C00BAA969 /* Sources */, - 345BF24B2AF7980C00BAA969 /* Frameworks */, - 345BF24C2AF7980C00BAA969 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = FalSampleApp; - packageProductDependencies = ( - 347ECB602AFF83B10044CCC1 /* FalClient */, - 347ECB632AFF84B50044CCC1 /* Kingfisher */, - ); - productName = FalSampleApp; - productReference = 345BF24E2AF7980C00BAA969 /* FalSampleApp.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 345BF2462AF7980C00BAA969 /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1500; - LastUpgradeCheck = 1500; - TargetAttributes = { - 345BF24D2AF7980C00BAA969 = { - CreatedOnToolsVersion = 15.0.1; - }; - }; - }; - buildConfigurationList = 345BF2492AF7980C00BAA969 /* Build configuration list for PBXProject "FalSampleApp" */; - compatibilityVersion = "Xcode 14.0"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 345BF2452AF7980C00BAA969; - packageReferences = ( - 347ECB5F2AFF83B10044CCC1 /* XCLocalSwiftPackageReference "../.." */, - 347ECB622AFF84B50044CCC1 /* XCRemoteSwiftPackageReference "Kingfisher" */, - ); - productRefGroup = 345BF24F2AF7980C00BAA969 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 345BF24D2AF7980C00BAA969 /* FalSampleApp */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 345BF24C2AF7980C00BAA969 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 345BF25A2AF7980D00BAA969 /* Preview Assets.xcassets in Resources */, - 345BF2562AF7980D00BAA969 /* Assets.xcassets in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 345BF24A2AF7980C00BAA969 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 347ECB5E2AFF82220044CCC1 /* fal.swift in Sources */, - 345BF2542AF7980C00BAA969 /* ContentView.swift in Sources */, - 345BF2522AF7980C00BAA969 /* FalSampleAppApp.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin XCBuildConfiguration section */ - 345BF25B2AF7980D00BAA969 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 345BF25C2AF7980D00BAA969 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - SWIFT_COMPILATION_MODE = wholemodule; - }; - name = Release; - }; - 345BF25E2AF7980D00BAA969 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = FalSampleApp/FalSampleApp.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = "\"FalSampleApp/Preview Content\""; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; - "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; - "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; - "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; - "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; - "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; - "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; - "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; - LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; - "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = ai.fal.FalSampleApp; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 345BF25F2AF7980D00BAA969 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = FalSampleApp/FalSampleApp.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = "\"FalSampleApp/Preview Content\""; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; - "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; - "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; - "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; - "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; - "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; - "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; - "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; - LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; - "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = ai.fal.FalSampleApp; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 345BF2492AF7980C00BAA969 /* Build configuration list for PBXProject "FalSampleApp" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 345BF25B2AF7980D00BAA969 /* Debug */, - 345BF25C2AF7980D00BAA969 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 345BF25D2AF7980D00BAA969 /* Build configuration list for PBXNativeTarget "FalSampleApp" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 345BF25E2AF7980D00BAA969 /* Debug */, - 345BF25F2AF7980D00BAA969 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - -/* Begin XCLocalSwiftPackageReference section */ - 347ECB5F2AFF83B10044CCC1 /* XCLocalSwiftPackageReference "../.." */ = { - isa = XCLocalSwiftPackageReference; - relativePath = ../..; - }; -/* End XCLocalSwiftPackageReference section */ - -/* Begin XCRemoteSwiftPackageReference section */ - 347ECB622AFF84B50044CCC1 /* XCRemoteSwiftPackageReference "Kingfisher" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/onevcat/Kingfisher.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 7.10.0; - }; - }; -/* End XCRemoteSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - 347ECB602AFF83B10044CCC1 /* FalClient */ = { - isa = XCSwiftPackageProductDependency; - productName = FalClient; - }; - 347ECB632AFF84B50044CCC1 /* Kingfisher */ = { - isa = XCSwiftPackageProductDependency; - package = 347ECB622AFF84B50044CCC1 /* XCRemoteSwiftPackageReference "Kingfisher" */; - productName = Kingfisher; - }; -/* End XCSwiftPackageProductDependency section */ - }; - rootObject = 345BF2462AF7980C00BAA969 /* Project object */; -} diff --git a/Sources/FalSampleApp/FalSampleApp/Assets.xcassets/AccentColor.colorset/Contents.json b/Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from Sources/FalSampleApp/FalSampleApp/Assets.xcassets/AccentColor.colorset/Contents.json rename to Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/Sources/FalSampleApp/FalSampleApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from Sources/FalSampleApp/FalSampleApp/Assets.xcassets/AppIcon.appiconset/Contents.json rename to Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/Sources/FalSampleApp/FalSampleApp/Assets.xcassets/Contents.json b/Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/Assets.xcassets/Contents.json similarity index 100% rename from Sources/FalSampleApp/FalSampleApp/Assets.xcassets/Contents.json rename to Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/Assets.xcassets/Contents.json diff --git a/Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/ContentView.swift b/Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/ContentView.swift new file mode 100644 index 0000000..91a9510 --- /dev/null +++ b/Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/ContentView.swift @@ -0,0 +1,60 @@ +import Kingfisher +import SwiftUI + +struct ContentView: View { + @State var canvasView = CanvasView() + @State var drawingData: Data? + @ObservedObject var liveImage = LiveImage() + + var body: some View { + GeometryReader { geometry in + if geometry.size.width > geometry.size.height { + // Landscape + HStack { + DrawingCanvasView(canvasView: $canvasView, drawingData: $drawingData) + .onChange(of: drawingData) { onDrawingChange() } + .frame(maxWidth: .infinity, maxHeight: .infinity) + ImageViewContainer(imageData: liveImage.currentImage) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } else { + // Portrait + VStack { + ImageViewContainer(imageData: liveImage.currentImage) + .frame(maxWidth: .infinity, maxHeight: .infinity) + DrawingCanvasView(canvasView: $canvasView, drawingData: $drawingData) + .onChange(of: drawingData) { onDrawingChange() } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + .padding() + } + + func onDrawingChange() { + guard let data = drawingData else { + return + } + do { + try liveImage.generate(prompt: "a moon in a starry night sky", drawing: data) + } catch { + print(error) + } + } +} + +struct ImageViewContainer: View { + var imageData: Data? + + var body: some View { + VStack { + if let image = imageData { + KFImage.data(image, cacheKey: UUID().uuidString) + .transition(.opacity) + } else { + Rectangle() + .fill(Color.gray.opacity(0.4)) + } + } + } +} diff --git a/Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/DrawingCanvasView.swift b/Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/DrawingCanvasView.swift new file mode 100644 index 0000000..2bedbc9 --- /dev/null +++ b/Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/DrawingCanvasView.swift @@ -0,0 +1,103 @@ +import PencilKit +import SwiftUI +import UIKit + +class CanvasView: PKCanvasView { + // keep a list of functions that will be called when the touchMoves event is fired + var touchMoveListeners: [(Set) -> Void] = [] + + func addTouchMoveListener(_ listener: @escaping (Set) -> Void) { + touchMoveListeners.append(listener) + } + + override func touchesMoved(_ touches: Set, with _: UIEvent?) { + // call all the touchMove listeners + touchMoveListeners.forEach { listener in + listener(touches) + } + } +} + +struct DrawingCanvasView: UIViewRepresentable { + @Binding var canvasView: CanvasView + @Binding var drawingData: Data? + @State var toolPicker = PKToolPicker() + @State var isDrawing = false + + func makeUIView(context: Context) -> CanvasView { + canvasView.tool = PKInkingTool(.pen, color: .black, width: 10) + canvasView.delegate = context.coordinator + canvasView.addTouchMoveListener { _ in + if self.isDrawing { +// self.triggerDrawingChange() + } + } + return canvasView + } + +// @MainActor + func triggerDrawingChange() { + if let image = drawingToImage(canvasView: canvasView), + let imageData = image.jpegData(compressionQuality: 0.6) + { + drawingData = imageData + } + } + + func updateUIView(_: CanvasView, context _: Context) { + showToolPicker() + if let data = drawingData, + let drawing = try? PKDrawing(data: data) + { + canvasView.drawing = drawing + } + } + + func makeCoordinator() -> Coordinator { + return Coordinator(self) + } + + class Coordinator: NSObject, PKCanvasViewDelegate { + var parent: DrawingCanvasView + + init(_ parent: DrawingCanvasView) { + self.parent = parent + } + + @MainActor + func canvasViewDrawingDidChange(_: PKCanvasView) { + parent.triggerDrawingChange() + } + + @MainActor + func canvasViewDidBeginUsingTool(_: PKCanvasView) { + parent.isDrawing = true + } + + @MainActor + func canvasViewDidEndUsingTool(_: PKCanvasView) { + parent.isDrawing = false + } + } +} + +extension DrawingCanvasView { + func showToolPicker() { + toolPicker.setVisible(true, forFirstResponder: canvasView) + toolPicker.addObserver(canvasView) + canvasView.becomeFirstResponder() + } + +// @MainActor + func drawingToImage(canvasView: PKCanvasView) -> UIImage? { + // TODO: improve this, so the drawable area is clear to the user and also cropped + // correctly when the image is submitted + let drawingArea = CGRect(x: 0, y: 0, width: 512, height: 512) + return canvasView.drawing.image(from: drawingArea, scale: 1.0) +// let renderer = ImageRenderer(content: self) +// guard let image = renderer.cgImage?.cropping(to: drawingArea) else { +// return nil +// } +// return UIImage(cgImage: image) + } +} diff --git a/Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/FalRealtimeSampleApp.entitlements b/Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/FalRealtimeSampleApp.entitlements new file mode 100644 index 0000000..f2ef3ae --- /dev/null +++ b/Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/FalRealtimeSampleApp.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + + diff --git a/Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/FalRealtimeSampleAppApp.swift b/Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/FalRealtimeSampleAppApp.swift new file mode 100644 index 0000000..7756b09 --- /dev/null +++ b/Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/FalRealtimeSampleAppApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct FalRealtimeSampleAppApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/Sources/FalSampleApp/FalSampleApp/Preview Content/Preview Assets.xcassets/Contents.json b/Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/Preview Content/Preview Assets.xcassets/Contents.json similarity index 100% rename from Sources/FalSampleApp/FalSampleApp/Preview Content/Preview Assets.xcassets/Contents.json rename to Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/Preview Content/Preview Assets.xcassets/Contents.json diff --git a/Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/ViewModel.swift b/Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/ViewModel.swift new file mode 100644 index 0000000..f4a44ae --- /dev/null +++ b/Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/ViewModel.swift @@ -0,0 +1,66 @@ +import FalClient +import SwiftUI + +// See https://www.fal.ai/models/latent-consistency-sd/api for API documentation + +let OptimizedLatentConsistency = "110602490-lcm-sd15-i2i" + +struct LcmInput: Encodable { + let prompt: String + let imageUrl: String + let seed: Int + let syncMode: Bool + + enum CodingKeys: String, CodingKey { + case prompt + case imageUrl = "image_url" + case seed + case syncMode = "sync_mode" + } +} + +struct LcmImage: Decodable { + let url: String + let width: Int + let height: Int +} + +struct LcmResponse: Decodable { + let images: [LcmImage] +} + +class LiveImage: ObservableObject { + @Published var currentImage: Data? + + // This example demonstrates the support to Codable types + // RealtimeConnection<[String: Any]> can also be used + // for untyped input / output using dictionaries + private var connection: RealtimeConnection? + + init() { + connection = try? fal.realtime.connect( + to: OptimizedLatentConsistency, + connectionKey: "PencilKitDemo", + throttleInterval: .milliseconds(128) + ) { (result: Result) in + if case let .success(data) = result, + let image = data.images.first { + let data = try? Data(contentsOf: URL(string: image.url)!) + DispatchQueue.main.async { + self.currentImage = data + } + } + } + } + + func generate(prompt: String, drawing: Data) throws { + if let connection = connection { + try connection.send(LcmInput( + prompt: prompt, + imageUrl: "data:image/jpeg;base64,\(drawing.base64EncodedString())", + seed: 6_252_023, + syncMode: true + )) + } + } +} diff --git a/Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/fal.swift b/Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/fal.swift new file mode 100644 index 0000000..a0e174d --- /dev/null +++ b/Sources/Samples/FalRealtimeSampleApp/FalRealtimeSampleApp/fal.swift @@ -0,0 +1,4 @@ +import FalClient + +let fal = FalClient.withProxy("http://localhost:3333/api/fal/proxy") +// let fal = FalClient.withCredentials(.keyPair("fal_key_id:fal_key_secret")) diff --git a/Sources/Samples/FalSampleApp/FalSampleApp/Assets.xcassets/AccentColor.colorset/Contents.json b/Sources/Samples/FalSampleApp/FalSampleApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Sources/Samples/FalSampleApp/FalSampleApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Samples/FalSampleApp/FalSampleApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sources/Samples/FalSampleApp/FalSampleApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..532cd72 --- /dev/null +++ b/Sources/Samples/FalSampleApp/FalSampleApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,63 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Samples/FalSampleApp/FalSampleApp/Assets.xcassets/Contents.json b/Sources/Samples/FalSampleApp/FalSampleApp/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Sources/Samples/FalSampleApp/FalSampleApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/FalSampleApp/FalSampleApp/ContentView.swift b/Sources/Samples/FalSampleApp/FalSampleApp/ContentView.swift similarity index 98% rename from Sources/FalSampleApp/FalSampleApp/ContentView.swift rename to Sources/Samples/FalSampleApp/FalSampleApp/ContentView.swift index 5e5560c..1f85a39 100644 --- a/Sources/FalSampleApp/FalSampleApp/ContentView.swift +++ b/Sources/Samples/FalSampleApp/FalSampleApp/ContentView.swift @@ -17,7 +17,7 @@ struct ContentView: View { print("Generate image...") isLoading = true do { - let result = try await fal.subscribe("110602490-fast-sdxl", input: [ + let result = try await fal.subscribe(to: "110602490-fast-sdxl", input: [ "prompt": PROMPT, ], includeLogs: true) { update in print(update) diff --git a/Sources/FalSampleApp/FalSampleApp/FalSampleApp.entitlements b/Sources/Samples/FalSampleApp/FalSampleApp/FalSampleApp.entitlements similarity index 100% rename from Sources/FalSampleApp/FalSampleApp/FalSampleApp.entitlements rename to Sources/Samples/FalSampleApp/FalSampleApp/FalSampleApp.entitlements diff --git a/Sources/FalSampleApp/FalSampleApp/FalSampleAppApp.swift b/Sources/Samples/FalSampleApp/FalSampleApp/FalSampleAppApp.swift similarity index 100% rename from Sources/FalSampleApp/FalSampleApp/FalSampleAppApp.swift rename to Sources/Samples/FalSampleApp/FalSampleApp/FalSampleAppApp.swift diff --git a/Sources/Samples/FalSampleApp/FalSampleApp/Preview Content/Preview Assets.xcassets/Contents.json b/Sources/Samples/FalSampleApp/FalSampleApp/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Sources/Samples/FalSampleApp/FalSampleApp/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/FalSampleApp/FalSampleApp/fal.swift b/Sources/Samples/FalSampleApp/FalSampleApp/fal.swift similarity index 100% rename from Sources/FalSampleApp/FalSampleApp/fal.swift rename to Sources/Samples/FalSampleApp/FalSampleApp/fal.swift