diff --git a/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift b/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift index 04346d128..5bd4e372c 100644 --- a/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift +++ b/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift @@ -403,5 +403,8 @@ private struct AudiobookShelfTabRoot: View { .tint(theme.linkColor) .environmentObject(theme) } + .task { + navigation.dismiss = onDismiss + } } } diff --git a/BookPlayer/Import/Models/ImportOperation.swift b/BookPlayer/Import/Models/ImportOperation.swift index 72f4d0f30..a2c9feafb 100644 --- a/BookPlayer/Import/Models/ImportOperation.swift +++ b/BookPlayer/Import/Models/ImportOperation.swift @@ -187,7 +187,7 @@ public class ImportOperation: Operation { try FileManager.default.copyItem(at: fileURL, to: existingFileURL) - if DataManager.isAppOwnFolder(fileURL) { + if DataManager.isAppManagedSource(fileURL) { fileURL.disableFileProtection() try? FileManager.default.removeItem(at: fileURL) } @@ -216,13 +216,17 @@ public class ImportOperation: Operation { private func detectFolderOrganization() { guard files.count > 1 else { return } - let documentsURL = DataManager.getDocumentsFolderURL() + let topLevelImportPaths: Set = [ + DataManager.getDocumentsFolderURL().resolvingSymlinksInPath().path, + DataManager.getSharedFilesFolderURL().resolvingSymlinksInPath().path, + DataManager.getInboxFolderURL().resolvingSymlinksInPath().path, + ] var parentFolders = Set() for file in files { let parentURL = file.deletingLastPathComponent() - guard parentURL != documentsURL else { continue } + guard !topLevelImportPaths.contains(parentURL.resolvingSymlinksInPath().path) else { continue } parentFolders.insert(parentURL.lastPathComponent) } @@ -260,7 +264,7 @@ public class ImportOperation: Operation { do { try FileManager.default.copyItem(at: currentFile, to: destinationURL) - if DataManager.isAppOwnFolder(currentFile) { + if DataManager.isAppManagedSource(currentFile) { currentFile.disableFileProtection() try FileManager.default.removeItem(at: currentFile) } diff --git a/BookPlayer/Jellyfin/JellyfinRootView.swift b/BookPlayer/Jellyfin/JellyfinRootView.swift index fdd3c0edf..07c94aee7 100644 --- a/BookPlayer/Jellyfin/JellyfinRootView.swift +++ b/BookPlayer/Jellyfin/JellyfinRootView.swift @@ -287,6 +287,9 @@ private struct JellyfinTabRoot: View { .sheet(isPresented: $showConnectionDetails) { connectionDetailsSheet } + .task { + navigation.dismiss = onDismiss + } } @ViewBuilder @@ -483,6 +486,9 @@ where ViewModel.Item == JellyfinLibraryItem { dismissAll: dismissAll ) } + .task { + navigation.dismiss = onDismiss + } } } diff --git a/BookPlayer/Library/ItemList/ItemListView+Alerts.swift b/BookPlayer/Library/ItemList/ItemListView+Alerts.swift index 989453d04..4ef4e68ac 100644 --- a/BookPlayer/Library/ItemList/ItemListView+Alerts.swift +++ b/BookPlayer/Library/ItemList/ItemListView+Alerts.swift @@ -46,7 +46,7 @@ extension ItemListView { @ViewBuilder func importCompletionAlert(for alertParameters: ImportOperationState.AlertParameters) -> some View { let hasParentFolder = model.libraryNode.folderRelativePath != nil - let suggestedFolderName = alertParameters.suggestedFolderName ?? "" + let suggestedFolderName = ((alertParameters.suggestedFolderName ?? "") as NSString).deletingPathExtension let canCreateBound = alertParameters.hasOnlyBooks || alertParameters.singleFolder != nil if hasParentFolder { @@ -60,6 +60,7 @@ extension ItemListView { } Button("new_playlist_button") { + folderInput.prepareForFolder(title: suggestedFolderName, placeholder: suggestedFolderName) model.selectedSetItems = Set(alertParameters.itemIdentifiers) activeAlert = nil Task { @MainActor in @@ -113,7 +114,7 @@ extension ItemListView { .disabled(availableFolders.isEmpty) Button("bound_books_create_button") { - let suggestedFolderName = model.selectedItems.first?.title ?? "" + let suggestedFolderName = ((model.selectedItems.first?.title ?? "") as NSString).deletingPathExtension folderInput.prepareForBound(title: suggestedFolderName, placeholder: suggestedFolderName) activeAlert = nil Task { @MainActor in diff --git a/BookPlayer/Library/ItemList/ItemListViewModel.swift b/BookPlayer/Library/ItemList/ItemListViewModel.swift index 66053b4d7..2cf63809f 100644 --- a/BookPlayer/Library/ItemList/ItemListViewModel.swift +++ b/BookPlayer/Library/ItemList/ItemListViewModel.swift @@ -512,7 +512,7 @@ extension ItemListViewModel { let gotAccess = url.startAccessingSecurityScopedResource() guard gotAccess else { continue } - if DataManager.isAppOwnFolder(url) { + if DataManager.isInDocumentsFolder(url) { skippedOwnFiles += 1 url.stopAccessingSecurityScopedResource() continue diff --git a/BookPlayer/Library/ItemList/LibraryRootView.swift b/BookPlayer/Library/ItemList/LibraryRootView.swift index 9c01cfc6b..306e4e154 100644 --- a/BookPlayer/Library/ItemList/LibraryRootView.swift +++ b/BookPlayer/Library/ItemList/LibraryRootView.swift @@ -165,6 +165,11 @@ struct LibraryRootView: View { UserDefaults.standard.removeObject(forKey: Constants.UserDefaults.showPlayer) playerState.showPlayer = true } + } catch BPPlayerError.fileMissing { + // Silent preload: if the last-played file is missing on disk, + // swallow the error. The user will see the proper alert if/when + // they explicitly try to play this book. Surfacing it here would + // race with other cold-launch presentations (e.g. the import sheet). } catch { loadingState.error = error } diff --git a/BookPlayer/Library/ItemList/Models/FolderCreationInput.swift b/BookPlayer/Library/ItemList/Models/FolderCreationInput.swift index 4474f5cb5..b5c2ce465 100644 --- a/BookPlayer/Library/ItemList/Models/FolderCreationInput.swift +++ b/BookPlayer/Library/ItemList/Models/FolderCreationInput.swift @@ -27,9 +27,9 @@ struct FolderCreationInput { type = .bound } - mutating func prepareForFolder(placeholder suggestedPlaceholder: String = "") { - name = "" - placeholder = suggestedPlaceholder + mutating func prepareForFolder(title: String? = nil, placeholder suggestedPlaceholder: String = "") { + name = title ?? "" + placeholder = title ?? suggestedPlaceholder type = .folder } } diff --git a/BookPlayer/MediaServerIntegration/BPNavigation.swift b/BookPlayer/MediaServerIntegration/BPNavigation.swift index 5a4eccc8a..71322f502 100644 --- a/BookPlayer/MediaServerIntegration/BPNavigation.swift +++ b/BookPlayer/MediaServerIntegration/BPNavigation.swift @@ -10,7 +10,7 @@ import SwiftUI @MainActor final class BPNavigation: ObservableObject { - var dismiss: DismissAction? + var dismiss: (() -> Void)? @Published var path = NavigationPath() diff --git a/BookPlayer/Settings/Sections/DebugFileTransferable.swift b/BookPlayer/Settings/Sections/DebugFileTransferable.swift index 5650b656e..b95e2e31f 100644 --- a/BookPlayer/Settings/Sections/DebugFileTransferable.swift +++ b/BookPlayer/Settings/Sections/DebugFileTransferable.swift @@ -9,64 +9,79 @@ import BookPlayerKit import RevenueCat import SwiftUI +import UniformTypeIdentifiers + +struct DebugFileDocument: FileDocument { + static var readableContentTypes: [UTType] { [.plainText] } + let data: Data + + init(data: Data) { + self.data = data + } + + init(configuration: ReadConfiguration) throws { + self.data = configuration.file.regularFileContents ?? Data() + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: data) + } +} struct DebugFileTransferable: Transferable { static var transferRepresentation: some TransferRepresentation { DataRepresentation(exportedContentType: .text) { file in - let syncService = file.syncService - let libraryService = file.libraryService - - var remoteIdentifiers: [String]? - var syncJobsInformation: String? - var syncError: String? - - // Always get sync state information regardless of isActive - syncJobsInformation = await file.getSyncOperationsInformation() - - if syncService.isActive { - do { - remoteIdentifiers = try await syncService.fetchSyncedIdentifiers() - } catch { - syncError = "Error fetching remote identifiers: \(error.localizedDescription)" - } + await file.generateDebugData() + } + .suggestedFileName { _ in + return "bookplayer_debug_information.txt" + } + } + + let libraryService: LibraryService + let accountService: AccountService + let syncService: SyncService + + func generateDebugData() async -> Data { + var remoteIdentifiers: [String]? + var syncError: String? + + let syncJobsInformation = await getSyncOperationsInformation() + + if syncService.isActive { + do { + remoteIdentifiers = try await syncService.fetchSyncedIdentifiers() + } catch { + syncError = "Error fetching remote identifiers: \(error.localizedDescription)" } + } - let localidentifiers = libraryService.fetchIdentifiers() + let localidentifiers = libraryService.fetchIdentifiers() - var libraryRepresentation = file.getLibraryRepresentation( + var libraryRepresentation = getLibraryRepresentation( + localidentifiers: localidentifiers, + remoteIdentifiers: remoteIdentifiers + ) + + if let remoteIdentifiers, + let remoteOnlyInfo = getRemoteOnlyInformation( localidentifiers: localidentifiers, remoteIdentifiers: remoteIdentifiers ) + { + libraryRepresentation += remoteOnlyInfo + } - if let remoteIdentifiers, - let remoteOnlyInfo = file.getRemoteOnlyInformation( - localidentifiers: localidentifiers, - remoteIdentifiers: remoteIdentifiers - ) - { - libraryRepresentation += remoteOnlyInfo - } - - libraryRepresentation += file.getStorageBreakdown() - - if let syncJobsInformation { - libraryRepresentation += syncJobsInformation - } + libraryRepresentation += getStorageBreakdown() - if let syncError { - libraryRepresentation += "\n\n⚠️ Sync Error:\n\(syncError)\n" - } + libraryRepresentation += syncJobsInformation - return libraryRepresentation.data(using: .utf8)! + if let syncError { + libraryRepresentation += "\n\n⚠️ Sync Error:\n\(syncError)\n" } - .suggestedFileName { _ in - return "bookplayer_debug_information.txt" - } - } - let libraryService: LibraryService - let accountService: AccountService - let syncService: SyncService + return libraryRepresentation.data(using: .utf8)! + } /// Get a representation of the library like with the `tree` command /// Note: For the first status, '✓' means the backing file exists, and '𐄂' that it's missing locally, diff --git a/BookPlayer/Settings/Sections/SettingsSupportSectionView.swift b/BookPlayer/Settings/Sections/SettingsSupportSectionView.swift index 032152107..f81923064 100644 --- a/BookPlayer/Settings/Sections/SettingsSupportSectionView.swift +++ b/BookPlayer/Settings/Sections/SettingsSupportSectionView.swift @@ -10,6 +10,7 @@ import BookPlayerKit import MessageUI import RevenueCat import SwiftUI +import UniformTypeIdentifiers struct SettingsSupportSectionView: View { var accessLevel: AccessLevel @@ -17,8 +18,12 @@ struct SettingsSupportSectionView: View { @Environment(\.libraryService) private var libraryService @Environment(\.accountService) private var accountService @Environment(\.syncService) private var syncService + @Environment(\.loadingState) private var loadingState @Environment(\.openURL) private var openURL + @State private var isExportingDebugFile = false + @State private var debugDocument: DebugFileDocument? + let supportEmail = "support@bookplayer.app" var sendEmail: () -> Void @@ -43,21 +48,7 @@ struct SettingsSupportSectionView: View { } .buttonStyle(.borderless) - ShareLink( - item: DebugFileTransferable( - libraryService: libraryService, - accountService: accountService, - syncService: syncService - ), - preview: SharePreview( - "bookplayer_debug_information.txt", - image: Image(systemName: "text.page") - ) - ) { - Text("settings_share_debug_information") - .bpFont(.body) - } - .foregroundStyle(theme.primaryColor) + debugInfoButton Button { let url = URL(string: "https://github.com/TortugaPower/BookPlayer")! openURL(url) @@ -85,6 +76,55 @@ struct SettingsSupportSectionView: View { } } + @ViewBuilder + private var debugInfoButton: some View { + let file = DebugFileTransferable( + libraryService: libraryService, + accountService: accountService, + syncService: syncService + ) + // On iOS-app-on-Mac, `ShareLink` + `Transferable` crashes in + // `SHKSaveToFilesSharingService` when routing to "Save to Files" because + // `NSItemProvider.suggestedName` isn't propagated, and `NSSavePanel` + // rejects the nil filename. Use `.fileExporter` with an explicit + // `defaultFilename` to bypass that path. + if ProcessInfo.processInfo.isiOSAppOnMac { + Button { + Task { @MainActor in + loadingState.show = true + let data = await file.generateDebugData() + loadingState.show = false + debugDocument = DebugFileDocument(data: data) + isExportingDebugFile = true + } + } label: { + Text("settings_share_debug_information") + .bpFont(.body) + } + .foregroundStyle(theme.primaryColor) + .fileExporter( + isPresented: $isExportingDebugFile, + document: debugDocument, + contentType: .plainText, + defaultFilename: "bookplayer_debug_information" + ) { _ in + debugDocument = nil + } + } else { + ShareLink( + item: file, + preview: SharePreview( + "bookplayer_debug_information.txt", + image: Image(systemName: "text.page") + ) + ) { + Text("settings_share_debug_information") + .bpFont(.body) + } + .foregroundStyle(theme.primaryColor) + } + } + private var appVersion: String { let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String diff --git a/BookPlayer/Settings/SettingsView.swift b/BookPlayer/Settings/SettingsView.swift index 07a3bc7f4..49115a6ef 100644 --- a/BookPlayer/Settings/SettingsView.swift +++ b/BookPlayer/Settings/SettingsView.swift @@ -58,6 +58,7 @@ struct SettingsView: View { SettingsCreditsSectionView() } .environment(\.loadingState, loadingState) + .loadingOverlay(loadingState.show) .navigationTitle("settings_title") .navigationBarTitleDisplayMode(.inline) .applyListStyle(with: theme, background: theme.systemBackgroundColor) diff --git a/BookPlayerTests/ImportOperationTests.swift b/BookPlayerTests/ImportOperationTests.swift index 67a579160..bcc3f4fee 100644 --- a/BookPlayerTests/ImportOperationTests.swift +++ b/BookPlayerTests/ImportOperationTests.swift @@ -18,6 +18,8 @@ class ImportOperationTests: XCTestCase { // Put setup code here. This method is called before the invocation of each test method in the class. let documentsFolder = DataManager.getDocumentsFolderURL() DataTestUtils.clearFolderContents(url: documentsFolder) + let sharedFolder = DataManager.getSharedFilesFolderURL() + DataTestUtils.clearFolderContents(url: sharedFolder) } func testProcessOneFile() { @@ -60,4 +62,69 @@ class ImportOperationTests: XCTestCase { wait(for: [promise, promiseFile], timeout: 15) } + + func testProcessFileFromSharedFolder() { + let filename = "shared_file.txt" + let bookContents = "sharedbookcontents".data(using: .utf8)! + let sharedFolder = DataManager.getSharedFilesFolderURL() + + // Add test file to the App Group SharedFiles folder (Share-extension drop location) + let fileUrl = DataTestUtils.generateTestFile(name: filename, contents: bookContents, destinationFolder: sharedFolder) + + let promise = XCTestExpectation(description: "Process shared file") + let dataManager = DataManager(coreDataStack: CoreDataStack(testPath: "/dev/null")) + let audioMetadataService = AudioMetadataService() + let libraryService = LibraryService() + libraryService.setup(dataManager: dataManager, audioMetadataService: audioMetadataService) + let operation = ImportOperation(files: [fileUrl], libraryService: libraryService) + + operation.completionBlock = { + // Source in SharedFiles should be cleaned up after import (isAppManagedSource) + XCTAssertFalse(FileManager.default.fileExists(atPath: fileUrl.path)) + + XCTAssertNotNil(operation.processedFiles.first) + let processedFile = operation.processedFiles.first! + XCTAssert(FileManager.default.fileExists(atPath: processedFile.path)) + XCTAssertEqual(FileManager.default.contents(atPath: processedFile.path), bookContents) + + promise.fulfill() + } + + operation.start() + + wait(for: [promise], timeout: 15) + } + + func testProcessFileFromInboxFolder() throws { + let filename = "inbox_file.txt" + let bookContents = "inboxbookcontents".data(using: .utf8)! + let inboxFolder = DataManager.getInboxFolderURL() + try FileManager.default.createDirectory(at: inboxFolder, withIntermediateDirectories: true) + + // Add test file to the Documents/Inbox folder (system inbox for document interactions) + let fileUrl = DataTestUtils.generateTestFile(name: filename, contents: bookContents, destinationFolder: inboxFolder) + + let promise = XCTestExpectation(description: "Process inbox file") + let dataManager = DataManager(coreDataStack: CoreDataStack(testPath: "/dev/null")) + let audioMetadataService = AudioMetadataService() + let libraryService = LibraryService() + libraryService.setup(dataManager: dataManager, audioMetadataService: audioMetadataService) + let operation = ImportOperation(files: [fileUrl], libraryService: libraryService) + + operation.completionBlock = { + // Source in Inbox (a Documents subfolder) should be cleaned up after import + XCTAssertFalse(FileManager.default.fileExists(atPath: fileUrl.path)) + + XCTAssertNotNil(operation.processedFiles.first) + let processedFile = operation.processedFiles.first! + XCTAssert(FileManager.default.fileExists(atPath: processedFile.path)) + XCTAssertEqual(FileManager.default.contents(atPath: processedFile.path), bookContents) + + promise.fulfill() + } + + operation.start() + + wait(for: [promise], timeout: 15) + } } diff --git a/Shared/CoreData/DataManager.swift b/Shared/CoreData/DataManager.swift index c961f1bf7..c6e6a708f 100644 --- a/Shared/CoreData/DataManager.swift +++ b/Shared/CoreData/DataManager.swift @@ -157,18 +157,22 @@ public class DataManager { return absoluteUrl.contains(processedFolderUrl) } - /// Check if a URL points to the app's own Documents folder or is contained within it. - /// This prevents importing the app's own folder structure which would cause circular references. - public class func isAppOwnFolder(_ url: URL) -> Bool { - let resolvedURL = url.resolvingSymlinksInPath() + /// Check if a URL points into the app's Documents folder (or any subfolder). + /// Used to prevent circular self-imports via the file picker. + public class func isInDocumentsFolder(_ url: URL) -> Bool { + let resolvedPath = url.resolvingSymlinksInPath().path let documentsPath = getDocumentsFolderURL().resolvingSymlinksInPath().path + return resolvedPath == documentsPath || resolvedPath.hasPrefix(documentsPath + "/") + } - // Check if the URL is the Documents folder itself or inside it - if resolvedURL.path == documentsPath || resolvedURL.path.hasPrefix(documentsPath + "/") { - return true - } - - return false + /// Check if a URL points into an app-managed source location + /// (Documents or the App Group SharedFiles folder). Used during the import + /// flow to clean up source files once they've been copied into Processed. + public class func isAppManagedSource(_ url: URL) -> Bool { + if isInDocumentsFolder(url) { return true } + let resolvedPath = url.resolvingSymlinksInPath().path + let sharedPath = getSharedFilesFolderURL().resolvingSymlinksInPath().path + return resolvedPath == sharedPath || resolvedPath.hasPrefix(sharedPath + "/") } /// Create the parent folder (and intermediates) for a file URL if necessary