From 4f4c43847c11b69712ea5c071a90d6306fe4ac68 Mon Sep 17 00:00:00 2001 From: matalvernaz Date: Mon, 27 Apr 2026 06:49:02 -0700 Subject: [PATCH 1/9] Allow web URL imports via the share extension 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. --- BookPlayerShareExtension/Info.plist | 1 + .../ShareViewController.swift | 85 ++++++++++++++++--- 2 files changed, 75 insertions(+), 11 deletions(-) diff --git a/BookPlayerShareExtension/Info.plist b/BookPlayerShareExtension/Info.plist index d3001c53b..cf70421a2 100644 --- a/BookPlayerShareExtension/Info.plist +++ b/BookPlayerShareExtension/Info.plist @@ -20,6 +20,7 @@ || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.folder" || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.movie" || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.pkware.zip-archive" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" ) ).@count > 0 ).@count > 0 diff --git a/BookPlayerShareExtension/ShareViewController.swift b/BookPlayerShareExtension/ShareViewController.swift index 71eb10f1f..33896d67a 100644 --- a/BookPlayerShareExtension/ShareViewController.swift +++ b/BookPlayerShareExtension/ShareViewController.swift @@ -90,6 +90,19 @@ class ShareViewController: UIViewController { /// In-memory array of shared items var sharedItems = [URL]() + /// File extensions BookPlayer can fetch from a remote `http(s)` URL. + /// + /// File-URL shares (AirDrop, Files app) are accepted unconditionally — they're already + /// concrete files and the existing copy flow handles them. This list only gates web URLs, + /// where we'd otherwise hand the main app an arbitrary HTML page that `SingleFileDownloadService` + /// would dutifully save as a broken "audio" file. + static let supportedRemoteFileExtensions: Set = [ + "mp3", "m4a", "m4b", "aac", "flac", "ogg", "opus", "wav", "wma", + "aiff", "aif", "caf", + "mp4", "m4v", "mov", + "zip" + ] + override func viewDidLoad() { super.viewDidLoad() @@ -156,7 +169,9 @@ class ShareViewController: UIViewController { guard error == nil else { return } - if let url = data as? URL { + if let url = data as? URL, + ShareViewController.isSupportedShareURL(url) + { self?.sharedItems.append(url) } } @@ -186,20 +201,68 @@ class ShareViewController: UIViewController { } func saveSharedItems(_ items: [URL]) { - var mutableItems = items - guard !mutableItems.isEmpty else { - DispatchQueue.main.async { [weak self] in - self?.extensionContext?.completeRequest(returningItems: nil) - } - return + /// File URLs (AirDrop, Files, Photos) are concrete on-disk items: copy them into the + /// app group's shared folder where the main app will pick them up on next foreground. + /// Web URLs are forwarded to the main app via the `bookplayer://download` URL scheme + /// so `SingleFileDownloadService` can fetch them with the app's normal networking stack. + let fileItems = items.filter { $0.isFileURL } + let webItems = items.filter { !$0.isFileURL } + + for item in fileItems { + let destinationURL = sharedFolder.appendingPathComponent(item.lastPathComponent) + try? FileManager.default.copyItem(at: item, to: destinationURL) + } + + /// Share extensions only ever receive a single URL in practice (Safari and most apps + /// share one item at a time), so handing off the first web URL covers the realistic case. + if let webURL = webItems.first { + openInHostApp(downloadURL: webURL) } - let item = mutableItems.removeFirst() + DispatchQueue.main.async { [weak self] in + self?.extensionContext?.completeRequest(returningItems: nil) + } + } - let destinationURL = sharedFolder.appendingPathComponent(item.lastPathComponent) - try? FileManager.default.copyItem(at: item, to: destinationURL) + /// Hand a shareable URL to the main BookPlayer app via the `bookplayer://download?url=…` + /// custom URL scheme. + /// + /// Share extensions can't call `UIApplication.shared.open(_:)` directly, so we walk the + /// responder chain to find the `UIApplication` and invoke its `openURL:` selector. This + /// pattern is widely used by share extensions (1Password, Pocket, etc.) and is accepted + /// by App Review. + private func openInHostApp(downloadURL: URL) { + guard + let escaped = downloadURL.absoluteString.addingPercentEncoding( + withAllowedCharacters: .urlQueryAllowed + ), + let actionURL = URL(string: "bookplayer://download?url=\(escaped)") + else { return } + + let selector = NSSelectorFromString("openURL:") + var responder: UIResponder? = self + while let current = responder { + if current !== self, current.responds(to: selector) { + _ = current.perform(selector, with: actionURL) + return + } + responder = current.next + } + } - saveSharedItems(mutableItems) + /// Returns `true` for share items BookPlayer can usefully import. + /// + /// File URLs are always accepted (they're concrete files arriving via Files / AirDrop / + /// document pickers — the main app's import pipeline handles MIME sniffing). Web URLs + /// are only accepted when the path extension matches a known media or archive type, so + /// we don't appear in the share sheet for arbitrary web pages we couldn't actually + /// download as audio. + static func isSupportedShareURL(_ url: URL) -> Bool { + if url.isFileURL { return true } + guard let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" + else { return false } + return Self.supportedRemoteFileExtensions.contains(url.pathExtension.lowercased()) } func applyDefaultThemeColors() { From b275069fd6b601bb600892adb3d5b3462af70d58 Mon Sep 17 00:00:00 2001 From: matalvernaz Date: Mon, 27 Apr 2026 07:41:43 -0700 Subject: [PATCH 2/9] Fix iOS 18 host-app handoff: use NSExtensionContext.open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../ShareViewController.swift | 49 ++++++++++++++----- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/BookPlayerShareExtension/ShareViewController.swift b/BookPlayerShareExtension/ShareViewController.swift index 33896d67a..9e455da9c 100644 --- a/BookPlayerShareExtension/ShareViewController.swift +++ b/BookPlayerShareExtension/ShareViewController.swift @@ -215,35 +215,60 @@ class ShareViewController: UIViewController { /// Share extensions only ever receive a single URL in practice (Safari and most apps /// share one item at a time), so handing off the first web URL covers the realistic case. + /// We must defer `completeRequest` until *after* the URL has been handed to the host — + /// completing the extension request first dismisses the extension's process and iOS + /// drops the in-flight URL open. if let webURL = webItems.first { - openInHostApp(downloadURL: webURL) - } - - DispatchQueue.main.async { [weak self] in - self?.extensionContext?.completeRequest(returningItems: nil) + openInHostApp(downloadURL: webURL) { [weak self] in + DispatchQueue.main.async { + self?.extensionContext?.completeRequest(returningItems: nil) + } + } + } else { + DispatchQueue.main.async { [weak self] in + self?.extensionContext?.completeRequest(returningItems: nil) + } } } /// Hand a shareable URL to the main BookPlayer app via the `bookplayer://download?url=…` /// custom URL scheme. /// - /// Share extensions can't call `UIApplication.shared.open(_:)` directly, so we walk the - /// responder chain to find the `UIApplication` and invoke its `openURL:` selector. This - /// pattern is widely used by share extensions (1Password, Pocket, etc.) and is accepted - /// by App Review. - private func openInHostApp(downloadURL: URL) { + /// Tries `NSExtensionContext.open(_:completionHandler:)` first — Apple's docs claim it's + /// unavailable for share extensions, but in practice it works on iOS 14+ and is the only + /// reliable path on iOS 18+, where the older responder-chain `openURL:` walk no longer + /// reaches a live `UIApplication` from a share-extension context. Falls back to the + /// responder-chain walk for older iOS versions where the extensionContext path may return + /// false synchronously. + private func openInHostApp(downloadURL: URL, completion: @escaping () -> Void) { guard let escaped = downloadURL.absoluteString.addingPercentEncoding( withAllowedCharacters: .urlQueryAllowed ), let actionURL = URL(string: "bookplayer://download?url=\(escaped)") - else { return } + else { + completion() + return + } + + extensionContext?.open(actionURL) { [weak self] success in + if success { + completion() + } else { + self?.fallbackOpenViaResponderChain(actionURL) + completion() + } + } + } + /// Legacy share-extension URL-open path: send `openURL:` up the responder chain until a + /// `UIApplication` accepts it. Pre-iOS 18 fallback only. + private func fallbackOpenViaResponderChain(_ url: URL) { let selector = NSSelectorFromString("openURL:") var responder: UIResponder? = self while let current = responder { if current !== self, current.responds(to: selector) { - _ = current.perform(selector, with: actionURL) + _ = current.perform(selector, with: url) return } responder = current.next From 3f8159951d6178796ccf683d65a07f15918b9dc9 Mon Sep 17 00:00:00 2001 From: matalvernaz Date: Mon, 27 Apr 2026 08:19:15 -0700 Subject: [PATCH 3/9] Download web URLs in the extension instead of opening host app 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. --- .../ShareViewController.swift | 104 +++++++++--------- 1 file changed, 51 insertions(+), 53 deletions(-) diff --git a/BookPlayerShareExtension/ShareViewController.swift b/BookPlayerShareExtension/ShareViewController.swift index 9e455da9c..0f6947d01 100644 --- a/BookPlayerShareExtension/ShareViewController.swift +++ b/BookPlayerShareExtension/ShareViewController.swift @@ -201,10 +201,14 @@ class ShareViewController: UIViewController { } func saveSharedItems(_ items: [URL]) { - /// File URLs (AirDrop, Files, Photos) are concrete on-disk items: copy them into the - /// app group's shared folder where the main app will pick them up on next foreground. - /// Web URLs are forwarded to the main app via the `bookplayer://download` URL scheme - /// so `SingleFileDownloadService` can fetch them with the app's normal networking stack. + /// Share extensions can't launch their host app on current iOS — the responder-chain + /// `openURL:` trick silently no-ops, and `NSExtensionContext.open` is documented as + /// Today-widget-only. So instead of trying to hand a URL to the main app, we deposit the + /// final file into the app group's shared folder ourselves: file URLs are copied (already + /// concrete on disk), web URLs are downloaded. Either way, BookPlayer's main-app + /// `DirectoryWatcher` on `getSharedFilesFolderURL()` (see `LibraryRootView`) and + /// `ImportManager.notifyPendingFiles()` import the file on the next foreground — the + /// same code path that handles AirDropped audio. let fileItems = items.filter { $0.isFileURL } let webItems = items.filter { !$0.isFileURL } @@ -213,65 +217,59 @@ class ShareViewController: UIViewController { try? FileManager.default.copyItem(at: item, to: destinationURL) } - /// Share extensions only ever receive a single URL in practice (Safari and most apps - /// share one item at a time), so handing off the first web URL covers the realistic case. - /// We must defer `completeRequest` until *after* the URL has been handed to the host — - /// completing the extension request first dismisses the extension's process and iOS - /// drops the in-flight URL open. + /// Share extensions in practice receive a single URL at a time (Safari and most apps + /// share one item per gesture), so downloading the first web URL covers the realistic + /// case. We hold off on `completeRequest` until the download finishes — a foreground + /// `URLSession` is bound to the share extension's process lifetime, so completing the + /// request first would tear the download down mid-flight. if let webURL = webItems.first { - openInHostApp(downloadURL: webURL) { [weak self] in - DispatchQueue.main.async { - self?.extensionContext?.completeRequest(returningItems: nil) - } + downloadIntoSharedFolder(webURL) { [weak self] in + self?.completeRequestOnMain() } } else { - DispatchQueue.main.async { [weak self] in - self?.extensionContext?.completeRequest(returningItems: nil) - } + completeRequestOnMain() } } - /// Hand a shareable URL to the main BookPlayer app via the `bookplayer://download?url=…` - /// custom URL scheme. + /// Download a web URL straight into the app group's shared folder, then invoke + /// `completion` (success or failure) so the caller can dismiss the extension. /// - /// Tries `NSExtensionContext.open(_:completionHandler:)` first — Apple's docs claim it's - /// unavailable for share extensions, but in practice it works on iOS 14+ and is the only - /// reliable path on iOS 18+, where the older responder-chain `openURL:` walk no longer - /// reaches a live `UIApplication` from a share-extension context. Falls back to the - /// responder-chain walk for older iOS versions where the extensionContext path may return - /// false synchronously. - private func openInHostApp(downloadURL: URL, completion: @escaping () -> Void) { - guard - let escaped = downloadURL.absoluteString.addingPercentEncoding( - withAllowedCharacters: .urlQueryAllowed - ), - let actionURL = URL(string: "bookplayer://download?url=\(escaped)") - else { - completion() - return - } - - extensionContext?.open(actionURL) { [weak self] success in - if success { - completion() - } else { - self?.fallbackOpenViaResponderChain(actionURL) - completion() - } + /// Uses a foreground `URLSession` for v1 — fine for the audio-file sizes typical of + /// share-sheet senders (single tracks, episodes, chapters in the tens-of-MB range). For + /// multi-gigabyte single files, switching to a background `URLSession` configured with + /// `sharedContainerIdentifier` would let the transfer survive the extension dismissing, + /// at the cost of needing the main-app `AppDelegate` to handle + /// `application(_:handleEventsForBackgroundURLSession:completionHandler:)`. + private func downloadIntoSharedFolder(_ url: URL, completion: @escaping () -> Void) { + let destinationFolder = sharedFolder + let task = URLSession.shared.downloadTask(with: url) { tempURL, response, error in + defer { completion() } + + /// `URLSession.downloadTask` reports success on non-2xx responses too — the temp + /// file just contains the error body. Reject anything that isn't a 2xx so we don't + /// move a 404 HTML page into the library named `song.mp3`. + guard error == nil, let tempURL, + let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode) + else { return } + + /// Prefer the server-suggested filename (Content-Disposition) over the URL's last + /// path component — yt-dlp-multi etc. set this to the actual track name rather than + /// the opaque token segment. + let filename = httpResponse.suggestedFilename ?? url.lastPathComponent + let destinationURL = destinationFolder.appendingPathComponent(filename) + + /// Replace any existing file of the same name in the shared folder so the import + /// pipeline doesn't get confused by a stale half-download. + try? FileManager.default.removeItem(at: destinationURL) + try? FileManager.default.moveItem(at: tempURL, to: destinationURL) } + task.resume() } - /// Legacy share-extension URL-open path: send `openURL:` up the responder chain until a - /// `UIApplication` accepts it. Pre-iOS 18 fallback only. - private func fallbackOpenViaResponderChain(_ url: URL) { - let selector = NSSelectorFromString("openURL:") - var responder: UIResponder? = self - while let current = responder { - if current !== self, current.responds(to: selector) { - _ = current.perform(selector, with: url) - return - } - responder = current.next + private func completeRequestOnMain() { + DispatchQueue.main.async { [weak self] in + self?.extensionContext?.completeRequest(returningItems: nil) } } From 4bae450bfb19e4115a38a4095541d1f7e3fa05c1 Mon Sep 17 00:00:00 2001 From: matalvernaz Date: Mon, 27 Apr 2026 12:36:14 -0700 Subject: [PATCH 4/9] Hand web URLs to SingleFileDownloadService via shared UserDefaults 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. --- .../Library/ItemList/LibraryRootView.swift | 14 ++++ .../ShareViewController.swift | 77 ++++++------------- Shared/Constants.swift | 5 ++ 3 files changed, 43 insertions(+), 53 deletions(-) diff --git a/BookPlayer/Library/ItemList/LibraryRootView.swift b/BookPlayer/Library/ItemList/LibraryRootView.swift index 9f7179b20..193597f52 100644 --- a/BookPlayer/Library/ItemList/LibraryRootView.swift +++ b/BookPlayer/Library/ItemList/LibraryRootView.swift @@ -142,6 +142,20 @@ struct LibraryRootView: View { for action in pendingActions { ActionParserService.handleAction(action) } + + /// Drain web URLs queued by the share extension into `SingleFileDownloadService` so + /// they download with progress in BookPlayer's normal UI and land directly in the + /// library (no separate AirDrop-style import confirmation). + let pendingShareURLStrings = + UserDefaults.sharedDefaults.stringArray(forKey: Constants.UserDefaults.pendingShareDownloadURLs) + ?? [] + if !pendingShareURLStrings.isEmpty { + UserDefaults.sharedDefaults.removeObject(forKey: Constants.UserDefaults.pendingShareDownloadURLs) + let urls = pendingShareURLStrings.compactMap(URL.init(string:)) + if !urls.isEmpty { + singleFileDownloadService.handleDownload(urls) + } + } } func loadLastBookIfNeeded() async { diff --git a/BookPlayerShareExtension/ShareViewController.swift b/BookPlayerShareExtension/ShareViewController.swift index 0f6947d01..fa3114831 100644 --- a/BookPlayerShareExtension/ShareViewController.swift +++ b/BookPlayerShareExtension/ShareViewController.swift @@ -201,14 +201,14 @@ class ShareViewController: UIViewController { } func saveSharedItems(_ items: [URL]) { - /// Share extensions can't launch their host app on current iOS — the responder-chain - /// `openURL:` trick silently no-ops, and `NSExtensionContext.open` is documented as - /// Today-widget-only. So instead of trying to hand a URL to the main app, we deposit the - /// final file into the app group's shared folder ourselves: file URLs are copied (already - /// concrete on disk), web URLs are downloaded. Either way, BookPlayer's main-app - /// `DirectoryWatcher` on `getSharedFilesFolderURL()` (see `LibraryRootView`) and - /// `ImportManager.notifyPendingFiles()` import the file on the next foreground — the - /// same code path that handles AirDropped audio. + /// Two paths: file URLs (AirDrop, Files, Photos) get copied into the app group's shared + /// folder where the main app's `DirectoryWatcher` + `ImportManager` import them — same + /// code path that handles AirDropped audio. Web URLs are stashed in the app group's + /// shared `UserDefaults`; on next foreground the main app drains them and feeds them + /// to its existing `SingleFileDownloadService`, so the download runs in BookPlayer's + /// usual UI (progress bar, direct-into-library) instead of inside this extension. + /// (Share extensions can't launch their host app on current iOS, so a real URL handoff + /// isn't an option; queueing through shared `UserDefaults` is the closest substitute.) let fileItems = items.filter { $0.isFileURL } let webItems = items.filter { !$0.isFileURL } @@ -217,54 +217,25 @@ class ShareViewController: UIViewController { try? FileManager.default.copyItem(at: item, to: destinationURL) } - /// Share extensions in practice receive a single URL at a time (Safari and most apps - /// share one item per gesture), so downloading the first web URL covers the realistic - /// case. We hold off on `completeRequest` until the download finishes — a foreground - /// `URLSession` is bound to the share extension's process lifetime, so completing the - /// request first would tear the download down mid-flight. - if let webURL = webItems.first { - downloadIntoSharedFolder(webURL) { [weak self] in - self?.completeRequestOnMain() - } - } else { - completeRequestOnMain() + if !webItems.isEmpty { + queueWebURLsForMainApp(webItems) } + + completeRequestOnMain() } - /// Download a web URL straight into the app group's shared folder, then invoke - /// `completion` (success or failure) so the caller can dismiss the extension. - /// - /// Uses a foreground `URLSession` for v1 — fine for the audio-file sizes typical of - /// share-sheet senders (single tracks, episodes, chapters in the tens-of-MB range). For - /// multi-gigabyte single files, switching to a background `URLSession` configured with - /// `sharedContainerIdentifier` would let the transfer survive the extension dismissing, - /// at the cost of needing the main-app `AppDelegate` to handle - /// `application(_:handleEventsForBackgroundURLSession:completionHandler:)`. - private func downloadIntoSharedFolder(_ url: URL, completion: @escaping () -> Void) { - let destinationFolder = sharedFolder - let task = URLSession.shared.downloadTask(with: url) { tempURL, response, error in - defer { completion() } - - /// `URLSession.downloadTask` reports success on non-2xx responses too — the temp - /// file just contains the error body. Reject anything that isn't a 2xx so we don't - /// move a 404 HTML page into the library named `song.mp3`. - guard error == nil, let tempURL, - let httpResponse = response as? HTTPURLResponse, - (200..<300).contains(httpResponse.statusCode) - else { return } - - /// Prefer the server-suggested filename (Content-Disposition) over the URL's last - /// path component — yt-dlp-multi etc. set this to the actual track name rather than - /// the opaque token segment. - let filename = httpResponse.suggestedFilename ?? url.lastPathComponent - let destinationURL = destinationFolder.appendingPathComponent(filename) - - /// Replace any existing file of the same name in the shared folder so the import - /// pipeline doesn't get confused by a stale half-download. - try? FileManager.default.removeItem(at: destinationURL) - try? FileManager.default.moveItem(at: tempURL, to: destinationURL) - } - task.resume() + /// Append web URLs to the app group's shared `pendingShareDownloadURLs` array. The main + /// app reads and clears this array on next foreground (see `LibraryRootView.handleLibraryLoaded`) + /// and hands each URL to `SingleFileDownloadService.handleDownload(_:)`. + private func queueWebURLsForMainApp(_ urls: [URL]) { + let strings = urls.map { $0.absoluteString } + let existing = + UserDefaults.sharedDefaults.stringArray(forKey: Constants.UserDefaults.pendingShareDownloadURLs) + ?? [] + UserDefaults.sharedDefaults.set( + existing + strings, + forKey: Constants.UserDefaults.pendingShareDownloadURLs + ) } private func completeRequestOnMain() { diff --git a/Shared/Constants.swift b/Shared/Constants.swift index 8d4c94207..7b8ef47de 100644 --- a/Shared/Constants.swift +++ b/Shared/Constants.swift @@ -53,6 +53,11 @@ public enum Constants { /// Key to store an array of identifiers that need their progress recalculated public static let staleProgressIdentifiers = "staleProgressIdentifiers" + /// Web URLs the share extension queued for the main app to download with + /// `SingleFileDownloadService` on next foreground. Lives in the app group's shared + /// `UserDefaults` so both processes see the same value. + public static let pendingShareDownloadURLs = "pendingShareDownloadURLs" + // One-time migrations public static let fileProtectionMigration = "userFileProtectionMigration" From c83536134f7b0566b3cde604d3332de9b67e84df Mon Sep 17 00:00:00 2001 From: matalvernaz Date: Mon, 27 Apr 2026 13:15:40 -0700 Subject: [PATCH 5/9] Drain pending share-download URLs on every scenePhase=.active 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. --- .../Library/ItemList/LibraryRootView.swift | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/BookPlayer/Library/ItemList/LibraryRootView.swift b/BookPlayer/Library/ItemList/LibraryRootView.swift index 193597f52..d095a16eb 100644 --- a/BookPlayer/Library/ItemList/LibraryRootView.swift +++ b/BookPlayer/Library/ItemList/LibraryRootView.swift @@ -92,6 +92,7 @@ struct LibraryRootView: View { } .onChange(of: scenePhase) { guard scenePhase == .active else { return } + drainPendingShareDownloadURLs() showImport() } .onReceive(syncService.downloadErrorPublisher) { (relativePath, error) in @@ -143,18 +144,28 @@ struct LibraryRootView: View { ActionParserService.handleAction(action) } - /// Drain web URLs queued by the share extension into `SingleFileDownloadService` so - /// they download with progress in BookPlayer's normal UI and land directly in the - /// library (no separate AirDrop-style import confirmation). + drainPendingShareDownloadURLs() + } + + /// Drain web URLs the share extension queued in shared `UserDefaults` and feed them + /// to `SingleFileDownloadService` so the download runs in BookPlayer's normal UI and + /// lands directly in the library. Called both on initial library load and on every + /// `scenePhase == .active` transition: handleLibraryLoaded only fires once per + /// `LibraryRootView` lifecycle, but the user can share to BookPlayer while it's still + /// in memory in the background, in which case the drain has to run on warm-foreground + /// too. Idempotent: removing the UserDefaults key first means re-runs are no-ops. + func drainPendingShareDownloadURLs() { let pendingShareURLStrings = UserDefaults.sharedDefaults.stringArray(forKey: Constants.UserDefaults.pendingShareDownloadURLs) ?? [] - if !pendingShareURLStrings.isEmpty { - UserDefaults.sharedDefaults.removeObject(forKey: Constants.UserDefaults.pendingShareDownloadURLs) - let urls = pendingShareURLStrings.compactMap(URL.init(string:)) - if !urls.isEmpty { - singleFileDownloadService.handleDownload(urls) - } + NSLog("BP-DRAIN: drainPendingShareDownloadURLs called, found %d URLs", pendingShareURLStrings.count) + guard !pendingShareURLStrings.isEmpty else { return } + + UserDefaults.sharedDefaults.removeObject(forKey: Constants.UserDefaults.pendingShareDownloadURLs) + let urls = pendingShareURLStrings.compactMap(URL.init(string:)) + if !urls.isEmpty { + NSLog("BP-DRAIN: handing %d URLs to singleFileDownloadService", urls.count) + singleFileDownloadService.handleDownload(urls) } } From 858a00ae9f74e8a3f67a8f6ad1e4623ae94f605d Mon Sep 17 00:00:00 2001 From: matalvernaz Date: Mon, 27 Apr 2026 16:09:34 -0700 Subject: [PATCH 6/9] 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. --- BookPlayer/AppDelegate.swift | 112 ++++++++++++++++++ .../Library/ItemList/LibraryRootView.swift | 25 ---- .../ShareViewController.swift | 110 +++++++++++++---- Shared/Constants.swift | 13 +- 4 files changed, 210 insertions(+), 50 deletions(-) diff --git a/BookPlayer/AppDelegate.swift b/BookPlayer/AppDelegate.swift index 04c77fba9..540728076 100644 --- a/BookPlayer/AppDelegate.swift +++ b/BookPlayer/AppDelegate.swift @@ -79,9 +79,30 @@ class AppDelegate: UIResponder, UIApplicationDelegate, BPLogger { // Setup core services AppServices.shared.setupCoreServices() + /// Hook the URLSession delegate that handles share-extension background downloads. + /// Touching the lazy session re-attaches us as delegate for any transfer iOS already + /// has in flight from a prior share — without this, completions queued before the app + /// launched would be silently dropped. + BackgroundShareDownloadDelegate.shared.ensureSessionReady() + return true } + func application( + _ application: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping () -> Void + ) { + /// iOS calls this when a background URLSession we own has events to deliver and the app + /// was suspended/terminated. We only own the share-extension session; ignore others. + guard identifier == Constants.shareExtensionBackgroundSessionIdentifier else { + completionHandler() + return + } + BackgroundShareDownloadDelegate.shared.backgroundCompletionHandler = completionHandler + BackgroundShareDownloadDelegate.shared.ensureSessionReady() + } + func application( _ application: UIApplication, handle intent: INIntent, @@ -468,3 +489,94 @@ extension AppDelegate { queue.addOperation(backupOperation) } } + +/// Receives completion events for the background `URLSession` that the share extension +/// uses to download shared web URLs. The share extension cannot keep its own foreground +/// session alive after dismissal, so it kicks off the transfer with a background session +/// keyed by `Constants.shareExtensionBackgroundSessionIdentifier`. When the download +/// completes, iOS launches BookPlayer (in the background if needed) and routes events +/// here. The delegate moves the temp file into the app group's shared folder, where +/// BookPlayer's normal `ImportManager` pipeline picks it up the next time the user +/// foregrounds the app. +final class BackgroundShareDownloadDelegate: NSObject, URLSessionDownloadDelegate, BPLogger { + static let shared = BackgroundShareDownloadDelegate() + + /// Stored from `application(_:handleEventsForBackgroundURLSession:completionHandler:)` — + /// must be invoked once `urlSessionDidFinishEvents` fires so iOS can take a fresh app + /// snapshot. + var backgroundCompletionHandler: (() -> Void)? + + /// The session is recreated lazily with the same identifier the share extension uses, + /// which connects this delegate to any in-flight transfers iOS is already running for us. + private(set) lazy var session: URLSession = { + let config = URLSessionConfiguration.background( + withIdentifier: Constants.shareExtensionBackgroundSessionIdentifier + ) + config.sharedContainerIdentifier = Constants.ApplicationGroupIdentifier + config.sessionSendsLaunchEvents = true + config.isDiscretionary = false + return URLSession(configuration: config, delegate: self, delegateQueue: nil) + }() + + /// Touch the lazy session so it hooks up as the delegate for any pending transfers. Call + /// from `AppDelegate.didFinishLaunching` so completions don't get lost when the app cold- + /// starts after a download finished while the app was suspended. + func ensureSessionReady() { + _ = session + } + + // MARK: - URLSessionDownloadDelegate + + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL + ) { + /// Reject non-2xx HTTP responses — `URLSession` reports success on a 404 and the temp + /// file just contains the error body. Don't deposit garbage into the library. + if let httpResponse = downloadTask.response as? HTTPURLResponse, + !(200..<300).contains(httpResponse.statusCode) + { + Self.logger.error( + "share download \(downloadTask.originalRequest?.url?.absoluteString ?? "?") returned status \(httpResponse.statusCode)" + ) + return + } + + let originalURL = downloadTask.originalRequest?.url + let filename = + downloadTask.response?.suggestedFilename + ?? originalURL?.lastPathComponent + ?? "shared-\(UUID().uuidString)" + let destinationURL = DataManager.getSharedFilesFolderURL().appendingPathComponent(filename) + + /// Replace any same-named stale download from a previous attempt. + try? FileManager.default.removeItem(at: destinationURL) + do { + try FileManager.default.moveItem(at: location, to: destinationURL) + Self.logger.info("share download landed at \(destinationURL.lastPathComponent)") + } catch { + Self.logger.error("share download move failed: \(error.localizedDescription)") + } + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + if let error { + Self.logger.error( + "share download task error: \(error.localizedDescription) for \(task.originalRequest?.url?.absoluteString ?? "?")" + ) + } + } + + func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + DispatchQueue.main.async { [weak self] in + let handler = self?.backgroundCompletionHandler + self?.backgroundCompletionHandler = nil + handler?() + } + } +} diff --git a/BookPlayer/Library/ItemList/LibraryRootView.swift b/BookPlayer/Library/ItemList/LibraryRootView.swift index d095a16eb..9f7179b20 100644 --- a/BookPlayer/Library/ItemList/LibraryRootView.swift +++ b/BookPlayer/Library/ItemList/LibraryRootView.swift @@ -92,7 +92,6 @@ struct LibraryRootView: View { } .onChange(of: scenePhase) { guard scenePhase == .active else { return } - drainPendingShareDownloadURLs() showImport() } .onReceive(syncService.downloadErrorPublisher) { (relativePath, error) in @@ -143,30 +142,6 @@ struct LibraryRootView: View { for action in pendingActions { ActionParserService.handleAction(action) } - - drainPendingShareDownloadURLs() - } - - /// Drain web URLs the share extension queued in shared `UserDefaults` and feed them - /// to `SingleFileDownloadService` so the download runs in BookPlayer's normal UI and - /// lands directly in the library. Called both on initial library load and on every - /// `scenePhase == .active` transition: handleLibraryLoaded only fires once per - /// `LibraryRootView` lifecycle, but the user can share to BookPlayer while it's still - /// in memory in the background, in which case the drain has to run on warm-foreground - /// too. Idempotent: removing the UserDefaults key first means re-runs are no-ops. - func drainPendingShareDownloadURLs() { - let pendingShareURLStrings = - UserDefaults.sharedDefaults.stringArray(forKey: Constants.UserDefaults.pendingShareDownloadURLs) - ?? [] - NSLog("BP-DRAIN: drainPendingShareDownloadURLs called, found %d URLs", pendingShareURLStrings.count) - guard !pendingShareURLStrings.isEmpty else { return } - - UserDefaults.sharedDefaults.removeObject(forKey: Constants.UserDefaults.pendingShareDownloadURLs) - let urls = pendingShareURLStrings.compactMap(URL.init(string:)) - if !urls.isEmpty { - NSLog("BP-DRAIN: handing %d URLs to singleFileDownloadService", urls.count) - singleFileDownloadService.handleDownload(urls) - } } func loadLastBookIfNeeded() async { diff --git a/BookPlayerShareExtension/ShareViewController.swift b/BookPlayerShareExtension/ShareViewController.swift index fa3114831..9a5bf729a 100644 --- a/BookPlayerShareExtension/ShareViewController.swift +++ b/BookPlayerShareExtension/ShareViewController.swift @@ -90,6 +90,10 @@ class ShareViewController: UIViewController { /// In-memory array of shared items var sharedItems = [URL]() + /// Retained URLSession delegate. Held so iOS can deliver the completion to *this* + /// process if the extension survives long enough — see `kickOffBackgroundDownloads`. + private var backgroundDownloadCoordinator: BackgroundDownloadCoordinator? + /// File extensions BookPlayer can fetch from a remote `http(s)` URL. /// /// File-URL shares (AirDrop, Files app) are accepted unconditionally — they're already @@ -201,14 +205,19 @@ class ShareViewController: UIViewController { } func saveSharedItems(_ items: [URL]) { - /// Two paths: file URLs (AirDrop, Files, Photos) get copied into the app group's shared - /// folder where the main app's `DirectoryWatcher` + `ImportManager` import them — same - /// code path that handles AirDropped audio. Web URLs are stashed in the app group's - /// shared `UserDefaults`; on next foreground the main app drains them and feeds them - /// to its existing `SingleFileDownloadService`, so the download runs in BookPlayer's - /// usual UI (progress bar, direct-into-library) instead of inside this extension. - /// (Share extensions can't launch their host app on current iOS, so a real URL handoff - /// isn't an option; queueing through shared `UserDefaults` is the closest substitute.) + /// File URLs (AirDrop, Files, document picker) are concrete on-disk items: copy them + /// into the app group's shared folder synchronously where the main app's + /// `ImportManager` will pick them up on next foreground — same code path AirDropped + /// audio uses. + /// + /// Web URLs are handed to a background `URLSession` keyed by + /// `Constants.shareExtensionBackgroundSessionIdentifier`, with + /// `sharedContainerIdentifier` set to the app group. The transfer is owned by iOS's + /// `nsurlsessiond` daemon, not by this extension's process, so we can immediately call + /// `completeRequest` and let the share UI dismiss without waiting for the bytes. When + /// the download finishes, iOS launches the BookPlayer main app (in the background if + /// needed) and `BackgroundShareDownloadDelegate` moves the temp file into the same + /// shared folder, where the standard import flow takes over. let fileItems = items.filter { $0.isFileURL } let webItems = items.filter { !$0.isFileURL } @@ -218,24 +227,48 @@ class ShareViewController: UIViewController { } if !webItems.isEmpty { - queueWebURLsForMainApp(webItems) + kickOffBackgroundDownloads(for: webItems) } completeRequestOnMain() } - /// Append web URLs to the app group's shared `pendingShareDownloadURLs` array. The main - /// app reads and clears this array on next foreground (see `LibraryRootView.handleLibraryLoaded`) - /// and hands each URL to `SingleFileDownloadService.handleDownload(_:)`. - private func queueWebURLsForMainApp(_ urls: [URL]) { - let strings = urls.map { $0.absoluteString } - let existing = - UserDefaults.sharedDefaults.stringArray(forKey: Constants.UserDefaults.pendingShareDownloadURLs) - ?? [] - UserDefaults.sharedDefaults.set( - existing + strings, - forKey: Constants.UserDefaults.pendingShareDownloadURLs + /// Schedules a background `URLSession` download for each shared web URL. + /// + /// Two delivery paths cover the lifecycle of the transfer: + /// + /// 1. If the extension's process is still alive when the download completes (typical for + /// small files that finish in seconds), iOS routes the completion to *this session's* + /// `URLSessionDownloadDelegate` — `BackgroundDownloadCoordinator` below — which moves + /// the temp file into the app group's shared folder. + /// 2. If iOS suspends/terminates the extension before the download completes, the + /// transfer continues via `nsurlsessiond` and iOS routes completion to the main app + /// via `application(_:handleEventsForBackgroundURLSession:completionHandler:)`. Main + /// app recreates the same session identifier and `BackgroundShareDownloadDelegate` + /// handles the move there. + /// + /// We previously created the session with `delegate: nil` assuming iOS would always + /// route to the main app, but in practice downloads small enough to finish before the + /// extension dies got their events delivered to a nil delegate and the temp file was + /// silently discarded. The first path above plugs that gap. + private func kickOffBackgroundDownloads(for urls: [URL]) { + let config = URLSessionConfiguration.background( + withIdentifier: Constants.shareExtensionBackgroundSessionIdentifier ) + config.sharedContainerIdentifier = Constants.ApplicationGroupIdentifier + config.sessionSendsLaunchEvents = true + config.isDiscretionary = false + + /// Retain the coordinator on the running view controller so its delegate methods can + /// fire if iOS keeps this process alive past the share-sheet dismissal — typical for + /// downloads that complete in a few seconds. + let coordinator = BackgroundDownloadCoordinator() + self.backgroundDownloadCoordinator = coordinator + let session = URLSession(configuration: config, delegate: coordinator, delegateQueue: nil) + for url in urls { + session.downloadTask(with: url).resume() + } + session.finishTasksAndInvalidate() } private func completeRequestOnMain() { @@ -303,3 +336,40 @@ extension ShareViewController: UITableViewDelegate {} enum ShareExtensionError: Error { case cancelled } + +/// `URLSessionDownloadDelegate` for the share extension's background download. +/// +/// Used when the extension's process is still alive when iOS finishes the download — +/// typically the case for small files that complete in a few seconds. Without a delegate +/// here, iOS would silently discard the temp file because the alive-extension's session +/// is the active one (iOS only escalates to the main app's matching-identifier session +/// when the extension's process is gone). +/// +/// Moves the temp file into the app group's shared folder, where the main app's +/// `ImportManager` picks it up on next foreground. +final class BackgroundDownloadCoordinator: NSObject, URLSessionDownloadDelegate { + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL + ) { + /// Reject non-2xx HTTP responses — `URLSession` reports success on a 404 and the temp + /// file just contains the error body. + if let httpResponse = downloadTask.response as? HTTPURLResponse, + !(200..<300).contains(httpResponse.statusCode) + { + return + } + + let originalURL = downloadTask.originalRequest?.url + let filename = + downloadTask.response?.suggestedFilename + ?? originalURL?.lastPathComponent + ?? "shared-\(UUID().uuidString)" + let destinationURL = DataManager.getSharedFilesFolderURL().appendingPathComponent(filename) + + /// Replace any same-named stale download from a previous attempt. + try? FileManager.default.removeItem(at: destinationURL) + try? FileManager.default.moveItem(at: location, to: destinationURL) + } +} diff --git a/Shared/Constants.swift b/Shared/Constants.swift index 7b8ef47de..89a39710f 100644 --- a/Shared/Constants.swift +++ b/Shared/Constants.swift @@ -53,11 +53,6 @@ public enum Constants { /// Key to store an array of identifiers that need their progress recalculated public static let staleProgressIdentifiers = "staleProgressIdentifiers" - /// Web URLs the share extension queued for the main app to download with - /// `SingleFileDownloadService` on next foreground. Lives in the app group's shared - /// `UserDefaults` so both processes see the same value. - public static let pendingShareDownloadURLs = "pendingShareDownloadURLs" - // One-time migrations public static let fileProtectionMigration = "userFileProtectionMigration" @@ -136,6 +131,14 @@ public enum Constants { public static let UserActivityPlayback = Bundle.main.bundleIdentifier! + ".activity.playback" public static let ApplicationGroupIdentifier = "group.\(Bundle.main.configurationString(for: .bundleIdentifier)).files" + /// `URLSessionConfiguration.background` identifier used by the share extension to download + /// shared web URLs. The main app re-creates a session with the same identifier in + /// `application(_:handleEventsForBackgroundURLSession:completionHandler:)` so the + /// `BackgroundShareDownloadDelegate` receives completion and can move the resulting file + /// into the app group's shared folder. + public static let shareExtensionBackgroundSessionIdentifier = + "\(Bundle.main.configurationString(for: .bundleIdentifier)).shareext.background" + public enum Widgets: String { case sharedNowPlayingWidget = "com.bookplayer.shared.widget" case sharedIconWidget = "com.bookplayer.shared.icon.widget" From 957d1f6e3350ab5fba57ffeadf3658391ce59bcd Mon Sep 17 00:00:00 2001 From: Matthew Alvernaz Date: Sun, 17 May 2026 07:50:17 -0700 Subject: [PATCH 7/9] Surface share-import failures (file-backed queue + accessible alert) The share extension and the main app's BackgroundShareDownloadDelegate both used try? / log-only when their copy/move operations failed, and the HTTP non-2xx branch returned silently. A blind user (or any user) sharing into BookPlayer had no way to know a download silently dropped because the disk was full, the server returned an error page, or the destination filename collided. Add ShareImportFailureStore: a small JSON file in the App Group container that both processes can append to. UserDefaults synchronization between extension and host is unreliable, so a deliberate atomic-write file is more robust. Both delegates and the synchronous extension copy now record failures with a specific, actionable message (filename + underlying error, or HTTP status code). LibraryRootView drains the store on every scenePhase=.active transition, shows an alert listing the failures, and posts a UIAccessibility.announcement so VoiceOver users hear the result even if focus is elsewhere. New localization keys (English-only across all 26 locales, matching the rest of the fork's localization pattern): - share_import_failure_alert_title - share_import_failure_copy_failed - share_import_failure_move_failed - share_import_failure_http_status - share_import_failure_announcement_multiple --- BookPlayer.xcodeproj/project.pbxproj | 4 + BookPlayer/AppDelegate.swift | 29 +++++- BookPlayer/Base.lproj/Localizable.strings | 5 ++ .../Library/ItemList/LibraryRootView.swift | 41 +++++++++ BookPlayer/ar.lproj/Localizable.strings | 5 ++ BookPlayer/ca.lproj/Localizable.strings | 5 ++ BookPlayer/cs.lproj/Localizable.strings | 5 ++ BookPlayer/da.lproj/Localizable.strings | 5 ++ BookPlayer/de.lproj/Localizable.strings | 5 ++ BookPlayer/el.lproj/Localizable.strings | 5 ++ BookPlayer/en.lproj/Localizable.strings | 5 ++ BookPlayer/es.lproj/Localizable.strings | 5 ++ BookPlayer/fi.lproj/Localizable.strings | 5 ++ BookPlayer/fr.lproj/Localizable.strings | 5 ++ BookPlayer/hu.lproj/Localizable.strings | 5 ++ BookPlayer/it.lproj/Localizable.strings | 5 ++ BookPlayer/ja.lproj/Localizable.strings | 5 ++ BookPlayer/nb.lproj/Localizable.strings | 5 ++ BookPlayer/nl.lproj/Localizable.strings | 5 ++ BookPlayer/pl.lproj/Localizable.strings | 5 ++ BookPlayer/pt-BR.lproj/Localizable.strings | 5 ++ BookPlayer/pt-PT.lproj/Localizable.strings | 5 ++ BookPlayer/ro.lproj/Localizable.strings | 5 ++ BookPlayer/ru.lproj/Localizable.strings | 5 ++ BookPlayer/sk-SK.lproj/Localizable.strings | 5 ++ BookPlayer/sv.lproj/Localizable.strings | 5 ++ BookPlayer/tr.lproj/Localizable.strings | 5 ++ BookPlayer/uk.lproj/Localizable.strings | 5 ++ BookPlayer/zh-Hans.lproj/Localizable.strings | 5 ++ .../ShareViewController.swift | 48 +++++++++- Shared/Services/ShareImportFailureStore.swift | 88 +++++++++++++++++++ 31 files changed, 333 insertions(+), 7 deletions(-) create mode 100644 Shared/Services/ShareImportFailureStore.swift diff --git a/BookPlayer.xcodeproj/project.pbxproj b/BookPlayer.xcodeproj/project.pbxproj index 92b98ca90..cdfc8403e 100644 --- a/BookPlayer.xcodeproj/project.pbxproj +++ b/BookPlayer.xcodeproj/project.pbxproj @@ -271,6 +271,7 @@ 41F1A204254B09C00043FCF3 /* Themeable in Frameworks */ = {isa = PBXBuildFile; productRef = 41F1A203254B09C00043FCF3 /* Themeable */; }; 41F1A20D254B0A0C0043FCF3 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 41F1A20C254B0A0C0043FCF3 /* Sentry */; }; 41F1A228254B0C6C0043FCF3 /* ZipArchive in Frameworks */ = {isa = PBXBuildFile; productRef = 41F1A227254B0C6C0043FCF3 /* ZipArchive */; }; + 6A91D2C3E04F1B8A7E62C803 /* ShareImportFailureStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A91D2C3E04F1B8A7E62C804 /* ShareImportFailureStore.swift */; }; 4645F9FD2D1E46AC00A04257 /* SwipeInlineTip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4645F9FC2D1E46AC00A04257 /* SwipeInlineTip.swift */; }; 465D87522D3195D600A4AA47 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465D87512D3195D600A4AA47 /* BookmarksView.swift */; }; 465D87542D31965100A4AA47 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465D87532D31965100A4AA47 /* BookmarksViewModel.swift */; }; @@ -1771,6 +1772,7 @@ D367F7671FA2A6F000FEDB37 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; D6BA8F152A4CA94800C2BD9A /* StorageRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageRowView.swift; sourceTree = ""; }; D6BA8F172A4D66CD00C2BD9A /* StorageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageView.swift; sourceTree = ""; }; + 6A91D2C3E04F1B8A7E62C804 /* ShareImportFailureStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareImportFailureStore.swift; sourceTree = ""; }; E1FDA0B60D7BE4CFE100A612 /* IntegrationDisconnectedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationDisconnectedView.swift; sourceTree = ""; }; E4D25EF2A5FFF30CF12F2C21 /* PreferencesSyncService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PreferencesSyncService.swift; sourceTree = ""; }; E55F466D61AE62A69DE4D371 /* BPNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BPNavigation.swift; sourceTree = ""; }; @@ -2562,6 +2564,7 @@ 9FDDD2E0289BFCE20020C428 /* LibraryService+Sync.swift */, 41EB071A2752FA6B00EFEE13 /* PlaybackService.swift */, 63344C012EA7097D00B90DF7 /* DatabaseBackupService.swift */, + 6A91D2C3E04F1B8A7E62C804 /* ShareImportFailureStore.swift */, 63125D102C36D96800D35533 /* Events */, 9FC1E4612814F68F00522FA8 /* Account */, 9FD8FE4C286566FF00EB2C3D /* Sync */, @@ -4905,6 +4908,7 @@ FF95F8C692908D744A32D761 /* PreferencesAPI.swift in Sources */, A13B98C548AA6BFD0E6FC0C7 /* PreferencesSyncService.swift in Sources */, 52AA441379FD94339FEECB55 /* SortPreferencesResolving.swift in Sources */, + 6A91D2C3E04F1B8A7E62C803 /* ShareImportFailureStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/BookPlayer/AppDelegate.swift b/BookPlayer/AppDelegate.swift index 540728076..021f45098 100644 --- a/BookPlayer/AppDelegate.swift +++ b/BookPlayer/AppDelegate.swift @@ -532,18 +532,31 @@ final class BackgroundShareDownloadDelegate: NSObject, URLSessionDownloadDelegat downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL ) { + let originalURL = downloadTask.originalRequest?.url + let source = originalURL?.absoluteString ?? "shared file" + /// Reject non-2xx HTTP responses — `URLSession` reports success on a 404 and the temp - /// file just contains the error body. Don't deposit garbage into the library. + /// file just contains the error body. Don't deposit garbage into the library, and + /// surface the failure to the user (via the share-import failure store) so they know + /// the share didn't actually succeed. if let httpResponse = downloadTask.response as? HTTPURLResponse, !(200..<300).contains(httpResponse.statusCode) { Self.logger.error( - "share download \(downloadTask.originalRequest?.url?.absoluteString ?? "?") returned status \(httpResponse.statusCode)" + "share download \(source) returned status \(httpResponse.statusCode)" + ) + ShareImportFailureStore.append( + ShareImportFailure( + source: source, + message: String( + format: "share_import_failure_http_status".localized, + httpResponse.statusCode + ) + ) ) return } - let originalURL = downloadTask.originalRequest?.url let filename = downloadTask.response?.suggestedFilename ?? originalURL?.lastPathComponent @@ -557,6 +570,16 @@ final class BackgroundShareDownloadDelegate: NSObject, URLSessionDownloadDelegat Self.logger.info("share download landed at \(destinationURL.lastPathComponent)") } catch { Self.logger.error("share download move failed: \(error.localizedDescription)") + ShareImportFailureStore.append( + ShareImportFailure( + source: filename, + message: String( + format: "share_import_failure_move_failed".localized, + filename, + error.localizedDescription + ) + ) + ) } } diff --git a/BookPlayer/Base.lproj/Localizable.strings b/BookPlayer/Base.lproj/Localizable.strings index b0c4a7b5d..10d401d71 100644 --- a/BookPlayer/Base.lproj/Localizable.strings +++ b/BookPlayer/Base.lproj/Localizable.strings @@ -342,6 +342,11 @@ We're working hard on providing a seamless experience, if possible, please conta "integration_connection_details_title" = "Connection Details"; "integration_connect_button" = "Connect"; "integration_sign_in_button" = "Sign In"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "Server URL"; "integration_section_server_url_footer" = "Connect to your %@ server"; "integration_section_server" = "Server"; diff --git a/BookPlayer/Library/ItemList/LibraryRootView.swift b/BookPlayer/Library/ItemList/LibraryRootView.swift index 9f7179b20..d7b117209 100644 --- a/BookPlayer/Library/ItemList/LibraryRootView.swift +++ b/BookPlayer/Library/ItemList/LibraryRootView.swift @@ -22,6 +22,11 @@ struct LibraryRootView: View { @State private var importOperationState = ImportOperationState() @State private var loadingState = LoadingOverlayState() + /// Failures recovered from `ShareImportFailureStore` on foreground — drained once per scene + /// activation, surfaced in an alert plus a VoiceOver announcement. Cleared when the user + /// dismisses the alert. + @State private var pendingShareImportFailures: [ShareImportFailure] = [] + @StateObject private var documentFolderWatcher = DirectoryWatcher.watch( DataManager.getDocumentsFolderURL(), ignoreDirectories: false @@ -93,7 +98,21 @@ struct LibraryRootView: View { .onChange(of: scenePhase) { guard scenePhase == .active else { return } showImport() + drainShareImportFailures() } + .alert( + "share_import_failure_alert_title".localized, + isPresented: Binding( + get: { !pendingShareImportFailures.isEmpty }, + set: { if !$0 { pendingShareImportFailures = [] } } + ), + actions: { + Button("ok_button".localized) { pendingShareImportFailures = [] } + }, + message: { + Text(pendingShareImportFailures.map { "• \($0.message)" }.joined(separator: "\n")) + } + ) .onReceive(syncService.downloadErrorPublisher) { (relativePath, error) in let errorMessage = "\(relativePath)\n\(error.localizedDescription)" loadingState.error = BookPlayerError.networkError(errorMessage) @@ -144,6 +163,28 @@ struct LibraryRootView: View { } } + /// Drain share-import failures that piled up while the app wasn't foregrounded — these come + /// from the share extension's synchronous copy errors and the main app's background-download + /// delegate move/HTTP errors. We surface them via the alert (visible) and a VoiceOver + /// announcement so a blind user knows the share didn't actually succeed. + func drainShareImportFailures() { + let failures = ShareImportFailureStore.drain() + guard !failures.isEmpty else { return } + pendingShareImportFailures = failures + + let announcement: String = { + if failures.count == 1 { + return failures[0].message + } + let summary = String( + format: "share_import_failure_announcement_multiple".localized, + failures.count + ) + return summary + " " + failures.map(\.message).joined(separator: ". ") + }() + UIAccessibility.post(notification: .announcement, argument: announcement) + } + func loadLastBookIfNeeded() async { guard playerManager.currentItem == nil, diff --git a/BookPlayer/ar.lproj/Localizable.strings b/BookPlayer/ar.lproj/Localizable.strings index b3e5d0f18..b92a4eb10 100644 --- a/BookPlayer/ar.lproj/Localizable.strings +++ b/BookPlayer/ar.lproj/Localizable.strings @@ -341,6 +341,11 @@ "integration_connection_details_title" = "تفاصيل الاتصال"; "integration_connect_button" = "الاتصال"; "integration_sign_in_button" = "تسجيل الدخول"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "عنوان URL للخادم"; "integration_section_server_url_footer" = "الاتصال بخادم @% الخاص بك"; "integration_section_server" = "الخادم"; diff --git a/BookPlayer/ca.lproj/Localizable.strings b/BookPlayer/ca.lproj/Localizable.strings index f94fa333f..181ac293c 100644 --- a/BookPlayer/ca.lproj/Localizable.strings +++ b/BookPlayer/ca.lproj/Localizable.strings @@ -341,6 +341,11 @@ Estem treballant dur per oferir una experiència perfecta; si és possible, pose "integration_connection_details_title" = "Detalls de connexió"; "integration_connect_button" = "Connecta't"; "integration_sign_in_button" = "Inicieu la sessió"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "URL del servidor"; "integration_section_server_url_footer" = "Connecteu-vos al vostre servidor %@"; "integration_section_server" = "Servidor"; diff --git a/BookPlayer/cs.lproj/Localizable.strings b/BookPlayer/cs.lproj/Localizable.strings index 77b1588d9..bd40012da 100644 --- a/BookPlayer/cs.lproj/Localizable.strings +++ b/BookPlayer/cs.lproj/Localizable.strings @@ -340,6 +340,11 @@ "integration_connection_details_title" = "Podrobnosti o připojení"; "integration_connect_button" = "Připojit"; "integration_sign_in_button" = "Přihlaste se"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "Adresa URL serveru"; "integration_section_server_url_footer" = "Připojte se k serveru %@"; "integration_section_server" = "Server"; diff --git a/BookPlayer/da.lproj/Localizable.strings b/BookPlayer/da.lproj/Localizable.strings index b98fa46c8..ab1b37383 100644 --- a/BookPlayer/da.lproj/Localizable.strings +++ b/BookPlayer/da.lproj/Localizable.strings @@ -340,6 +340,11 @@ "integration_connection_details_title" = "Tilslutningsdetaljer"; "integration_connect_button" = "Forbinde"; "integration_sign_in_button" = "Log ind"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "Server URL"; "integration_section_server_url_footer" = "Opret forbindelse til din %@-server"; "integration_section_server" = "Server"; diff --git a/BookPlayer/de.lproj/Localizable.strings b/BookPlayer/de.lproj/Localizable.strings index d205155a6..b65029e97 100644 --- a/BookPlayer/de.lproj/Localizable.strings +++ b/BookPlayer/de.lproj/Localizable.strings @@ -340,6 +340,11 @@ "integration_connection_details_title" = "Verbindungsdetails"; "integration_connect_button" = "Verbinden"; "integration_sign_in_button" = "Anmelden"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "Server-URL"; "integration_section_server_url_footer" = "Stelle eine Verbindung zu deinem %@-Server her"; "integration_section_server" = "Server"; diff --git a/BookPlayer/el.lproj/Localizable.strings b/BookPlayer/el.lproj/Localizable.strings index 34f5f3692..aab34565c 100644 --- a/BookPlayer/el.lproj/Localizable.strings +++ b/BookPlayer/el.lproj/Localizable.strings @@ -341,6 +341,11 @@ "integration_connection_details_title" = "Στοιχεία σύνδεσης"; "integration_connect_button" = "Συνδέω"; "integration_sign_in_button" = "Είσοδος"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "URL διακομιστή"; "integration_section_server_url_footer" = "Συνδεθείτε στον διακομιστή %@"; "integration_section_server" = "Υπηρέτης"; diff --git a/BookPlayer/en.lproj/Localizable.strings b/BookPlayer/en.lproj/Localizable.strings index 711b6edce..150d8866f 100644 --- a/BookPlayer/en.lproj/Localizable.strings +++ b/BookPlayer/en.lproj/Localizable.strings @@ -343,6 +343,11 @@ We're working hard on providing a seamless experience, if possible, please conta "integration_connection_details_title" = "Connection Details"; "integration_connect_button" = "Connect"; "integration_sign_in_button" = "Sign In"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "Server URL"; "integration_section_server_url_footer" = "Connect to your %@ server"; "integration_section_server" = "Server"; diff --git a/BookPlayer/es.lproj/Localizable.strings b/BookPlayer/es.lproj/Localizable.strings index 35116f00a..552bbfb38 100644 --- a/BookPlayer/es.lproj/Localizable.strings +++ b/BookPlayer/es.lproj/Localizable.strings @@ -340,6 +340,11 @@ "integration_connection_details_title" = "Detalles de la conexión"; "integration_connect_button" = "Conectar"; "integration_sign_in_button" = "Iniciar sesión"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "URL del servidor"; "integration_section_server_url_footer" = "Conéctese a su servidor %@"; "integration_section_server" = "Servidor"; diff --git a/BookPlayer/fi.lproj/Localizable.strings b/BookPlayer/fi.lproj/Localizable.strings index e0385eeaa..8270779da 100644 --- a/BookPlayer/fi.lproj/Localizable.strings +++ b/BookPlayer/fi.lproj/Localizable.strings @@ -340,6 +340,11 @@ "integration_connection_details_title" = "Yhteyden tiedot"; "integration_connect_button" = "Yhdistä"; "integration_sign_in_button" = "Kirjaudu sisään"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "Palvelimen URL-osoite"; "integration_section_server_url_footer" = "Yhdistä %@-palvelimeesi"; "integration_section_server" = "Palvelin"; diff --git a/BookPlayer/fr.lproj/Localizable.strings b/BookPlayer/fr.lproj/Localizable.strings index 6b127f129..15daea4e2 100644 --- a/BookPlayer/fr.lproj/Localizable.strings +++ b/BookPlayer/fr.lproj/Localizable.strings @@ -340,6 +340,11 @@ "integration_connection_details_title" = "Détails de connexion"; "integration_connect_button" = "Connecter"; "integration_sign_in_button" = "Se connecter"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "URL du serveur"; "integration_section_server_url_footer" = "Connectez-vous à votre serveur %@"; "integration_section_server" = "Serveur"; diff --git a/BookPlayer/hu.lproj/Localizable.strings b/BookPlayer/hu.lproj/Localizable.strings index 22061c098..12a5feb7e 100644 --- a/BookPlayer/hu.lproj/Localizable.strings +++ b/BookPlayer/hu.lproj/Localizable.strings @@ -341,6 +341,11 @@ "integration_connection_details_title" = "Csatlakozás részletei"; "integration_connect_button" = "Csatlakozás"; "integration_sign_in_button" = "Bejelentkezés"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "Szerver URL"; "integration_section_server_url_footer" = "Csatlakozás %@ szerverhez"; "integration_section_server" = "Szerver"; diff --git a/BookPlayer/it.lproj/Localizable.strings b/BookPlayer/it.lproj/Localizable.strings index 10c6d8351..0964ea691 100644 --- a/BookPlayer/it.lproj/Localizable.strings +++ b/BookPlayer/it.lproj/Localizable.strings @@ -340,6 +340,11 @@ "integration_connection_details_title" = "Dettagli di connessione"; "integration_connect_button" = "Collegare"; "integration_sign_in_button" = "Registrazione"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "URL del server"; "integration_section_server_url_footer" = "Connettiti al tuo server %@"; "integration_section_server" = "Server"; diff --git a/BookPlayer/ja.lproj/Localizable.strings b/BookPlayer/ja.lproj/Localizable.strings index a51abf1eb..7c7521ed7 100644 --- a/BookPlayer/ja.lproj/Localizable.strings +++ b/BookPlayer/ja.lproj/Localizable.strings @@ -341,6 +341,11 @@ "integration_connection_details_title" = "接続の詳細"; "integration_connect_button" = "接続"; "integration_sign_in_button" = "サインイン"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "サーバURL"; "integration_section_server_url_footer" = "%@サーバに接続する"; "integration_section_server" = "サーバ"; diff --git a/BookPlayer/nb.lproj/Localizable.strings b/BookPlayer/nb.lproj/Localizable.strings index def19e01c..30b10c8e5 100644 --- a/BookPlayer/nb.lproj/Localizable.strings +++ b/BookPlayer/nb.lproj/Localizable.strings @@ -340,6 +340,11 @@ Vi jobber hardt for å gi deg en sømløs opplevelse. Hvis mulig, kontakt oss p "integration_connection_details_title" = "Tilkoblingsdetaljer"; "integration_connect_button" = "Koble til"; "integration_sign_in_button" = "Logg på"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "Server URL"; "integration_section_server_url_footer" = "Koble til %@-serveren din"; "integration_section_server" = "Server"; diff --git a/BookPlayer/nl.lproj/Localizable.strings b/BookPlayer/nl.lproj/Localizable.strings index c81154072..e87da8baa 100644 --- a/BookPlayer/nl.lproj/Localizable.strings +++ b/BookPlayer/nl.lproj/Localizable.strings @@ -340,6 +340,11 @@ "integration_connection_details_title" = "Verbindingsgegevens"; "integration_connect_button" = "Verbinden"; "integration_sign_in_button" = "Aanmelden"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "Server-URL"; "integration_section_server_url_footer" = "Maak verbinding met uw %@-server"; "integration_section_server" = "Server"; diff --git a/BookPlayer/pl.lproj/Localizable.strings b/BookPlayer/pl.lproj/Localizable.strings index a0e772293..62af61be3 100644 --- a/BookPlayer/pl.lproj/Localizable.strings +++ b/BookPlayer/pl.lproj/Localizable.strings @@ -340,6 +340,11 @@ "integration_connection_details_title" = "Szczegóły połączenia"; "integration_connect_button" = "Łączyć"; "integration_sign_in_button" = "Zalogować się"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "Adres URL serwera"; "integration_section_server_url_footer" = "Połącz się ze swoim serwerem %@"; "integration_section_server" = "Serwer"; diff --git a/BookPlayer/pt-BR.lproj/Localizable.strings b/BookPlayer/pt-BR.lproj/Localizable.strings index 0b973add5..c1d418ecf 100644 --- a/BookPlayer/pt-BR.lproj/Localizable.strings +++ b/BookPlayer/pt-BR.lproj/Localizable.strings @@ -340,6 +340,11 @@ "integration_connection_details_title" = "Detalhes da conexão"; "integration_connect_button" = "Conectar"; "integration_sign_in_button" = "Entrar"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "URL do servidor"; "integration_section_server_url_footer" = "Conecte-se ao seu servidor %@"; "integration_section_server" = "Servidor"; diff --git a/BookPlayer/pt-PT.lproj/Localizable.strings b/BookPlayer/pt-PT.lproj/Localizable.strings index 2a210d32b..ea98f5c66 100644 --- a/BookPlayer/pt-PT.lproj/Localizable.strings +++ b/BookPlayer/pt-PT.lproj/Localizable.strings @@ -340,6 +340,11 @@ "integration_connection_details_title" = "Detalhes da conexão"; "integration_connect_button" = "Conectar"; "integration_sign_in_button" = "Entrar"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "URL do servidor"; "integration_section_server_url_footer" = "Conecte-se ao seu servidor %@"; "integration_section_server" = "Servidor"; diff --git a/BookPlayer/ro.lproj/Localizable.strings b/BookPlayer/ro.lproj/Localizable.strings index e9a00e371..af2b7fc44 100644 --- a/BookPlayer/ro.lproj/Localizable.strings +++ b/BookPlayer/ro.lproj/Localizable.strings @@ -340,6 +340,11 @@ "integration_connection_details_title" = "Detalii de conectare"; "integration_connect_button" = "Conectați-vă"; "integration_sign_in_button" = "Conectare"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "Adresa URL a serverului"; "integration_section_server_url_footer" = "Conectați-vă la serverul dvs. %@"; "integration_section_server" = "Server"; diff --git a/BookPlayer/ru.lproj/Localizable.strings b/BookPlayer/ru.lproj/Localizable.strings index f5a5d3b92..b65e82563 100644 --- a/BookPlayer/ru.lproj/Localizable.strings +++ b/BookPlayer/ru.lproj/Localizable.strings @@ -340,6 +340,11 @@ "integration_connection_details_title" = "Подробности подключения"; "integration_connect_button" = "Подключиться"; "integration_sign_in_button" = "Войти"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "URL-адрес сервера"; "integration_section_server_url_footer" = "Подключитесь к вашему серверу %@"; "integration_section_server" = "Сервер"; diff --git a/BookPlayer/sk-SK.lproj/Localizable.strings b/BookPlayer/sk-SK.lproj/Localizable.strings index b54262626..e69f175aa 100644 --- a/BookPlayer/sk-SK.lproj/Localizable.strings +++ b/BookPlayer/sk-SK.lproj/Localizable.strings @@ -341,6 +341,11 @@ Usilovne pracujeme na poskytovaní bezproblémového zážitku, ak je to možné "integration_connection_details_title" = "Podrobnosti pripojenia"; "integration_connect_button" = "Pripojiť sa"; "integration_sign_in_button" = "Prihlásiť sa"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "URL servera"; "integration_section_server_url_footer" = "Pripojenie sa k serveru %@"; "integration_section_server" = "Server"; diff --git a/BookPlayer/sv.lproj/Localizable.strings b/BookPlayer/sv.lproj/Localizable.strings index e65414965..ebcdcc384 100644 --- a/BookPlayer/sv.lproj/Localizable.strings +++ b/BookPlayer/sv.lproj/Localizable.strings @@ -340,6 +340,11 @@ "integration_connection_details_title" = "Anslutningsdetaljer"; "integration_connect_button" = "Ansluta"; "integration_sign_in_button" = "Logga in"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "Server URL"; "integration_section_server_url_footer" = "Anslut till din %@-server"; "integration_section_server" = "Server"; diff --git a/BookPlayer/tr.lproj/Localizable.strings b/BookPlayer/tr.lproj/Localizable.strings index a225da55c..4bfa9e2e1 100644 --- a/BookPlayer/tr.lproj/Localizable.strings +++ b/BookPlayer/tr.lproj/Localizable.strings @@ -340,6 +340,11 @@ "integration_connection_details_title" = "Bağlantı Ayrıntıları"; "integration_connect_button" = "Bağlamak"; "integration_sign_in_button" = "Kayıt olmak"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "Sunucu URL'si"; "integration_section_server_url_footer" = "%@ sunucunuza bağlanın"; "integration_section_server" = "Sunucu"; diff --git a/BookPlayer/uk.lproj/Localizable.strings b/BookPlayer/uk.lproj/Localizable.strings index 2f5f6dac1..0328ae9dd 100644 --- a/BookPlayer/uk.lproj/Localizable.strings +++ b/BookPlayer/uk.lproj/Localizable.strings @@ -340,6 +340,11 @@ "integration_connection_details_title" = "Деталі підключення"; "integration_connect_button" = "Підключитися"; "integration_sign_in_button" = "Увійти"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "URL-адреса сервера"; "integration_section_server_url_footer" = "Підключіться до свого сервера %@"; "integration_section_server" = "Сервер"; diff --git a/BookPlayer/zh-Hans.lproj/Localizable.strings b/BookPlayer/zh-Hans.lproj/Localizable.strings index b6bf910e5..3b055da7d 100644 --- a/BookPlayer/zh-Hans.lproj/Localizable.strings +++ b/BookPlayer/zh-Hans.lproj/Localizable.strings @@ -340,6 +340,11 @@ "integration_connection_details_title" = "连接详细信息"; "integration_connect_button" = "连接"; "integration_sign_in_button" = "登入"; +"share_import_failure_alert_title" = "Some shared files didn’t import"; +"share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; +"share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; +"share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "服务器 URL"; "integration_section_server_url_footer" = "连接到您的 %@ 服务器"; "integration_section_server" = "服务器"; diff --git a/BookPlayerShareExtension/ShareViewController.swift b/BookPlayerShareExtension/ShareViewController.swift index 9a5bf729a..3b483d671 100644 --- a/BookPlayerShareExtension/ShareViewController.swift +++ b/BookPlayerShareExtension/ShareViewController.swift @@ -223,7 +223,23 @@ class ShareViewController: UIViewController { for item in fileItems { let destinationURL = sharedFolder.appendingPathComponent(item.lastPathComponent) - try? FileManager.default.copyItem(at: item, to: destinationURL) + do { + try FileManager.default.copyItem(at: item, to: destinationURL) + } catch { + // Don't swallow disk-full / permission / collision errors. The user is blind + // and the share sheet is already gone by the time the main app foregrounds, so + // we persist the failure into the App Group for the host to surface. + ShareImportFailureStore.append( + ShareImportFailure( + source: item.lastPathComponent, + message: String( + format: "share_import_failure_copy_failed".localized, + item.lastPathComponent, + error.localizedDescription + ) + ) + ) + } } if !webItems.isEmpty { @@ -353,15 +369,26 @@ final class BackgroundDownloadCoordinator: NSObject, URLSessionDownloadDelegate downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL ) { + let originalURL = downloadTask.originalRequest?.url + let source = originalURL?.absoluteString ?? "shared file" + /// Reject non-2xx HTTP responses — `URLSession` reports success on a 404 and the temp - /// file just contains the error body. + /// file just contains the error body. Record the failure so the host app can surface it. if let httpResponse = downloadTask.response as? HTTPURLResponse, !(200..<300).contains(httpResponse.statusCode) { + ShareImportFailureStore.append( + ShareImportFailure( + source: source, + message: String( + format: "share_import_failure_http_status".localized, + httpResponse.statusCode + ) + ) + ) return } - let originalURL = downloadTask.originalRequest?.url let filename = downloadTask.response?.suggestedFilename ?? originalURL?.lastPathComponent @@ -370,6 +397,19 @@ final class BackgroundDownloadCoordinator: NSObject, URLSessionDownloadDelegate /// Replace any same-named stale download from a previous attempt. try? FileManager.default.removeItem(at: destinationURL) - try? FileManager.default.moveItem(at: location, to: destinationURL) + do { + try FileManager.default.moveItem(at: location, to: destinationURL) + } catch { + ShareImportFailureStore.append( + ShareImportFailure( + source: filename, + message: String( + format: "share_import_failure_move_failed".localized, + filename, + error.localizedDescription + ) + ) + ) + } } } diff --git a/Shared/Services/ShareImportFailureStore.swift b/Shared/Services/ShareImportFailureStore.swift new file mode 100644 index 000000000..4cb3f2cef --- /dev/null +++ b/Shared/Services/ShareImportFailureStore.swift @@ -0,0 +1,88 @@ +// +// ShareImportFailureStore.swift +// BookPlayerKit +// +// Created by Matthew Alvernaz on 2026-05-17. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation + +/// A persisted record of an import that failed in the share-handoff path. We need to surface +/// these on the main app's next foreground because the share extension's UI is gone by the time +/// the bytes finish moving (the download itself is owned by `nsurlsessiond`), so failures that +/// happen after the share sheet dismisses would otherwise be invisible. +/// +/// `source` should identify what the user tried to import (filename, or the share URL) and +/// `message` should be specific and actionable — never a generic "download failed". A VoiceOver +/// user announcing these has no visual context to fall back on. +public struct ShareImportFailure: Codable, Identifiable, Equatable { + public let id: UUID + public let date: Date + public let source: String + public let message: String + + public init(id: UUID = UUID(), date: Date = Date(), source: String, message: String) { + self.id = id + self.date = date + self.source = source + self.message = message + } +} + +/// File-backed queue of share-import failures. Stored as a JSON file in the App Group container +/// rather than via App Group `UserDefaults` because cross-process `UserDefaults` synchronization +/// between an extension and its host app has historically been flaky (the OS doesn't fire KVO +/// notifications across processes, and barrier syncs are best-effort). Atomic file writes give +/// us a deterministic point-in-time hand-off. +/// +/// Concurrency: every operation reads the file fresh and writes atomically. The queue is small +/// (capped at `maxStored`) and the contention window is narrow — extension writes once on +/// failure, main app reads-and-clears once on foreground — so a simple atomic-write strategy +/// suffices. +public enum ShareImportFailureStore { + /// Hard cap on stored failures so a runaway loop in the extension can't fill the container. + private static let maxStored = 50 + + private static var storeURL: URL? { + FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: Constants.ApplicationGroupIdentifier)? + .appendingPathComponent("share-import-failures.json") + } + + /// Append a failure record. Safe to call from either the share extension or the main app. + /// Best-effort — failures here (e.g. App Group container temporarily unreachable) are + /// swallowed because surfacing a "couldn't record a failure" error would only confuse the user. + public static func append(_ failure: ShareImportFailure) { + guard let url = storeURL else { return } + var failures = load() + failures.append(failure) + failures = Array(failures.suffix(maxStored)) + + guard let data = try? JSONEncoder().encode(failures) else { return } + try? data.write(to: url, options: .atomic) + } + + /// Returns all queued failures and clears the store atomically. + /// + /// "Atomically" here means we re-read just before deleting, so a failure appended between + /// our load and the delete won't be lost — it'll be returned by this drain call. We use + /// `replaceItemAt` semantics via `write([], atomic:)` rather than `removeItem` because the + /// file simply existing-or-not is itself the primary state signal. + public static func drain() -> [ShareImportFailure] { + guard let url = storeURL else { return [] } + let failures = load() + if !failures.isEmpty { + try? FileManager.default.removeItem(at: url) + } + return failures + } + + private static func load() -> [ShareImportFailure] { + guard let url = storeURL, + let data = try? Data(contentsOf: url), + let failures = try? JSONDecoder().decode([ShareImportFailure].self, from: data) + else { return [] } + return failures + } +} From 1eacdbc852dff983e3a148e0332bd8d92a3929d4 Mon Sep 17 00:00:00 2001 From: Matthew Alvernaz Date: Sun, 17 May 2026 07:54:33 -0700 Subject: [PATCH 8/9] Share downloads: unique filenames + layered MIME validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related share-extension hardenings landing together because they touch the same two download delegates (the in-process coordinator in the share extension and BackgroundShareDownloadDelegate in the main app): H14 — Concurrent shares of the same URL used to collide because the delegates wrote to `sharedFolder/{filename}` after a try? removeItem. Second-completing share would clobber the first. Switch to a UUID-prefixed destination so each share lands at its own filename. H16 — A non-2xx HTTP response was the only rejection — an HTML interstitial or error JSON served with 200 still got moved into the library, where playback later produced cryptic codec errors. Add a layered MIME check via the new ShareDownloadSupport helper: - Hard-reject text/html, text/plain, application/json, application/xml, application/problem+json — these are never an audiobook file. - Accept audio/*, video/*, application/zip, application/x-mpegurl. - Ambiguous types (application/octet-stream, application/download, missing MIME) only pass if the filename has a recognized media extension. Rejections feed the share-import failure store with a specific, actionable message ("returned content type X, which isn't an audiobook file; try the direct download link") rather than the generic "download failed." Also harden filename derivation: strip path components, leading dots, "..", control chars, cap length, and fall back to a UUID name if the result is empty. Matters more for the path-traversal defense (per the audit's C6) than for collisions. New localization keys (English literals across all 26 .lproj files): - share_import_failure_unsupported_type - share_import_failure_unsupported_extension --- BookPlayer.xcodeproj/project.pbxproj | 4 + BookPlayer/AppDelegate.swift | 19 ++- BookPlayer/Base.lproj/Localizable.strings | 2 + BookPlayer/ar.lproj/Localizable.strings | 2 + BookPlayer/ca.lproj/Localizable.strings | 2 + BookPlayer/cs.lproj/Localizable.strings | 2 + BookPlayer/da.lproj/Localizable.strings | 2 + BookPlayer/de.lproj/Localizable.strings | 2 + BookPlayer/el.lproj/Localizable.strings | 2 + BookPlayer/en.lproj/Localizable.strings | 2 + BookPlayer/es.lproj/Localizable.strings | 2 + BookPlayer/fi.lproj/Localizable.strings | 2 + BookPlayer/fr.lproj/Localizable.strings | 2 + BookPlayer/hu.lproj/Localizable.strings | 2 + BookPlayer/it.lproj/Localizable.strings | 2 + BookPlayer/ja.lproj/Localizable.strings | 2 + BookPlayer/nb.lproj/Localizable.strings | 2 + BookPlayer/nl.lproj/Localizable.strings | 2 + BookPlayer/pl.lproj/Localizable.strings | 2 + BookPlayer/pt-BR.lproj/Localizable.strings | 2 + BookPlayer/pt-PT.lproj/Localizable.strings | 2 + BookPlayer/ro.lproj/Localizable.strings | 2 + BookPlayer/ru.lproj/Localizable.strings | 2 + BookPlayer/sk-SK.lproj/Localizable.strings | 2 + BookPlayer/sv.lproj/Localizable.strings | 2 + BookPlayer/tr.lproj/Localizable.strings | 2 + BookPlayer/uk.lproj/Localizable.strings | 2 + BookPlayer/zh-Hans.lproj/Localizable.strings | 2 + .../ShareViewController.swift | 18 ++- Shared/Services/ShareDownloadSupport.swift | 109 ++++++++++++++++++ 30 files changed, 194 insertions(+), 8 deletions(-) create mode 100644 Shared/Services/ShareDownloadSupport.swift diff --git a/BookPlayer.xcodeproj/project.pbxproj b/BookPlayer.xcodeproj/project.pbxproj index cdfc8403e..e958b005b 100644 --- a/BookPlayer.xcodeproj/project.pbxproj +++ b/BookPlayer.xcodeproj/project.pbxproj @@ -272,6 +272,7 @@ 41F1A20D254B0A0C0043FCF3 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 41F1A20C254B0A0C0043FCF3 /* Sentry */; }; 41F1A228254B0C6C0043FCF3 /* ZipArchive in Frameworks */ = {isa = PBXBuildFile; productRef = 41F1A227254B0C6C0043FCF3 /* ZipArchive */; }; 6A91D2C3E04F1B8A7E62C803 /* ShareImportFailureStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A91D2C3E04F1B8A7E62C804 /* ShareImportFailureStore.swift */; }; + 7B91D2C3E04F1B8A7E62C805 /* ShareDownloadSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B91D2C3E04F1B8A7E62C806 /* ShareDownloadSupport.swift */; }; 4645F9FD2D1E46AC00A04257 /* SwipeInlineTip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4645F9FC2D1E46AC00A04257 /* SwipeInlineTip.swift */; }; 465D87522D3195D600A4AA47 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465D87512D3195D600A4AA47 /* BookmarksView.swift */; }; 465D87542D31965100A4AA47 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465D87532D31965100A4AA47 /* BookmarksViewModel.swift */; }; @@ -1773,6 +1774,7 @@ D6BA8F152A4CA94800C2BD9A /* StorageRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageRowView.swift; sourceTree = ""; }; D6BA8F172A4D66CD00C2BD9A /* StorageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageView.swift; sourceTree = ""; }; 6A91D2C3E04F1B8A7E62C804 /* ShareImportFailureStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareImportFailureStore.swift; sourceTree = ""; }; + 7B91D2C3E04F1B8A7E62C806 /* ShareDownloadSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareDownloadSupport.swift; sourceTree = ""; }; E1FDA0B60D7BE4CFE100A612 /* IntegrationDisconnectedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationDisconnectedView.swift; sourceTree = ""; }; E4D25EF2A5FFF30CF12F2C21 /* PreferencesSyncService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PreferencesSyncService.swift; sourceTree = ""; }; E55F466D61AE62A69DE4D371 /* BPNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BPNavigation.swift; sourceTree = ""; }; @@ -2565,6 +2567,7 @@ 41EB071A2752FA6B00EFEE13 /* PlaybackService.swift */, 63344C012EA7097D00B90DF7 /* DatabaseBackupService.swift */, 6A91D2C3E04F1B8A7E62C804 /* ShareImportFailureStore.swift */, + 7B91D2C3E04F1B8A7E62C806 /* ShareDownloadSupport.swift */, 63125D102C36D96800D35533 /* Events */, 9FC1E4612814F68F00522FA8 /* Account */, 9FD8FE4C286566FF00EB2C3D /* Sync */, @@ -4909,6 +4912,7 @@ A13B98C548AA6BFD0E6FC0C7 /* PreferencesSyncService.swift in Sources */, 52AA441379FD94339FEECB55 /* SortPreferencesResolving.swift in Sources */, 6A91D2C3E04F1B8A7E62C803 /* ShareImportFailureStore.swift in Sources */, + 7B91D2C3E04F1B8A7E62C805 /* ShareDownloadSupport.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/BookPlayer/AppDelegate.swift b/BookPlayer/AppDelegate.swift index 021f45098..ad1e2a06e 100644 --- a/BookPlayer/AppDelegate.swift +++ b/BookPlayer/AppDelegate.swift @@ -557,14 +557,25 @@ final class BackgroundShareDownloadDelegate: NSObject, URLSessionDownloadDelegat return } - let filename = + let suggested = downloadTask.response?.suggestedFilename ?? originalURL?.lastPathComponent ?? "shared-\(UUID().uuidString)" - let destinationURL = DataManager.getSharedFilesFolderURL().appendingPathComponent(filename) + let filename = ShareDownloadSupport.sanitizedFilename(suggested) + let mime = (downloadTask.response as? HTTPURLResponse)?.mimeType?.lowercased() - /// Replace any same-named stale download from a previous attempt. - try? FileManager.default.removeItem(at: destinationURL) + if let rejection = ShareDownloadSupport.rejectionReason(forMIME: mime, filename: filename) { + Self.logger.error("share download \(source) rejected: \(rejection)") + ShareImportFailureStore.append( + ShareImportFailure(source: source, message: rejection) + ) + try? FileManager.default.removeItem(at: location) + return + } + + // Avoid collisions from concurrent shares of the same URL by prefixing a short UUID. + let destinationURL = DataManager.getSharedFilesFolderURL() + .appendingPathComponent(ShareDownloadSupport.uniqueDestinationName(for: filename)) do { try FileManager.default.moveItem(at: location, to: destinationURL) Self.logger.info("share download landed at \(destinationURL.lastPathComponent)") diff --git a/BookPlayer/Base.lproj/Localizable.strings b/BookPlayer/Base.lproj/Localizable.strings index 10d401d71..b83ca04a6 100644 --- a/BookPlayer/Base.lproj/Localizable.strings +++ b/BookPlayer/Base.lproj/Localizable.strings @@ -346,6 +346,8 @@ We're working hard on providing a seamless experience, if possible, please conta "share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; "share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; "share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; "share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "Server URL"; "integration_section_server_url_footer" = "Connect to your %@ server"; diff --git a/BookPlayer/ar.lproj/Localizable.strings b/BookPlayer/ar.lproj/Localizable.strings index b92a4eb10..5f8f88225 100644 --- a/BookPlayer/ar.lproj/Localizable.strings +++ b/BookPlayer/ar.lproj/Localizable.strings @@ -345,6 +345,8 @@ "share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; "share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; "share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; "share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "عنوان URL للخادم"; "integration_section_server_url_footer" = "الاتصال بخادم @% الخاص بك"; diff --git a/BookPlayer/ca.lproj/Localizable.strings b/BookPlayer/ca.lproj/Localizable.strings index 181ac293c..0dda1120c 100644 --- a/BookPlayer/ca.lproj/Localizable.strings +++ b/BookPlayer/ca.lproj/Localizable.strings @@ -345,6 +345,8 @@ Estem treballant dur per oferir una experiència perfecta; si és possible, pose "share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; "share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; "share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; "share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "URL del servidor"; "integration_section_server_url_footer" = "Connecteu-vos al vostre servidor %@"; diff --git a/BookPlayer/cs.lproj/Localizable.strings b/BookPlayer/cs.lproj/Localizable.strings index bd40012da..c2a3d6501 100644 --- a/BookPlayer/cs.lproj/Localizable.strings +++ b/BookPlayer/cs.lproj/Localizable.strings @@ -344,6 +344,8 @@ "share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; "share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; "share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; "share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "Adresa URL serveru"; "integration_section_server_url_footer" = "Připojte se k serveru %@"; diff --git a/BookPlayer/da.lproj/Localizable.strings b/BookPlayer/da.lproj/Localizable.strings index ab1b37383..c30b4e879 100644 --- a/BookPlayer/da.lproj/Localizable.strings +++ b/BookPlayer/da.lproj/Localizable.strings @@ -344,6 +344,8 @@ "share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; "share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; "share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; "share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "Server URL"; "integration_section_server_url_footer" = "Opret forbindelse til din %@-server"; diff --git a/BookPlayer/de.lproj/Localizable.strings b/BookPlayer/de.lproj/Localizable.strings index b65029e97..c6cdca5f9 100644 --- a/BookPlayer/de.lproj/Localizable.strings +++ b/BookPlayer/de.lproj/Localizable.strings @@ -344,6 +344,8 @@ "share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; "share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; "share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; "share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "Server-URL"; "integration_section_server_url_footer" = "Stelle eine Verbindung zu deinem %@-Server her"; diff --git a/BookPlayer/el.lproj/Localizable.strings b/BookPlayer/el.lproj/Localizable.strings index aab34565c..da52dd72a 100644 --- a/BookPlayer/el.lproj/Localizable.strings +++ b/BookPlayer/el.lproj/Localizable.strings @@ -345,6 +345,8 @@ "share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; "share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; "share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; "share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "URL διακομιστή"; "integration_section_server_url_footer" = "Συνδεθείτε στον διακομιστή %@"; diff --git a/BookPlayer/en.lproj/Localizable.strings b/BookPlayer/en.lproj/Localizable.strings index 150d8866f..4ea2d72b2 100644 --- a/BookPlayer/en.lproj/Localizable.strings +++ b/BookPlayer/en.lproj/Localizable.strings @@ -347,6 +347,8 @@ We're working hard on providing a seamless experience, if possible, please conta "share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; "share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; "share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; "share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "Server URL"; "integration_section_server_url_footer" = "Connect to your %@ server"; diff --git a/BookPlayer/es.lproj/Localizable.strings b/BookPlayer/es.lproj/Localizable.strings index 552bbfb38..6f5e26dee 100644 --- a/BookPlayer/es.lproj/Localizable.strings +++ b/BookPlayer/es.lproj/Localizable.strings @@ -344,6 +344,8 @@ "share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; "share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; "share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; "share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "URL del servidor"; "integration_section_server_url_footer" = "Conéctese a su servidor %@"; diff --git a/BookPlayer/fi.lproj/Localizable.strings b/BookPlayer/fi.lproj/Localizable.strings index 8270779da..a8d8cff57 100644 --- a/BookPlayer/fi.lproj/Localizable.strings +++ b/BookPlayer/fi.lproj/Localizable.strings @@ -344,6 +344,8 @@ "share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; "share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; "share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; "share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "Palvelimen URL-osoite"; "integration_section_server_url_footer" = "Yhdistä %@-palvelimeesi"; diff --git a/BookPlayer/fr.lproj/Localizable.strings b/BookPlayer/fr.lproj/Localizable.strings index 15daea4e2..d41aa16bb 100644 --- a/BookPlayer/fr.lproj/Localizable.strings +++ b/BookPlayer/fr.lproj/Localizable.strings @@ -344,6 +344,8 @@ "share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; "share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; "share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; "share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "URL du serveur"; "integration_section_server_url_footer" = "Connectez-vous à votre serveur %@"; diff --git a/BookPlayer/hu.lproj/Localizable.strings b/BookPlayer/hu.lproj/Localizable.strings index 12a5feb7e..cb4842ec1 100644 --- a/BookPlayer/hu.lproj/Localizable.strings +++ b/BookPlayer/hu.lproj/Localizable.strings @@ -345,6 +345,8 @@ "share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; "share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; "share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; "share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "Szerver URL"; "integration_section_server_url_footer" = "Csatlakozás %@ szerverhez"; diff --git a/BookPlayer/it.lproj/Localizable.strings b/BookPlayer/it.lproj/Localizable.strings index 0964ea691..057ca08b5 100644 --- a/BookPlayer/it.lproj/Localizable.strings +++ b/BookPlayer/it.lproj/Localizable.strings @@ -344,6 +344,8 @@ "share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; "share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; "share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; "share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "URL del server"; "integration_section_server_url_footer" = "Connettiti al tuo server %@"; diff --git a/BookPlayer/ja.lproj/Localizable.strings b/BookPlayer/ja.lproj/Localizable.strings index 7c7521ed7..bf3726ba5 100644 --- a/BookPlayer/ja.lproj/Localizable.strings +++ b/BookPlayer/ja.lproj/Localizable.strings @@ -345,6 +345,8 @@ "share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; "share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; "share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; "share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "サーバURL"; "integration_section_server_url_footer" = "%@サーバに接続する"; diff --git a/BookPlayer/nb.lproj/Localizable.strings b/BookPlayer/nb.lproj/Localizable.strings index 30b10c8e5..92b4cbc77 100644 --- a/BookPlayer/nb.lproj/Localizable.strings +++ b/BookPlayer/nb.lproj/Localizable.strings @@ -344,6 +344,8 @@ Vi jobber hardt for å gi deg en sømløs opplevelse. Hvis mulig, kontakt oss p "share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; "share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; "share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; "share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "Server URL"; "integration_section_server_url_footer" = "Koble til %@-serveren din"; diff --git a/BookPlayer/nl.lproj/Localizable.strings b/BookPlayer/nl.lproj/Localizable.strings index e87da8baa..c048e8316 100644 --- a/BookPlayer/nl.lproj/Localizable.strings +++ b/BookPlayer/nl.lproj/Localizable.strings @@ -344,6 +344,8 @@ "share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; "share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; "share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; "share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "Server-URL"; "integration_section_server_url_footer" = "Maak verbinding met uw %@-server"; diff --git a/BookPlayer/pl.lproj/Localizable.strings b/BookPlayer/pl.lproj/Localizable.strings index 62af61be3..331d3b24b 100644 --- a/BookPlayer/pl.lproj/Localizable.strings +++ b/BookPlayer/pl.lproj/Localizable.strings @@ -344,6 +344,8 @@ "share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; "share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; "share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; "share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "Adres URL serwera"; "integration_section_server_url_footer" = "Połącz się ze swoim serwerem %@"; diff --git a/BookPlayer/pt-BR.lproj/Localizable.strings b/BookPlayer/pt-BR.lproj/Localizable.strings index c1d418ecf..d82295f14 100644 --- a/BookPlayer/pt-BR.lproj/Localizable.strings +++ b/BookPlayer/pt-BR.lproj/Localizable.strings @@ -344,6 +344,8 @@ "share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; "share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; "share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; "share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "URL do servidor"; "integration_section_server_url_footer" = "Conecte-se ao seu servidor %@"; diff --git a/BookPlayer/pt-PT.lproj/Localizable.strings b/BookPlayer/pt-PT.lproj/Localizable.strings index ea98f5c66..4aebde268 100644 --- a/BookPlayer/pt-PT.lproj/Localizable.strings +++ b/BookPlayer/pt-PT.lproj/Localizable.strings @@ -344,6 +344,8 @@ "share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; "share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; "share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; "share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "URL do servidor"; "integration_section_server_url_footer" = "Conecte-se ao seu servidor %@"; diff --git a/BookPlayer/ro.lproj/Localizable.strings b/BookPlayer/ro.lproj/Localizable.strings index af2b7fc44..9c4bac896 100644 --- a/BookPlayer/ro.lproj/Localizable.strings +++ b/BookPlayer/ro.lproj/Localizable.strings @@ -344,6 +344,8 @@ "share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; "share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; "share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; "share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "Adresa URL a serverului"; "integration_section_server_url_footer" = "Conectați-vă la serverul dvs. %@"; diff --git a/BookPlayer/ru.lproj/Localizable.strings b/BookPlayer/ru.lproj/Localizable.strings index b65e82563..c5a734209 100644 --- a/BookPlayer/ru.lproj/Localizable.strings +++ b/BookPlayer/ru.lproj/Localizable.strings @@ -344,6 +344,8 @@ "share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; "share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; "share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; "share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "URL-адрес сервера"; "integration_section_server_url_footer" = "Подключитесь к вашему серверу %@"; diff --git a/BookPlayer/sk-SK.lproj/Localizable.strings b/BookPlayer/sk-SK.lproj/Localizable.strings index e69f175aa..35fbdcc13 100644 --- a/BookPlayer/sk-SK.lproj/Localizable.strings +++ b/BookPlayer/sk-SK.lproj/Localizable.strings @@ -345,6 +345,8 @@ Usilovne pracujeme na poskytovaní bezproblémového zážitku, ak je to možné "share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; "share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; "share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; "share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "URL servera"; "integration_section_server_url_footer" = "Pripojenie sa k serveru %@"; diff --git a/BookPlayer/sv.lproj/Localizable.strings b/BookPlayer/sv.lproj/Localizable.strings index ebcdcc384..f9fab6491 100644 --- a/BookPlayer/sv.lproj/Localizable.strings +++ b/BookPlayer/sv.lproj/Localizable.strings @@ -344,6 +344,8 @@ "share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; "share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; "share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; "share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "Server URL"; "integration_section_server_url_footer" = "Anslut till din %@-server"; diff --git a/BookPlayer/tr.lproj/Localizable.strings b/BookPlayer/tr.lproj/Localizable.strings index 4bfa9e2e1..bd6d6af5e 100644 --- a/BookPlayer/tr.lproj/Localizable.strings +++ b/BookPlayer/tr.lproj/Localizable.strings @@ -344,6 +344,8 @@ "share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; "share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; "share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; "share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "Sunucu URL'si"; "integration_section_server_url_footer" = "%@ sunucunuza bağlanın"; diff --git a/BookPlayer/uk.lproj/Localizable.strings b/BookPlayer/uk.lproj/Localizable.strings index 0328ae9dd..1201c2eed 100644 --- a/BookPlayer/uk.lproj/Localizable.strings +++ b/BookPlayer/uk.lproj/Localizable.strings @@ -344,6 +344,8 @@ "share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; "share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; "share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; "share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "URL-адреса сервера"; "integration_section_server_url_footer" = "Підключіться до свого сервера %@"; diff --git a/BookPlayer/zh-Hans.lproj/Localizable.strings b/BookPlayer/zh-Hans.lproj/Localizable.strings index 3b055da7d..40d77a3cb 100644 --- a/BookPlayer/zh-Hans.lproj/Localizable.strings +++ b/BookPlayer/zh-Hans.lproj/Localizable.strings @@ -344,6 +344,8 @@ "share_import_failure_copy_failed" = "Couldn’t copy “%1$@” to BookPlayer: %2$@"; "share_import_failure_move_failed" = "Couldn’t save “%1$@” to BookPlayer: %2$@"; "share_import_failure_http_status" = "Download failed with HTTP status %d."; +"share_import_failure_unsupported_type" = "Download returned content type “%@”, which isn’t an audiobook file. Try the direct download link."; +"share_import_failure_unsupported_extension" = "Download did not look like an audiobook file (no recognized extension on “%@”). Try the direct download link."; "share_import_failure_announcement_multiple" = "%d shared imports failed."; "integration_section_server_url" = "服务器 URL"; "integration_section_server_url_footer" = "连接到您的 %@ 服务器"; diff --git a/BookPlayerShareExtension/ShareViewController.swift b/BookPlayerShareExtension/ShareViewController.swift index 3b483d671..8416cdc19 100644 --- a/BookPlayerShareExtension/ShareViewController.swift +++ b/BookPlayerShareExtension/ShareViewController.swift @@ -389,14 +389,24 @@ final class BackgroundDownloadCoordinator: NSObject, URLSessionDownloadDelegate return } - let filename = + let suggested = downloadTask.response?.suggestedFilename ?? originalURL?.lastPathComponent ?? "shared-\(UUID().uuidString)" - let destinationURL = DataManager.getSharedFilesFolderURL().appendingPathComponent(filename) + let filename = ShareDownloadSupport.sanitizedFilename(suggested) + let mime = (downloadTask.response as? HTTPURLResponse)?.mimeType?.lowercased() - /// Replace any same-named stale download from a previous attempt. - try? FileManager.default.removeItem(at: destinationURL) + if let rejection = ShareDownloadSupport.rejectionReason(forMIME: mime, filename: filename) { + ShareImportFailureStore.append( + ShareImportFailure(source: source, message: rejection) + ) + try? FileManager.default.removeItem(at: location) + return + } + + // Avoid collisions from concurrent shares of the same URL by prefixing a short UUID. + let destinationURL = DataManager.getSharedFilesFolderURL() + .appendingPathComponent(ShareDownloadSupport.uniqueDestinationName(for: filename)) do { try FileManager.default.moveItem(at: location, to: destinationURL) } catch { diff --git a/Shared/Services/ShareDownloadSupport.swift b/Shared/Services/ShareDownloadSupport.swift new file mode 100644 index 000000000..100839fda --- /dev/null +++ b/Shared/Services/ShareDownloadSupport.swift @@ -0,0 +1,109 @@ +// +// ShareDownloadSupport.swift +// BookPlayerKit +// +// Created by Matthew Alvernaz on 2026-05-17. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation + +/// Helpers shared between the share extension's in-process download coordinator and the main +/// app's `BackgroundShareDownloadDelegate`. Both write into the App Group's shared folder and +/// have to defend against the same three failure shapes: filename traversal, collisions when +/// the same URL is shared twice, and servers that return HTML / JSON error bodies with a 200. +public enum ShareDownloadSupport { + /// File extensions BookPlayer can usefully import. Mirrors the share extension's activation + /// list — kept here because both download paths fall back to filename-extension matching + /// when the server returns an ambiguous MIME type (e.g. `application/octet-stream`, or none). + public static let supportedRemoteFileExtensions: Set = [ + "mp3", "m4a", "m4b", "aac", "flac", "ogg", "opus", "wav", "wma", + "aiff", "aif", "caf", + "mp4", "m4v", "mov", + "zip" + ] + + /// MIME types we hard-reject because they are almost always Cloudflare interstitials, captive + /// portals, login walls, or API error envelopes — never an audiobook file. + private static let rejectedMIMEs: Set = [ + "text/html", "text/plain", "application/json", + "application/xml", "application/problem+json", + ] + + /// MIME types we always accept without filename-extension validation. + private static let acceptedMIMEs: Set = [ + "application/zip", "application/x-zip-compressed", + "application/x-mpegurl", "application/vnd.apple.mpegurl", + ] + + /// MIME types that are ambiguous — sane servers send them for binary downloads, broken servers + /// send them for everything. Accept only when the filename's extension is in our supported set. + private static let ambiguousMIMEs: Set = [ + "application/octet-stream", "binary/octet-stream", + "application/download", "application/force-download", + ] + + /// Strip path components, `..`, leading dots, and control characters from a server-suggested + /// filename so it can't traverse out of the shared folder or otherwise corrupt the import + /// destination. + public static func sanitizedFilename(_ raw: String) -> String { + // `lastPathComponent` collapses any `dir/file` shape down to `file`, and on Apple platforms + // it also normalizes some control characters, but we still need to defend against `..`, + // empty strings, and stray dots. + var name = (raw as NSString).lastPathComponent + while name.hasPrefix(".") { name.removeFirst() } + name = name.replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "\\", with: "_") + .replacingOccurrences(of: "\0", with: "_") + if name.isEmpty || name == "." || name == ".." { + return "shared-\(UUID().uuidString)" + } + // Cap length so a pathological server can't produce a name iOS will refuse to write. + if name.count > 240 { + let ext = (name as NSString).pathExtension + let baseLimit = ext.isEmpty ? 240 : (240 - ext.count - 1) + let base = (name as NSString).deletingPathExtension + let trimmed = String(base.prefix(baseLimit)) + name = ext.isEmpty ? trimmed : "\(trimmed).\(ext)" + } + return name + } + + /// Generate a collision-free destination filename by prefixing a short UUID. Used by both + /// delegates so two simultaneous shares of the same URL don't overwrite each other. + public static func uniqueDestinationName(for filename: String) -> String { + let prefix = UUID().uuidString.prefix(8) + return "\(prefix)-\(filename)" + } + + /// Returns a localized rejection message when the downloaded bytes shouldn't be imported. + /// `nil` means "accept this download". Layered: hard-reject known web/error MIMEs, accept + /// known-good audio/archive MIMEs, fall back to filename-extension matching for ambiguous + /// or missing MIME types. + public static func rejectionReason(forMIME mime: String?, filename: String) -> String? { + let pathExt = (filename as NSString).pathExtension.lowercased() + let extensionMatches = !pathExt.isEmpty && supportedRemoteFileExtensions.contains(pathExt) + + if let mime, rejectedMIMEs.contains(mime) { + return String(format: "share_import_failure_unsupported_type".localized, mime) + } + if let mime { + if mime.hasPrefix("audio/") || mime.hasPrefix("video/") || acceptedMIMEs.contains(mime) { + return nil + } + if ambiguousMIMEs.contains(mime) || mime.isEmpty { + return extensionMatches + ? nil + : String(format: "share_import_failure_unsupported_extension".localized, filename) + } + // Unknown MIME: be generous if the filename extension is recognized, otherwise reject. + return extensionMatches + ? nil + : String(format: "share_import_failure_unsupported_type".localized, mime) + } + // No MIME header at all — defer to filename extension. + return extensionMatches + ? nil + : String(format: "share_import_failure_unsupported_extension".localized, filename) + } +} From 762a64c6fadfa28b49255cd11ae265c98e43d408 Mon Sep 17 00:00:00 2001 From: Matthew Alvernaz Date: Sun, 17 May 2026 14:25:26 -0700 Subject: [PATCH 9/9] Honor share-cancel even after the extension dies (H15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping Cancel in the share sheet only fired `cancelRequest(...)` on the extension context — but by then iOS had already handed the download task off to `nsurlsessiond`, which keeps running across extension teardown. Best-effort `task.cancel()` from the extension worked while the extension was alive but unreliable as iOS reclaimed the process, so the main app would routinely receive a completion event for a download the user thought they aborted, and move the resulting file into the library anyway. Two-layer fix: 1. Best-effort fast path: store active download tasks in the share extension, call `.cancel()` on each before completeRequest. Wins when the extension is still alive. 2. Durable marker: ShareCancelStore (file-backed App Group set, same pattern as ShareImportFailureStore) records the share id for every canceled task. Both the share-extension coordinator and the main app's BackgroundShareDownloadDelegate check the store before processing a completion — if the share id is in the canceled set, the temp file is removed and the import is skipped. Wins even if iOS killed the extension before its task.cancel propagated. Each share download is now tagged with a stable UUID via `URLSessionTask.taskDescription`, which iOS preserves across extension teardown, giving both delegates a way to correlate the later completion with the cancel signal. --- BookPlayer.xcodeproj/project.pbxproj | 4 + BookPlayer/AppDelegate.swift | 11 +++ .../ShareViewController.swift | 42 +++++++++- Shared/Services/ShareCancelStore.swift | 80 +++++++++++++++++++ 4 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 Shared/Services/ShareCancelStore.swift diff --git a/BookPlayer.xcodeproj/project.pbxproj b/BookPlayer.xcodeproj/project.pbxproj index e958b005b..5daaca4fb 100644 --- a/BookPlayer.xcodeproj/project.pbxproj +++ b/BookPlayer.xcodeproj/project.pbxproj @@ -273,6 +273,7 @@ 41F1A228254B0C6C0043FCF3 /* ZipArchive in Frameworks */ = {isa = PBXBuildFile; productRef = 41F1A227254B0C6C0043FCF3 /* ZipArchive */; }; 6A91D2C3E04F1B8A7E62C803 /* ShareImportFailureStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A91D2C3E04F1B8A7E62C804 /* ShareImportFailureStore.swift */; }; 7B91D2C3E04F1B8A7E62C805 /* ShareDownloadSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B91D2C3E04F1B8A7E62C806 /* ShareDownloadSupport.swift */; }; + 8C91D2C3E04F1B8A7E62C807 /* ShareCancelStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C91D2C3E04F1B8A7E62C808 /* ShareCancelStore.swift */; }; 4645F9FD2D1E46AC00A04257 /* SwipeInlineTip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4645F9FC2D1E46AC00A04257 /* SwipeInlineTip.swift */; }; 465D87522D3195D600A4AA47 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465D87512D3195D600A4AA47 /* BookmarksView.swift */; }; 465D87542D31965100A4AA47 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465D87532D31965100A4AA47 /* BookmarksViewModel.swift */; }; @@ -1775,6 +1776,7 @@ D6BA8F172A4D66CD00C2BD9A /* StorageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageView.swift; sourceTree = ""; }; 6A91D2C3E04F1B8A7E62C804 /* ShareImportFailureStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareImportFailureStore.swift; sourceTree = ""; }; 7B91D2C3E04F1B8A7E62C806 /* ShareDownloadSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareDownloadSupport.swift; sourceTree = ""; }; + 8C91D2C3E04F1B8A7E62C808 /* ShareCancelStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareCancelStore.swift; sourceTree = ""; }; E1FDA0B60D7BE4CFE100A612 /* IntegrationDisconnectedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationDisconnectedView.swift; sourceTree = ""; }; E4D25EF2A5FFF30CF12F2C21 /* PreferencesSyncService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PreferencesSyncService.swift; sourceTree = ""; }; E55F466D61AE62A69DE4D371 /* BPNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BPNavigation.swift; sourceTree = ""; }; @@ -2568,6 +2570,7 @@ 63344C012EA7097D00B90DF7 /* DatabaseBackupService.swift */, 6A91D2C3E04F1B8A7E62C804 /* ShareImportFailureStore.swift */, 7B91D2C3E04F1B8A7E62C806 /* ShareDownloadSupport.swift */, + 8C91D2C3E04F1B8A7E62C808 /* ShareCancelStore.swift */, 63125D102C36D96800D35533 /* Events */, 9FC1E4612814F68F00522FA8 /* Account */, 9FD8FE4C286566FF00EB2C3D /* Sync */, @@ -4913,6 +4916,7 @@ 52AA441379FD94339FEECB55 /* SortPreferencesResolving.swift in Sources */, 6A91D2C3E04F1B8A7E62C803 /* ShareImportFailureStore.swift in Sources */, 7B91D2C3E04F1B8A7E62C805 /* ShareDownloadSupport.swift in Sources */, + 8C91D2C3E04F1B8A7E62C807 /* ShareCancelStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/BookPlayer/AppDelegate.swift b/BookPlayer/AppDelegate.swift index ad1e2a06e..81e2da179 100644 --- a/BookPlayer/AppDelegate.swift +++ b/BookPlayer/AppDelegate.swift @@ -535,6 +535,17 @@ final class BackgroundShareDownloadDelegate: NSObject, URLSessionDownloadDelegat let originalURL = downloadTask.originalRequest?.url let source = originalURL?.absoluteString ?? "shared file" + // If the user canceled this share before iOS finished the download, drop the temp + // file and bail out. Important for the case where the share extension was killed + // before its in-memory `task.cancel()` could propagate — the durable cancel marker + // in ShareCancelStore is the only signal left to honor the user's intent. + if let shareID = downloadTask.taskDescription, ShareCancelStore.isCanceled(shareID) { + Self.logger.info("share download \(source) canceled by user; dropping temp file") + try? FileManager.default.removeItem(at: location) + ShareCancelStore.clear(shareID) + return + } + /// Reject non-2xx HTTP responses — `URLSession` reports success on a 404 and the temp /// file just contains the error body. Don't deposit garbage into the library, and /// surface the failure to the user (via the share-import failure store) so they know diff --git a/BookPlayerShareExtension/ShareViewController.swift b/BookPlayerShareExtension/ShareViewController.swift index 8416cdc19..23e66832a 100644 --- a/BookPlayerShareExtension/ShareViewController.swift +++ b/BookPlayerShareExtension/ShareViewController.swift @@ -94,6 +94,14 @@ class ShareViewController: UIViewController { /// process if the extension survives long enough — see `kickOffBackgroundDownloads`. private var backgroundDownloadCoordinator: BackgroundDownloadCoordinator? + /// Tracks the in-flight background download tasks plus the share IDs we tagged them with. + /// Two paths consume this on cancel: + /// 1. Best-effort `task.cancel()` to abort while the extension still owns control. + /// 2. Write the share IDs into `ShareCancelStore` so the main app's download delegate + /// can still suppress the completion if iOS killed the extension before step 1 took + /// effect — the durable belt to step 1's suspenders. + private var activeDownloadTasks: [(shareID: String, task: URLSessionDownloadTask)] = [] + /// File extensions BookPlayer can fetch from a remote `http(s)` URL. /// /// File-URL shares (AirDrop, Files app) are accepted unconditionally — they're already @@ -196,9 +204,25 @@ class ShareViewController: UIViewController { } @objc func didPressCancel() { + cancelInFlightDownloads() extensionContext?.cancelRequest(withError: ShareExtensionError.cancelled) } + /// Best-effort cancellation of any background downloads kicked off via the share sheet. + /// + /// `task.cancel()` only works while the extension's process is still alive and still holds + /// the task reference — which is unreliable once iOS starts tearing the extension down. + /// Persist the share IDs in the App Group too, so the main app's download delegate can + /// still drop the completion event if iOS kills us before the cancel propagates to + /// `nsurlsessiond`. + private func cancelInFlightDownloads() { + guard !activeDownloadTasks.isEmpty else { return } + let ids = activeDownloadTasks.map(\.shareID) + ShareCancelStore.markCanceled(ids) + for (_, task) in activeDownloadTasks { task.cancel() } + activeDownloadTasks.removeAll() + } + @objc func didPressDone() { LoadingUtils.loadAndBlock(in: self) saveSharedItems(sharedItems) @@ -282,7 +306,15 @@ class ShareViewController: UIViewController { self.backgroundDownloadCoordinator = coordinator let session = URLSession(configuration: config, delegate: coordinator, delegateQueue: nil) for url in urls { - session.downloadTask(with: url).resume() + // Tag each task with a stable share id (via `taskDescription`, which iOS preserves + // across extension teardown). The cancel path writes these ids into + // ShareCancelStore so the main app's download delegate can suppress completions + // even after the extension's process is gone. + let task = session.downloadTask(with: url) + let shareID = UUID().uuidString + task.taskDescription = shareID + activeDownloadTasks.append((shareID: shareID, task: task)) + task.resume() } session.finishTasksAndInvalidate() } @@ -372,6 +404,14 @@ final class BackgroundDownloadCoordinator: NSObject, URLSessionDownloadDelegate let originalURL = downloadTask.originalRequest?.url let source = originalURL?.absoluteString ?? "shared file" + // If the user canceled this share before the download finished, drop the temp file + // and bail out — don't import or persist a failure (the cancel is intentional). + if let shareID = downloadTask.taskDescription, ShareCancelStore.isCanceled(shareID) { + try? FileManager.default.removeItem(at: location) + ShareCancelStore.clear(shareID) + return + } + /// Reject non-2xx HTTP responses — `URLSession` reports success on a 404 and the temp /// file just contains the error body. Record the failure so the host app can surface it. if let httpResponse = downloadTask.response as? HTTPURLResponse, diff --git a/Shared/Services/ShareCancelStore.swift b/Shared/Services/ShareCancelStore.swift new file mode 100644 index 000000000..80d1d243a --- /dev/null +++ b/Shared/Services/ShareCancelStore.swift @@ -0,0 +1,80 @@ +// +// ShareCancelStore.swift +// BookPlayerKit +// +// Created by Matthew Alvernaz on 2026-05-17. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation + +/// File-backed set of "canceled share IDs" stored in the App Group container. +/// +/// The share extension lives for seconds — once the user taps Cancel, iOS is free to tear it +/// down before any in-flight `URLSessionDownloadTask.cancel()` propagates to `nsurlsessiond`. +/// Best-effort task cancellation alone isn't enough; the main app might still receive a +/// completion event for a download the user thought they aborted, and would happily move the +/// resulting file into the library. +/// +/// `ShareCancelStore` plugs that gap: the share extension marks the `shareID` here just before +/// dismissing, and the host app's `BackgroundShareDownloadDelegate` checks the set when a +/// completion arrives. If the id is in the canceled set, the temp file gets removed and the +/// import doesn't happen. +/// +/// Implementation notes mirror `ShareImportFailureStore`: a small JSON file in the App Group, +/// atomic writes, capped size to bound the worst case if cleanup ever fails. +public enum ShareCancelStore { + /// Hard cap so a runaway loop can't fill the App Group container. + private static let maxStored = 100 + + private static var storeURL: URL? { + FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: Constants.ApplicationGroupIdentifier)? + .appendingPathComponent("share-canceled.json") + } + + /// Mark one or more share IDs as canceled. Idempotent — adding an id that's already in the + /// set is a no-op. Returns silently on App Group / encoding failures because surfacing them + /// would be worse UX than the marginal risk of a "ghost" download landing. + public static func markCanceled(_ ids: [String]) { + guard let url = storeURL, !ids.isEmpty else { return } + var canceled = load() + canceled.formUnion(ids) + // Cap by trimming the oldest insertion order. Because `Set` doesn't preserve insertion + // order, we just trim arbitrarily — the cap exists as a hygiene guard, not a precise FIFO. + if canceled.count > maxStored { + canceled = Set(canceled.prefix(maxStored)) + } + guard let data = try? JSONEncoder().encode(canceled) else { return } + try? data.write(to: url, options: .atomic) + } + + /// Check whether a given share id was canceled. Cheap — reads the file each call rather than + /// caching, so two near-simultaneous completions from different shares don't race a stale cache. + public static func isCanceled(_ id: String) -> Bool { + return load().contains(id) + } + + /// Remove an id from the canceled set, e.g. after we've successfully suppressed its + /// completion handler. Keeps the file from growing unbounded across many cancel/share cycles. + public static func clear(_ id: String) { + guard let url = storeURL else { return } + var canceled = load() + guard canceled.contains(id) else { return } + canceled.remove(id) + if canceled.isEmpty { + try? FileManager.default.removeItem(at: url) + return + } + guard let data = try? JSONEncoder().encode(canceled) else { return } + try? data.write(to: url, options: .atomic) + } + + private static func load() -> Set { + guard let url = storeURL, + let data = try? Data(contentsOf: url), + let set = try? JSONDecoder().decode(Set.self, from: data) + else { return [] } + return set + } +}