Allow web URL imports via the share extension#1516
Open
matalvernaz wants to merge 7 commits into
Open
Conversation
Lets users share an http(s) URL pointing at a media file (e.g. an mp3 served by a self-hosted yt-dlp / casting tool / podcast feed) into BookPlayer through the iOS share sheet. Today the share extension is hidden from the share sheet for URL-only payloads because the activation rule only matches public.audio / public.folder / public.movie / com.pkware.zip-archive attachments. Two changes: - Add public.url to the share extension's NSExtensionActivationRule so URL shares can reach ShareViewController in the first place. - In ShareViewController, branch saveSharedItems() on isFileURL: file URLs continue to be copied into the shared folder as before; web URLs are forwarded to the main app via bookplayer://download?url=... so the existing SingleFileDownloadService handles the actual fetch with the app's normal networking stack (Command.download and ActionParserService.handleDownloadAction already exist). To avoid showing BookPlayer in the share sheet for arbitrary web pages it could not actually download, web URLs are pre-filtered by path extension against a small allow-list of audio / movie / archive formats. File URLs (AirDrop, Files, document picker) are unchanged and accepted unconditionally. The extension hands off to the host app via the responder-chain walk + openURL: selector pattern used by 1Password, Pocket, and similar share extensions.
The previous responder-chain `openURL:` walk no longer reaches a live UIApplication from a share-extension context on iOS 18, so tapping Done in the share sheet for a URL just dismissed the extension and left the user back in Safari without launching BookPlayer. NSExtensionContext.open(_:completionHandler:) is documented as unavailable for share extensions, but it actually works on iOS 14+ and is the only reliable path on iOS 18+. Switch to it as the primary method, keeping the responder-chain walk as a fallback for older OSes where the extensionContext path returns false synchronously. Also defer completeRequest until after the URL handoff completes — completing first dismisses the extension before iOS dispatches the in-flight openURL, dropping it on the floor.
Current iOS makes the previous design unworkable: share extensions cannot launch their host app at all. The responder-chain openURL: walk silently no-ops with a system log warning, and the NSExtensionContext.open API is documented Today-widget-only and returns false for share extensions in practice. So the bookplayer://download?url=... handoff plan never reaches the main app. New approach: deposit the final file straight into the app group's shared folder ourselves. BookPlayer's main app already watches that folder via DirectoryWatcher in LibraryRootView, and runs ImportManager.notifyPendingFiles() on appear -- the same import pipeline that handles AirDropped audio. Web URLs become an additional contributor to that pipeline rather than triggering a separate download flow in the main app. The share extension now downloads the URL with a foreground URLSession.downloadTask while its loading UI is showing, then moves the result (with the server-suggested filename when available) into the shared folder before completing the extension request. Rejects non-2xx HTTP responses so a 404 HTML body never gets imported as audio. Foreground session is sufficient for typical share-sheet payloads (audio tracks, episodes -- tens of MB). A background URLSession with sharedContainerIdentifier is the right upgrade if multi-GB single files become a use case.
Build 5 worked but had two unwanted UX wrinkles: the share extension held its loading spinner until the URLSession download finished (slow on big files / slow networks), and arriving via the AirDrop file path triggered a second import-confirmation prompt inside BookPlayer. Better approach: stop downloading inside the share extension at all, and reuse SingleFileDownloadService -- the same in-app URL-download engine used by AudiobookShelf and Jellyfin integrations, with its own progress UI and direct-into-library output. Changes: - Share extension writes the queued web URLs into the app group's shared UserDefaults under Constants.UserDefaults.pendingShareDownloadURLs, then dismisses immediately. File-URL shares (AirDrop, Files) keep their existing copy-into-shared-folder behaviour. - LibraryRootView.handleLibraryLoaded() drains that key after the existing pendingURLActions drain and hands the URLs to singleFileDownloadService.handleDownload(_:). The download then runs in BookPlayer's normal UI and the file lands directly in Documents (the library) -- no separate import confirmation. - New Constants.UserDefaults.pendingShareDownloadURLs key documents the shared-defaults contract between extension and main app. The extension still requires the user to open BookPlayer for the download to start (same as today: the AirDrop import pipeline is also gated on foreground), but the share-sheet dismiss is now instant and the in-app experience matches the manual "download from URL" feature users already know.
Build 6 missed the warm-foreground case: handleLibraryLoaded only fires once per LibraryRootView lifecycle (gated behind isFirstLoad), so when BookPlayer was already in memory and the user shared to it from another app, the queued URL sat in shared UserDefaults forever. Fix: extract the drain into a standalone idempotent function and also trigger it from the existing .onChange(of: scenePhase) handler when scenePhase becomes .active. That covers warm foreground (the production failure mode); cold launch keeps working via handleLibraryLoaded as before. Includes NSLog instrumentation in the drain so device-log capture makes future regressions trivially observable. Verified end-to-end on iOS 26 simulator: URL written to shared UserDefaults, BookPlayer cold-launched, NSLog confirms drain fires with 1 URL and hands it to singleFileDownloadService, plist key cleared after launch.
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.
Contributor
Author
|
Caught up to current develop. Same six commits as before, just rebased — nothing else to see. |
20c522a to
858a00a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Lets users share an
http(s)URL pointing at a media file (e.g. an mp3 served by a self-hosted yt-dlp UI, a podcast episode link, a casting tool's direct download URL) into BookPlayer through the iOS share sheet. Today the share extension is hidden from the share sheet for URL-only payloads, since its activation rule only matchespublic.audio/public.folder/public.movie/com.pkware.zip-archiveattachments — and even if it weren't, the rest of the pipeline assumed file URLs that the extension couldcopyItemstraight into the shared folder.Architecture
The share extension's
NSExtensionActivationRuleacceptspublic.urlin addition to its existing UTIs. When a web URL arrives, the extension downloads it via a backgroundURLSessionwithsharedContainerIdentifierset to the app group, then immediately callscompleteRequest(returningItems: nil)— the share sheet dismisses without blocking on the download.Two delegate paths cover the lifecycle:
BackgroundDownloadCoordinator(inShareViewController.swift): retained on the running view controller for as long as the extension's process is alive. iOS routes theURLSession(_:downloadTask:didFinishDownloadingTo:)callback to this delegate when the download completes while the extension is still running (typical for small files that finish in seconds). The coordinator moves the temp file intoDataManager.getSharedFilesFolderURL().BackgroundShareDownloadDelegate(inAppDelegate.swift): the fallback for downloads large enough to outlive the extension. iOS launches the main app viaapplication(_:handleEventsForBackgroundURLSession:completionHandler:), which recreates a session under the sameConstants.shareExtensionBackgroundSessionIdentifierand lets this delegate move the temp file into the shared folder.Either way, the file lands in the app group's shared folder, and BookPlayer's existing
ImportManagerpipeline picks it up on the next foreground vianotifyPendingFiles()and theDirectoryWatcheralready wired to that folder. The user sees BookPlayer's standard import-and-place flow, identical to AirDrop.File-URL shares (AirDrop, Files, document picker) are unchanged: the extension still copies them directly into the shared folder.
URL filtering
To avoid showing BookPlayer in the share sheet for arbitrary web pages it couldn't actually download as audio, web URLs are pre-filtered by path extension against a small allow-list of audio / movie / archive formats:
mp3 m4a m4b aac flac ogg opus wav wma aiff aif caf mp4 m4v mov zip. File URLs are accepted unconditionally — the import pipeline handles MIME sniffing.Why background URLSession + delegate, and not the obvious alternatives
Earlier iterations tried each of these and ran into problems documented in the commit history:
bookplayer://download?url=…URL scheme. Doesn't work on iOS 18+: the responder-chainopenURL:walk no-ops with a system warning, andNSExtensionContext.open(_:completionHandler:)is documented Today-widget-only and confirmed to returnfalsefor share extensions.URLSession.downloadTaskinside the share extension, thencompleteRequest. Works, but the share UI hangs for the duration of the download.UserDefaults, drain in the main app viaSingleFileDownloadService. Required main-app code to fire on warm-foreground viascenePhase, raced withDocumentFolderWatcherto produce duplicate import dialogs, and ultimately couldn't be made to land files reliably without bypassing significant chunks of the existing pipeline.URLSessionin the extension withdelegate: nil. This was the most subtle failure. The session created the download task andnsurlsessiondfinished it correctly — but iOS only escalates background-session completion to the main app's matching-identifier session viahandleEventsForBackgroundURLSessionwhen the owning process is no longer running. For small downloads that complete in a few seconds, the share extension's process was still alive when the completion fired, so iOS routed thedidFinishDownloadingToevent to the alive process's session — which had no delegate — and silently discarded the temp file. The two-delegate setup in this PR covers both lifecycles.Test plan
https://…/song.mp3URL on iOS 26: file lands in shared folder, ImportManager surfaces it on next foreground, review and placement dialogs fire as for AirDrop, file imports into the libraryidevicesyslogthat the share extension'sBackgroundDownloadCoordinatorfiresdidFinishDownloadingToand moves the temp fileitems.filter { $0.isFileURL }keeping the originalcopyItempathBackgroundShareDownloadDelegate. Believed-correct from spec; not exercised in testing because typical share-sheet payloads are small.Known limitations