MacPaw / OpenAI

Swift community driven package for OpenAI public API
MIT License
1.99k stars 324 forks source link

Background URLSessions support #81

Open kutakmir opened 1 year ago

kutakmir commented 1 year ago

Is your feature request related to a problem? Please describe. If the app gets suspended or terminated, the URLSession cannot continue.

Describe the solution you'd like Either the OpenAI class itself or some other class that would take the responsibility of making the calls would have to be a subclass of NSObject and implement the URLSessionDelegate protocol, then there would have to be a way to register these delegates, associate them with individual calls, and have a way for the AppDelegate to bring them to life and finish the original task even after the app was suspended. https://developer.apple.com/documentation/foundation/urlsessiondelegate/1617185-urlsessiondidfinishevents

We can model this on the AWSS3TransferUtility: https://github.com/aws-amplify/aws-sdk-ios/blob/main/AWSS3/AWSS3TransferUtility.m

Describe alternatives you've considered Not sure if we have an alternative. The main issue is that the background sessions do not support closures so we have to use URLSession delegates.

Additional context This is about robustness of the system - low connectivity and people leaving the app. Imagine you have to transcribe 1 GB audio file. Even if you split it to smaller parts, you still need to upload them somehow if the user goes to the background. I don't think real users will have the patience to look at a progress bar for 10 minutes or however long it will take.

alelordelo commented 11 months ago

hi @kutakmir , did you go ahead with this?

kalafus commented 7 months ago

after some experimentation, i find 3 key takeaways (and a bonus 4th):

  1. URLSession must be created with a delegate, and configured to run in background.
    URLSession(
    configuration: .background(withIdentifier: UUID().uuidString),
    delegate: self,
    delegateQueue: nil)
  2. Requires keeping track of completionHandler closures, and running them when system invokes urlSession methods. Recommend dictionaries keyed by UUID, which can be recovered from URLSession.configuration.identifier. We need to do this due to the runtime error: "Completion handler blocks are not supported in background sessions. Use a delegate instead."
  3. URLSessionDelegate.urlSession implementations (and related classes) must be invoked on main thread, e.g. using DispatchQueue.main.sync {} when in the background.
  4. Extra Credit: urlSessionDidFinishEvents method doesn't actually come into play (although it does get called when the app is in the background). This method is called after urlSession methods have completed execution, and that's where callbacks will have been invoked; seems like a good place to put code for User Notification--this is probably Apple's intent.

All this is demonstrated in this fairly minimal example. Make a new iOS project, delete all .swift files, and add just this. It runs for me in iOS 15 simulator.

Perhaps this would help someone more familiar with MacPaw/OpenAI networking accomplish this request, or maybe I'll dig into that now that I have this minimum working example, as that suggested aws codebase is pretty fracking heavy for what we've got here.

import SwiftUI

#if os(macOS)
class MyAppDelegate: NSObject, NSApplicationDelegate {

    func applicationDidFinishLaunching(_ notification: Notification) {
    }

    func applicationWillTerminate(_ notification: Notification) {
        // Insert code here to tear down your application
    }

    func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
        return true
    }
}
#endif

@available(iOS 14.0, *)
@main
struct URLSessionDelegateExampleApp: App {
    @Environment(\.scenePhase) private var scenePhase

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .onChange(of: scenePhase) { _, newPhase in
            switch newPhase {
            case .active:
                // App is now active
                DataModel.instance.isInForeground = true
            case .inactive:
                // App is now inactive (e.g., transitioning to the background)
                DataModel.instance.isInForeground = false
            case .background:
                // App is now in the background
                DataModel.instance.isInForeground = false
            @unknown default:
                break
            }
        }
    }
}

class DataModel: ObservableObject {

    static var instance = DataModel()
    private init() {}
    @Published var text = "Hello, \(#function)!"
    var isInForeground = true
}

@available(iOS 13.0, *)
struct ContentView: View {
    @ObservedObject var dm = DataModel.instance

    var body: some View {
        VStack {
            Button(action: {
                UIPasteboard.general.string = dm.text
            }) {
                Text(dm.text)
                    .onAppear {
                        MyURLSessionDelegate
                            .instance
                            .urlSession
                            .downloadTask(
                                with: URLRequest(url: URL(string: "https://duck.com")!),
                                completionHandler: { [dm] url, urlResponse, error in
                                    if let url = url ?? urlResponse?.url {
                                        if let data = try? Data(contentsOf: url) {
                                            dm.text = String(data: data, encoding: .utf8) ?? data.base64EncodedString()
                                        } else {
                                            dm.text = url.absoluteString
                                        }
                                    } else if let error {
                                        dm.text = error.localizedDescription
                                    } else {
                                        dm.text = "downloaded https://duck.com"
                                    }
                                })
                            .resume()
                    }
            }
        }
    }
}

@available(iOS 13.0, *)
class MyURLSessionDelegate: NSObject {
    static let instance = MyURLSessionDelegate()
    private override init() {
        super.init()
    }

    var completionHandlers = [String: @Sendable (URL?, URLResponse?, Error?) -> Void]()

    var urlSession: URLSession { get {
        URLSession(
            configuration: .background(withIdentifier: UUID().uuidString),
            delegate: self,
            delegateQueue: nil)
    }}

}

@available(iOS 13.0, *)
extension MyURLSessionDelegate: URLSessionDelegate {
    // Tells the delegate that all messages enqueued for a session have been delivered.
    /*
     In iOS, when a background transfer completes or requires credentials, if your app is no longer running, your app is automatically relaunched in the background, and the app’s UIApplicationDelegate is sent an application(_:handleEventsForBackgroundURLSession:completionHandler:) message. This call contains the identifier of the session that caused your app to be launched. You should then store that completion handler before creating a background configuration object with the same identifier, and creating a session with that configuration. The newly created session is automatically reassociated with ongoing background activity.
     When your app later receives a urlSessionDidFinishEvents(forBackgroundURLSession:) message, this indicates that all messages previously enqueued for this session have been delivered, and that it is now safe to invoke the previously stored completion handler or to begin any internal updates that may result in invoking the completion handler.
     */
    public func urlSessionDidFinishEvents(forBackgroundURLSession: URLSession) {
#if DEBUG
        print(#function)
#endif
        // Handle the completion of events for background URL session here
        // Notify the app's completion handler, if any
        DispatchQueue.main.sync {
        }

    }

    public func urlSession(_ urlSession: URLSession, didBecomeInvalidWithError: Error?) {
        if let completionHandler = self.completionHandlers.removeValue(forKey: urlSession.configuration.identifier!) {
            if DataModel.instance.isInForeground {
                DispatchQueue.main.async {
                    completionHandler(nil, nil, didBecomeInvalidWithError)
                }
            } else {
                DispatchQueue.main.sync {
                    completionHandler(nil, nil, didBecomeInvalidWithError)
                }
            }
        }
    }
}

@available(iOS 13.0, *)
extension MyURLSessionDelegate: URLSessionDownloadDelegate {

    public func urlSession(_ urlSession: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
        if let completionHandler = self.completionHandlers.removeValue(forKey: urlSession.configuration.identifier!) {
            if DataModel.instance.isInForeground {
                DispatchQueue.main.async {
                    completionHandler(location, downloadTask.response, downloadTask.error)
                }
            } else {
                DispatchQueue.main.sync {
                    completionHandler(location, downloadTask.response, downloadTask.error)
                }
            }
        }
    }
}

extension URLSession {

    public func downloadTask(with request: URLRequest, completionHandler: @escaping @Sendable (URL?, URLResponse?, Error?) -> Void) -> URLSessionDownloadTask {
        MyURLSessionDelegate.instance.completionHandlers.updateValue(completionHandler, forKey: self.configuration.identifier!)
        return downloadTask(with: request)
    }

    public func downloadTask(with url: URL, completionHandler: @escaping @Sendable (URL?, URLResponse?, Error?) -> Void) -> URLSessionDownloadTask {
        MyURLSessionDelegate.instance.completionHandlers.updateValue(completionHandler, forKey: self.configuration.identifier!)
        return downloadTask(with: url)
    }

    public func downloadTask(withResumeData resumeData: Data, completionHandler: @escaping @Sendable (URL?, URLResponse?, Error?) -> Void) -> URLSessionDownloadTask {
        MyURLSessionDelegate.instance.completionHandlers.updateValue(completionHandler, forKey: self.configuration.identifier!)
        return downloadTask(withResumeData: resumeData)
    }

}