Skip to content

Allow web URL imports via the share extension#1516

Open
matalvernaz wants to merge 7 commits into
TortugaPower:developfrom
matalvernaz:feature/share-url-import
Open

Allow web URL imports via the share extension#1516
matalvernaz wants to merge 7 commits into
TortugaPower:developfrom
matalvernaz:feature/share-url-import

Conversation

@matalvernaz
Copy link
Copy Markdown
Contributor

@matalvernaz matalvernaz commented Apr 27, 2026

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 matches public.audio / public.folder / public.movie / com.pkware.zip-archive attachments — and even if it weren't, the rest of the pipeline assumed file URLs that the extension could copyItem straight into the shared folder.

Architecture

The share extension's NSExtensionActivationRule accepts public.url in addition to its existing UTIs. When a web URL arrives, the extension downloads it via a background URLSession with sharedContainerIdentifier set to the app group, then immediately calls completeRequest(returningItems: nil) — the share sheet dismisses without blocking on the download.

Two delegate paths cover the lifecycle:

  • BackgroundDownloadCoordinator (in ShareViewController.swift): retained on the running view controller for as long as the extension's process is alive. iOS routes the URLSession(_: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 into DataManager.getSharedFilesFolderURL().
  • BackgroundShareDownloadDelegate (in AppDelegate.swift): the fallback for downloads large enough to outlive the extension. iOS launches the main app via application(_:handleEventsForBackgroundURLSession:completionHandler:), which recreates a session under the same Constants.shareExtensionBackgroundSessionIdentifier and 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 ImportManager pipeline picks it up on the next foreground via notifyPendingFiles() and the DirectoryWatcher already 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:

  • Open the host app via bookplayer://download?url=… URL scheme. Doesn't work on iOS 18+: the responder-chain openURL: walk no-ops with a system warning, and NSExtensionContext.open(_:completionHandler:) is documented Today-widget-only and confirmed to return false for share extensions.
  • Foreground URLSession.downloadTask inside the share extension, then completeRequest. Works, but the share UI hangs for the duration of the download.
  • Queue the URL into shared UserDefaults, drain in the main app via SingleFileDownloadService. Required main-app code to fire on warm-foreground via scenePhase, raced with DocumentFolderWatcher to produce duplicate import dialogs, and ultimately couldn't be made to land files reliably without bypassing significant chunks of the existing pipeline.
  • Background URLSession in the extension with delegate: nil. This was the most subtle failure. The session created the download task and nsurlsessiond finished it correctly — but iOS only escalates background-session completion to the main app's matching-identifier session via handleEventsForBackgroundURLSession when 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 the didFinishDownloadingTo event 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

  • Compile-check on iOS device target
  • End-to-end share from Safari to a real https://…/song.mp3 URL 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 library
  • Confirmed via idevicesyslog that the share extension's BackgroundDownloadCoordinator fires didFinishDownloadingTo and moves the temp file
  • File-URL shares (AirDrop, Files) unchanged — covered structurally by items.filter { $0.isFileURL } keeping the original copyItem path
  • Long-running download outlives the extension — falls through to the main-app BackgroundShareDownloadDelegate. Believed-correct from spec; not exercised in testing because typical share-sheet payloads are small.

Known limitations

  • iOS share-extension activation rules can predicate on UTI only, not URL content, so we filter inside the extension by path extension. BookPlayer will appear in the share sheet for any URL whose path happens to end in one of the allow-listed extensions even if the host is unrelated (rare in practice). Pages whose URL has no media-y extension won't show BookPlayer at all.

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.
@matalvernaz
Copy link
Copy Markdown
Contributor Author

Caught up to current develop. Same six commits as before, just rebased — nothing else to see.

@matalvernaz matalvernaz force-pushed the feature/share-url-import branch from 20c522a to 858a00a Compare May 5, 2026 10:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant