Skip to content

Commit 858a00a

Browse files
committed
Use background URLSession with delegate for share-extension downloads
Final architecture for the web-URL share path. Earlier iterations ranged through opening the host app via openURL: (broken on iOS 18+), queueing into shared UserDefaults for the main app to drain (race conditions and lost events), and downloading inline in the share extension's foreground (worked but blocked the share UI for the duration of the download). This commit lands the version that actually works in the way iOS expects. The share extension creates a background URLSession with sharedContainerIdentifier set to the app group, kicks off a downloadTask, and immediately returns from completeRequest -- the share UI dismisses without waiting on bytes. iOS' nsurlsessiond keeps the transfer running in its own daemon process. Two delivery paths handle the eventual completion: 1. If the share extension's process is still alive when the download completes (typical for small files that finish in seconds), iOS routes the URLSession completion to *the alive process's session*. The previous "delegate: nil" version silently dropped the temp file in this case because there was no delegate to claim it. Now ShareViewController retains a BackgroundDownloadCoordinator that moves the temp file into DataManager.getSharedFilesFolderURL(). 2. If iOS terminates the extension before the download completes (large files, slow networks), the transfer keeps running in nsurlsessiond. When it finishes, iOS launches the main app via application(_:handleEventsForBackgroundURLSession:completionHandler:); AppDelegate hands off to BackgroundShareDownloadDelegate, which recreates the same-identifier session, claims the temp file, and moves it into the same shared folder. Either way the file lands in the app group's shared folder, where the existing AirDrop-style import pipeline (notifyPendingFiles + DirectoryWatcher + ImportManager) picks it up on the next foreground and runs it through BookPlayer's standard review-and-place dialogs. File URL shares (AirDrop, Files app, document picker) keep their original synchronous-copy behaviour; only web URLs go through the background-session path. Includes the abandoned LibraryRootView UserDefaults-drain code being cleaned up from earlier iterations, plus Constants.shareExtensionBackgroundSessionIdentifier as the documented shared key between extension and main app.
1 parent c835361 commit 858a00a

4 files changed

Lines changed: 210 additions & 50 deletions

File tree

BookPlayer/AppDelegate.swift

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,30 @@ class AppDelegate: UIResponder, UIApplicationDelegate, BPLogger {
7979
// Setup core services
8080
AppServices.shared.setupCoreServices()
8181

82+
/// Hook the URLSession delegate that handles share-extension background downloads.
83+
/// Touching the lazy session re-attaches us as delegate for any transfer iOS already
84+
/// has in flight from a prior share — without this, completions queued before the app
85+
/// launched would be silently dropped.
86+
BackgroundShareDownloadDelegate.shared.ensureSessionReady()
87+
8288
return true
8389
}
8490

91+
func application(
92+
_ application: UIApplication,
93+
handleEventsForBackgroundURLSession identifier: String,
94+
completionHandler: @escaping () -> Void
95+
) {
96+
/// iOS calls this when a background URLSession we own has events to deliver and the app
97+
/// was suspended/terminated. We only own the share-extension session; ignore others.
98+
guard identifier == Constants.shareExtensionBackgroundSessionIdentifier else {
99+
completionHandler()
100+
return
101+
}
102+
BackgroundShareDownloadDelegate.shared.backgroundCompletionHandler = completionHandler
103+
BackgroundShareDownloadDelegate.shared.ensureSessionReady()
104+
}
105+
85106
func application(
86107
_ application: UIApplication,
87108
handle intent: INIntent,
@@ -468,3 +489,94 @@ extension AppDelegate {
468489
queue.addOperation(backupOperation)
469490
}
470491
}
492+
493+
/// Receives completion events for the background `URLSession` that the share extension
494+
/// uses to download shared web URLs. The share extension cannot keep its own foreground
495+
/// session alive after dismissal, so it kicks off the transfer with a background session
496+
/// keyed by `Constants.shareExtensionBackgroundSessionIdentifier`. When the download
497+
/// completes, iOS launches BookPlayer (in the background if needed) and routes events
498+
/// here. The delegate moves the temp file into the app group's shared folder, where
499+
/// BookPlayer's normal `ImportManager` pipeline picks it up the next time the user
500+
/// foregrounds the app.
501+
final class BackgroundShareDownloadDelegate: NSObject, URLSessionDownloadDelegate, BPLogger {
502+
static let shared = BackgroundShareDownloadDelegate()
503+
504+
/// Stored from `application(_:handleEventsForBackgroundURLSession:completionHandler:)` —
505+
/// must be invoked once `urlSessionDidFinishEvents` fires so iOS can take a fresh app
506+
/// snapshot.
507+
var backgroundCompletionHandler: (() -> Void)?
508+
509+
/// The session is recreated lazily with the same identifier the share extension uses,
510+
/// which connects this delegate to any in-flight transfers iOS is already running for us.
511+
private(set) lazy var session: URLSession = {
512+
let config = URLSessionConfiguration.background(
513+
withIdentifier: Constants.shareExtensionBackgroundSessionIdentifier
514+
)
515+
config.sharedContainerIdentifier = Constants.ApplicationGroupIdentifier
516+
config.sessionSendsLaunchEvents = true
517+
config.isDiscretionary = false
518+
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
519+
}()
520+
521+
/// Touch the lazy session so it hooks up as the delegate for any pending transfers. Call
522+
/// from `AppDelegate.didFinishLaunching` so completions don't get lost when the app cold-
523+
/// starts after a download finished while the app was suspended.
524+
func ensureSessionReady() {
525+
_ = session
526+
}
527+
528+
// MARK: - URLSessionDownloadDelegate
529+
530+
func urlSession(
531+
_ session: URLSession,
532+
downloadTask: URLSessionDownloadTask,
533+
didFinishDownloadingTo location: URL
534+
) {
535+
/// Reject non-2xx HTTP responses — `URLSession` reports success on a 404 and the temp
536+
/// file just contains the error body. Don't deposit garbage into the library.
537+
if let httpResponse = downloadTask.response as? HTTPURLResponse,
538+
!(200..<300).contains(httpResponse.statusCode)
539+
{
540+
Self.logger.error(
541+
"share download \(downloadTask.originalRequest?.url?.absoluteString ?? "?") returned status \(httpResponse.statusCode)"
542+
)
543+
return
544+
}
545+
546+
let originalURL = downloadTask.originalRequest?.url
547+
let filename =
548+
downloadTask.response?.suggestedFilename
549+
?? originalURL?.lastPathComponent
550+
?? "shared-\(UUID().uuidString)"
551+
let destinationURL = DataManager.getSharedFilesFolderURL().appendingPathComponent(filename)
552+
553+
/// Replace any same-named stale download from a previous attempt.
554+
try? FileManager.default.removeItem(at: destinationURL)
555+
do {
556+
try FileManager.default.moveItem(at: location, to: destinationURL)
557+
Self.logger.info("share download landed at \(destinationURL.lastPathComponent)")
558+
} catch {
559+
Self.logger.error("share download move failed: \(error.localizedDescription)")
560+
}
561+
}
562+
563+
func urlSession(
564+
_ session: URLSession,
565+
task: URLSessionTask,
566+
didCompleteWithError error: Error?
567+
) {
568+
if let error {
569+
Self.logger.error(
570+
"share download task error: \(error.localizedDescription) for \(task.originalRequest?.url?.absoluteString ?? "?")"
571+
)
572+
}
573+
}
574+
575+
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
576+
DispatchQueue.main.async { [weak self] in
577+
let handler = self?.backgroundCompletionHandler
578+
self?.backgroundCompletionHandler = nil
579+
handler?()
580+
}
581+
}
582+
}

BookPlayer/Library/ItemList/LibraryRootView.swift

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,6 @@ struct LibraryRootView: View {
9292
}
9393
.onChange(of: scenePhase) {
9494
guard scenePhase == .active else { return }
95-
drainPendingShareDownloadURLs()
9695
showImport()
9796
}
9897
.onReceive(syncService.downloadErrorPublisher) { (relativePath, error) in
@@ -143,30 +142,6 @@ struct LibraryRootView: View {
143142
for action in pendingActions {
144143
ActionParserService.handleAction(action)
145144
}
146-
147-
drainPendingShareDownloadURLs()
148-
}
149-
150-
/// Drain web URLs the share extension queued in shared `UserDefaults` and feed them
151-
/// to `SingleFileDownloadService` so the download runs in BookPlayer's normal UI and
152-
/// lands directly in the library. Called both on initial library load and on every
153-
/// `scenePhase == .active` transition: handleLibraryLoaded only fires once per
154-
/// `LibraryRootView` lifecycle, but the user can share to BookPlayer while it's still
155-
/// in memory in the background, in which case the drain has to run on warm-foreground
156-
/// too. Idempotent: removing the UserDefaults key first means re-runs are no-ops.
157-
func drainPendingShareDownloadURLs() {
158-
let pendingShareURLStrings =
159-
UserDefaults.sharedDefaults.stringArray(forKey: Constants.UserDefaults.pendingShareDownloadURLs)
160-
?? []
161-
NSLog("BP-DRAIN: drainPendingShareDownloadURLs called, found %d URLs", pendingShareURLStrings.count)
162-
guard !pendingShareURLStrings.isEmpty else { return }
163-
164-
UserDefaults.sharedDefaults.removeObject(forKey: Constants.UserDefaults.pendingShareDownloadURLs)
165-
let urls = pendingShareURLStrings.compactMap(URL.init(string:))
166-
if !urls.isEmpty {
167-
NSLog("BP-DRAIN: handing %d URLs to singleFileDownloadService", urls.count)
168-
singleFileDownloadService.handleDownload(urls)
169-
}
170145
}
171146

172147
func loadLastBookIfNeeded() async {

BookPlayerShareExtension/ShareViewController.swift

Lines changed: 90 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ class ShareViewController: UIViewController {
9090
/// In-memory array of shared items
9191
var sharedItems = [URL]()
9292

93+
/// Retained URLSession delegate. Held so iOS can deliver the completion to *this*
94+
/// process if the extension survives long enough — see `kickOffBackgroundDownloads`.
95+
private var backgroundDownloadCoordinator: BackgroundDownloadCoordinator?
96+
9397
/// File extensions BookPlayer can fetch from a remote `http(s)` URL.
9498
///
9599
/// File-URL shares (AirDrop, Files app) are accepted unconditionally — they're already
@@ -201,14 +205,19 @@ class ShareViewController: UIViewController {
201205
}
202206

203207
func saveSharedItems(_ items: [URL]) {
204-
/// Two paths: file URLs (AirDrop, Files, Photos) get copied into the app group's shared
205-
/// folder where the main app's `DirectoryWatcher` + `ImportManager` import them — same
206-
/// code path that handles AirDropped audio. Web URLs are stashed in the app group's
207-
/// shared `UserDefaults`; on next foreground the main app drains them and feeds them
208-
/// to its existing `SingleFileDownloadService`, so the download runs in BookPlayer's
209-
/// usual UI (progress bar, direct-into-library) instead of inside this extension.
210-
/// (Share extensions can't launch their host app on current iOS, so a real URL handoff
211-
/// isn't an option; queueing through shared `UserDefaults` is the closest substitute.)
208+
/// File URLs (AirDrop, Files, document picker) are concrete on-disk items: copy them
209+
/// into the app group's shared folder synchronously where the main app's
210+
/// `ImportManager` will pick them up on next foreground — same code path AirDropped
211+
/// audio uses.
212+
///
213+
/// Web URLs are handed to a background `URLSession` keyed by
214+
/// `Constants.shareExtensionBackgroundSessionIdentifier`, with
215+
/// `sharedContainerIdentifier` set to the app group. The transfer is owned by iOS's
216+
/// `nsurlsessiond` daemon, not by this extension's process, so we can immediately call
217+
/// `completeRequest` and let the share UI dismiss without waiting for the bytes. When
218+
/// the download finishes, iOS launches the BookPlayer main app (in the background if
219+
/// needed) and `BackgroundShareDownloadDelegate` moves the temp file into the same
220+
/// shared folder, where the standard import flow takes over.
212221
let fileItems = items.filter { $0.isFileURL }
213222
let webItems = items.filter { !$0.isFileURL }
214223

@@ -218,24 +227,48 @@ class ShareViewController: UIViewController {
218227
}
219228

220229
if !webItems.isEmpty {
221-
queueWebURLsForMainApp(webItems)
230+
kickOffBackgroundDownloads(for: webItems)
222231
}
223232

224233
completeRequestOnMain()
225234
}
226235

227-
/// Append web URLs to the app group's shared `pendingShareDownloadURLs` array. The main
228-
/// app reads and clears this array on next foreground (see `LibraryRootView.handleLibraryLoaded`)
229-
/// and hands each URL to `SingleFileDownloadService.handleDownload(_:)`.
230-
private func queueWebURLsForMainApp(_ urls: [URL]) {
231-
let strings = urls.map { $0.absoluteString }
232-
let existing =
233-
UserDefaults.sharedDefaults.stringArray(forKey: Constants.UserDefaults.pendingShareDownloadURLs)
234-
?? []
235-
UserDefaults.sharedDefaults.set(
236-
existing + strings,
237-
forKey: Constants.UserDefaults.pendingShareDownloadURLs
236+
/// Schedules a background `URLSession` download for each shared web URL.
237+
///
238+
/// Two delivery paths cover the lifecycle of the transfer:
239+
///
240+
/// 1. If the extension's process is still alive when the download completes (typical for
241+
/// small files that finish in seconds), iOS routes the completion to *this session's*
242+
/// `URLSessionDownloadDelegate` — `BackgroundDownloadCoordinator` below — which moves
243+
/// the temp file into the app group's shared folder.
244+
/// 2. If iOS suspends/terminates the extension before the download completes, the
245+
/// transfer continues via `nsurlsessiond` and iOS routes completion to the main app
246+
/// via `application(_:handleEventsForBackgroundURLSession:completionHandler:)`. Main
247+
/// app recreates the same session identifier and `BackgroundShareDownloadDelegate`
248+
/// handles the move there.
249+
///
250+
/// We previously created the session with `delegate: nil` assuming iOS would always
251+
/// route to the main app, but in practice downloads small enough to finish before the
252+
/// extension dies got their events delivered to a nil delegate and the temp file was
253+
/// silently discarded. The first path above plugs that gap.
254+
private func kickOffBackgroundDownloads(for urls: [URL]) {
255+
let config = URLSessionConfiguration.background(
256+
withIdentifier: Constants.shareExtensionBackgroundSessionIdentifier
238257
)
258+
config.sharedContainerIdentifier = Constants.ApplicationGroupIdentifier
259+
config.sessionSendsLaunchEvents = true
260+
config.isDiscretionary = false
261+
262+
/// Retain the coordinator on the running view controller so its delegate methods can
263+
/// fire if iOS keeps this process alive past the share-sheet dismissal — typical for
264+
/// downloads that complete in a few seconds.
265+
let coordinator = BackgroundDownloadCoordinator()
266+
self.backgroundDownloadCoordinator = coordinator
267+
let session = URLSession(configuration: config, delegate: coordinator, delegateQueue: nil)
268+
for url in urls {
269+
session.downloadTask(with: url).resume()
270+
}
271+
session.finishTasksAndInvalidate()
239272
}
240273

241274
private func completeRequestOnMain() {
@@ -303,3 +336,40 @@ extension ShareViewController: UITableViewDelegate {}
303336
enum ShareExtensionError: Error {
304337
case cancelled
305338
}
339+
340+
/// `URLSessionDownloadDelegate` for the share extension's background download.
341+
///
342+
/// Used when the extension's process is still alive when iOS finishes the download —
343+
/// typically the case for small files that complete in a few seconds. Without a delegate
344+
/// here, iOS would silently discard the temp file because the alive-extension's session
345+
/// is the active one (iOS only escalates to the main app's matching-identifier session
346+
/// when the extension's process is gone).
347+
///
348+
/// Moves the temp file into the app group's shared folder, where the main app's
349+
/// `ImportManager` picks it up on next foreground.
350+
final class BackgroundDownloadCoordinator: NSObject, URLSessionDownloadDelegate {
351+
func urlSession(
352+
_ session: URLSession,
353+
downloadTask: URLSessionDownloadTask,
354+
didFinishDownloadingTo location: URL
355+
) {
356+
/// Reject non-2xx HTTP responses — `URLSession` reports success on a 404 and the temp
357+
/// file just contains the error body.
358+
if let httpResponse = downloadTask.response as? HTTPURLResponse,
359+
!(200..<300).contains(httpResponse.statusCode)
360+
{
361+
return
362+
}
363+
364+
let originalURL = downloadTask.originalRequest?.url
365+
let filename =
366+
downloadTask.response?.suggestedFilename
367+
?? originalURL?.lastPathComponent
368+
?? "shared-\(UUID().uuidString)"
369+
let destinationURL = DataManager.getSharedFilesFolderURL().appendingPathComponent(filename)
370+
371+
/// Replace any same-named stale download from a previous attempt.
372+
try? FileManager.default.removeItem(at: destinationURL)
373+
try? FileManager.default.moveItem(at: location, to: destinationURL)
374+
}
375+
}

Shared/Constants.swift

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,6 @@ public enum Constants {
5353
/// Key to store an array of identifiers that need their progress recalculated
5454
public static let staleProgressIdentifiers = "staleProgressIdentifiers"
5555

56-
/// Web URLs the share extension queued for the main app to download with
57-
/// `SingleFileDownloadService` on next foreground. Lives in the app group's shared
58-
/// `UserDefaults` so both processes see the same value.
59-
public static let pendingShareDownloadURLs = "pendingShareDownloadURLs"
60-
6156
// One-time migrations
6257
public static let fileProtectionMigration = "userFileProtectionMigration"
6358

@@ -136,6 +131,14 @@ public enum Constants {
136131
public static let UserActivityPlayback = Bundle.main.bundleIdentifier! + ".activity.playback"
137132
public static let ApplicationGroupIdentifier = "group.\(Bundle.main.configurationString(for: .bundleIdentifier)).files"
138133

134+
/// `URLSessionConfiguration.background` identifier used by the share extension to download
135+
/// shared web URLs. The main app re-creates a session with the same identifier in
136+
/// `application(_:handleEventsForBackgroundURLSession:completionHandler:)` so the
137+
/// `BackgroundShareDownloadDelegate` receives completion and can move the resulting file
138+
/// into the app group's shared folder.
139+
public static let shareExtensionBackgroundSessionIdentifier =
140+
"\(Bundle.main.configurationString(for: .bundleIdentifier)).shareext.background"
141+
139142
public enum Widgets: String {
140143
case sharedNowPlayingWidget = "com.bookplayer.shared.widget"
141144
case sharedIconWidget = "com.bookplayer.shared.icon.widget"

0 commit comments

Comments
 (0)