diff --git a/Sources/ScrollkeeperKit/Storage/ChosenDownloadFolder.swift b/Sources/ScrollkeeperKit/Storage/ChosenDownloadFolder.swift new file mode 100644 index 0000000..43f9c54 --- /dev/null +++ b/Sources/ScrollkeeperKit/Storage/ChosenDownloadFolder.swift @@ -0,0 +1,61 @@ +import Foundation + +/// The folder the user chose for downloads. It is remembered as a bookmark as well as a path: on the Mac a +/// security-scoped bookmark is what lets the app reach the folder again from inside the App Sandbox, and it +/// follows the folder when it is moved or renamed. +/// +/// A folder chosen before bookmarks were kept has only its path. It gets a bookmark the first time the app +/// reads it, while the app still runs outside the sandbox and can reach it. +struct ChosenDownloadFolder { + let settings: SettingsStore + + /// The chosen folder, with access to it until the app quits; nil when downloads go to the default folder. + func open() -> URL? { + if let folder = resolvedBookmark() { return folder } + let path = settings.downloadDirectoryPath + guard !path.isEmpty else { return nil } + let folder = URL(filePath: path, directoryHint: .isDirectory) + // A bookmark that no longer resolves may be for a disk that isn't connected; keep it for when it is. + if settings.downloadDirectoryBookmark == nil { + settings.downloadDirectoryBookmark = Self.bookmark(for: folder) + } + return folder + } + + func remember(_ folder: URL) { + settings.downloadDirectoryPath = folder.path + settings.downloadDirectoryBookmark = Self.bookmark(for: folder) + } + + func forget() { + settings.downloadDirectoryPath = "" + settings.downloadDirectoryBookmark = nil + } + + private func resolvedBookmark() -> URL? { + guard let bookmark = settings.downloadDirectoryBookmark else { return nil } + var isStale = false + guard let folder = try? URL(resolvingBookmarkData: bookmark, options: Self.resolutionOptions, + relativeTo: nil, bookmarkDataIsStale: &isStale) + else { return nil } + _ = folder.startAccessingSecurityScopedResource() + if isStale { + remember(folder) + } else if folder.path != settings.downloadDirectoryPath { + settings.downloadDirectoryPath = folder.path + } + return folder + } + + private static func bookmark(for folder: URL) -> Data? { + try? folder.bookmarkData(options: creationOptions, includingResourceValuesForKeys: nil, relativeTo: nil) + } + + #if os(macOS) + private static let creationOptions: URL.BookmarkCreationOptions = .withSecurityScope + private static let resolutionOptions: URL.BookmarkResolutionOptions = .withSecurityScope + #else + private static let creationOptions: URL.BookmarkCreationOptions = [] + private static let resolutionOptions: URL.BookmarkResolutionOptions = [] + #endif +} diff --git a/Sources/ScrollkeeperKit/Store/LibraryEnvironment.swift b/Sources/ScrollkeeperKit/Store/LibraryEnvironment.swift index 7ec5d69..916f2c1 100644 --- a/Sources/ScrollkeeperKit/Store/LibraryEnvironment.swift +++ b/Sources/ScrollkeeperKit/Store/LibraryEnvironment.swift @@ -86,6 +86,13 @@ public struct SettingsStore: Sendable { nonmutating set { defaults.set(newValue, forKey: Self.downloadDirectoryKey) } } + /// A bookmark of the folder the user chose for downloads, which reaches it from inside the App Sandbox. + /// Nil for the default folder, and for a folder chosen before bookmarks were kept. + public var downloadDirectoryBookmark: Data? { + get { defaults.data(forKey: Self.downloadDirectoryBookmarkKey) } + nonmutating set { defaults.set(newValue, forKey: Self.downloadDirectoryBookmarkKey) } + } + /// False until a full sync has run to the end, so an interrupted first sync is started again. public var hasCompletedFullSync: Bool { get { defaults.bool(forKey: Self.completedFullSyncKey) } @@ -111,6 +118,7 @@ public struct SettingsStore: Sendable { /// The key of `checksForUpdatesAtLaunch`, for views that bind a switch to it. public static let updateCheckAtLaunchKey = "checksForUpdatesAtLaunch" private static let downloadDirectoryKey = "downloadDirectoryPath" + private static let downloadDirectoryBookmarkKey = "downloadDirectoryBookmark" private static let completedFullSyncKey = "hasCompletedFullSync" private static let metadataVersionKey = "metadataVersion" } diff --git a/Sources/ScrollkeeperKit/Store/LibraryStore.swift b/Sources/ScrollkeeperKit/Store/LibraryStore.swift index 00610e6..ce21f25 100644 --- a/Sources/ScrollkeeperKit/Store/LibraryStore.swift +++ b/Sources/ScrollkeeperKit/Store/LibraryStore.swift @@ -130,7 +130,8 @@ public final class LibraryStore { metadataSynchronizer = MetadataSynchronizer( storefront: StorefrontClient(http: environment.http), http: environment.http, covers: covers ) - locator = FileLocator(root: Self.downloadDirectory(in: environment)) + let chosenFolder = ChosenDownloadFolder(settings: environment.settings).open() + locator = FileLocator(root: chosenFolder ?? environment.defaultDownloadDirectory) } /// Loads the stored catalog and, when an account exists but the library has never been @@ -167,13 +168,13 @@ public final class LibraryStore { /// Moves future downloads to another folder. Files already downloaded stay where they are. public func setDownloadDirectory(_ url: URL) { - environment.settings.downloadDirectoryPath = url.path + ChosenDownloadFolder(settings: environment.settings).remember(url) locator = FileLocator(root: url) refreshDownloadedItems() } public func resetDownloadDirectory() { - environment.settings.downloadDirectoryPath = "" + ChosenDownloadFolder(settings: environment.settings).forget() locator = FileLocator(root: environment.defaultDownloadDirectory) refreshDownloadedItems() } @@ -215,11 +216,4 @@ public final class LibraryStore { try? await write(repository) } } - - private static func downloadDirectory(in environment: LibraryEnvironment) -> URL { - let chosen = environment.settings.downloadDirectoryPath - return chosen.isEmpty - ? environment.defaultDownloadDirectory - : URL(filePath: chosen, directoryHint: .isDirectory) - } } diff --git a/Tests/ScrollkeeperKitTests/StoreDownloadTests.swift b/Tests/ScrollkeeperKitTests/StoreDownloadTests.swift index 0125cc4..ad291f9 100644 --- a/Tests/ScrollkeeperKitTests/StoreDownloadTests.swift +++ b/Tests/ScrollkeeperKitTests/StoreDownloadTests.swift @@ -165,6 +165,49 @@ import Testing store.resetDownloadDirectory() #expect(store.downloadDirectory == harness.environment.defaultDownloadDirectory) + #expect(harness.environment.settings.downloadDirectoryBookmark == nil) + } + + @Test func chosenFolderIsFoundAfterItMoves() throws { + let folder = try makeFolder("Books") + harness.makeStore().setDownloadDirectory(folder) + #expect(harness.environment.settings.downloadDirectoryBookmark != nil) + + let moved = harness.directory.url.appending(path: "Moved Books", directoryHint: .isDirectory) + try FileManager.default.moveItem(at: folder, to: moved) + + #expect(realPath(of: harness.makeStore().downloadDirectory) == realPath(of: moved)) + #expect(realPath(of: URL(filePath: harness.environment.settings.downloadDirectoryPath)) == realPath(of: moved)) + } + + @Test func folderChosenBeforeBookmarksGetsOne() throws { + let folder = try makeFolder("Books") + harness.environment.settings.downloadDirectoryPath = folder.path + + #expect(harness.makeStore().downloadDirectory.path == folder.path) + #expect(harness.environment.settings.downloadDirectoryBookmark != nil) + } + + @Test func missingChosenFolderKeepsItsBookmark() throws { + let folder = try makeFolder("Books") + harness.makeStore().setDownloadDirectory(folder) + let bookmark = harness.environment.settings.downloadDirectoryBookmark + try FileManager.default.removeItem(at: folder) + + #expect(harness.makeStore().downloadDirectory.path == folder.path) + #expect(harness.environment.settings.downloadDirectoryBookmark == bookmark) + } + + private func makeFolder(_ name: String) throws -> URL { + let folder = harness.directory.url.appending(path: name, directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + return folder + } + + /// The temporary folder is reached through a symbolic link, which a resolved bookmark leaves out, so paths + /// are compared in one form. + private func realPath(of folder: URL) -> String { + folder.resolvingSymlinksInPath().path } } diff --git a/docs/specs/app-handoff/design.md b/docs/specs/app-handoff/design.md index ca05046..bbacfe7 100644 --- a/docs/specs/app-handoff/design.md +++ b/docs/specs/app-handoff/design.md @@ -119,10 +119,45 @@ the state of the download the request waits for. While `handoffFiles` waits, ## Sandboxing -Pluck is sandboxed; Scrollkeeper and Mapsmith are not yet. The URL handoff and -the file opening work in the sandbox: Launch Services gives the receiving app -access to the files it is asked to open. That is why a sandboxed app gets its -list as a title index handed over by an index request, not by reading -`maps.json` from Scrollkeeper's folder, which it cannot. Mapsmith now uses -the same request. `maps.json` is still written for Mapsmith 1.1 and earlier, -and can go once those versions are no longer in use. +Pluck and Field Guide run in the App Sandbox; Mapsmith, Pawn Shop and +Turnkeeper are moving to it, and Scrollkeeper follows once it is signed with +the Developer ID (scrollkeeper#22, #23). The URL handoff and the file opening +work in the sandbox: Launch Services gives the receiving app access to the +files it is asked to open. That is why a sandboxed app gets its list as a +title index handed over by an index request, not by reading `maps.json` from +Scrollkeeper's folder, which it cannot. Mapsmith now uses the same request. +`maps.json` is still written for Mapsmith 1.1 and earlier, and can go once +those versions are no longer in use. + +### Shared download folder (planned, requirements 5) + +The apps that import many titles at once read Scrollkeeper's downloads +directly; asking for each title through the handoff would be too slow for +them. In the sandbox that needs a folder both sides may reach: + +- **App Group.** Scrollkeeper, Pawn Shop, Field Guide and Turnkeeper get the + `com.apple.security.application-groups` entitlement for one group, + `.io.github.scooper4711.tableside`. On the Mac the identifier must + start with the team ID, and the apps must be signed by that team; an + ad-hoc-signed app is refused or prompts for access to other apps' data. The + team ID is not committed: `build-app.sh` fills it in from a gitignored + setting, as `ios/Local.xcconfig` does for the iPad app. +- **Location.** `FileManager.containerURL(forSecurityApplicationGroupIdentifier:)` + gives `~/Library/Group Containers//`; downloads go to its + `Scrollkeeper/Files/`. `LibraryEnvironment.live()` makes that the default + download folder on the Mac, and falls back to `/Files` when the + group container is unavailable (an unsigned development build). +- **Moving the downloads.** `container-migration.plist` moves + `${ApplicationSupport}/Scrollkeeper` into Scrollkeeper's own container at the + first sandboxed launch. The store then moves `/Files` to the + shared folder with `FileManager.moveItem` when the shared folder does not + exist yet. Both are renames on the same volume, so a library of several + gigabytes moves at once. If the move fails, the old folder stays the + download folder. +- **Chosen folder.** A folder the user chose is kept as a security-scoped + bookmark (`ChosenDownloadFolder`, 5.4a of the library-manager requirements), + made while the app still runs outside the sandbox, so it stays reachable + after the switch. +- **Readers.** Each importing app looks for `Scrollkeeper/Files` in the group + container first and keeps its folder picker, with a remembered bookmark, as + the fallback (5.5). Readers never write there. diff --git a/docs/specs/app-handoff/requirements.md b/docs/specs/app-handoff/requirements.md index 6fb585a..9e09db4 100644 --- a/docs/specs/app-handoff/requirements.md +++ b/docs/specs/app-handoff/requirements.md @@ -95,3 +95,28 @@ does the same for any title with a PDF. just before the files are opened, and `state=failed&reason=` when the request cannot be met, with the text of the alert (2.7, 3.6). A failed index request has an empty SKU. + +## 5. Shared download folder (planned) + +Planned for when Scrollkeeper runs in the App Sandbox, which needs the +Developer ID (scrollkeeper#22, #23). Mac only; the iPad app keeps its +downloads in its Documents folder (10.3 of the library-manager requirements). + +5.1 Scrollkeeper's default download folder is in a folder shared by the + Tableside Toolkit apps, so that an app that imports many titles at once, + such as Pawn Shop, Field Guide or Turnkeeper, can read the downloads + without the user pointing it at them. +5.2 The first time it runs in the sandbox, Scrollkeeper moves the downloads in + its old default folder into the shared folder. Nothing is downloaded + again, and the move takes no longer than renaming a folder. +5.3 A download folder the user chose stays where it is. Other apps then ask + the user for it, as they do today. +5.4 Only Scrollkeeper writes to the shared folder; other apps only read it, + with the same layout as the download folder: + `/<edition name>.<ext>` and `<title>/<edition name>/`. +5.5 An app that reads the shared folder looks there first and asks the user + for a folder only when it is missing or empty: when Scrollkeeper is not + installed, has not yet run in the sandbox, or downloads to a folder the + user chose. +5.6 Apps that open one title at a time, such as Mapsmith and Pluck, keep + asking through the handoff (2, 3), which also downloads what is missing. diff --git a/docs/specs/library-manager/design.md b/docs/specs/library-manager/design.md index 0fda9f0..700b9c6 100644 --- a/docs/specs/library-manager/design.md +++ b/docs/specs/library-manager/design.md @@ -199,6 +199,15 @@ tags are unchanged. location the user picks in a save panel (`LibraryStore.export`) and is not tracked afterwards. +A download folder the user chose in Settings is kept by `ChosenDownloadFolder` +both as a path and as a security-scoped bookmark (5.4a). The bookmark comes +first: it reaches the folder from inside the App Sandbox and follows it when +moved. The path is the fallback when the bookmark no longer resolves, such as +for a disk that isn't connected, and the bookmark is then kept for when it is. +A folder chosen before bookmarks were kept has only its path, and gets a +bookmark the first time the store reads it, while the app runs outside the +sandbox. + Paizo's download API reads the members of the request body by position, so the body is written with its members in the order the web library uses: `key`, `legacy`, `token`, `customer`. diff --git a/docs/specs/library-manager/requirements.md b/docs/specs/library-manager/requirements.md index 39941ce..884b9db 100644 --- a/docs/specs/library-manager/requirements.md +++ b/docs/specs/library-manager/requirements.md @@ -110,6 +110,10 @@ once, online or off. titles, in one step. 5.4 A downloaded file is kept on disk. Its location is a folder inside the user's Library directory by default and can be changed in Settings. +5.4a A download folder the user chose is remembered so the app can still reach + it when it runs in the App Sandbox, and is found again after the user + moves or renames it. A folder on a disk that isn't connected stays + chosen. 5.5 Once a file is downloaded, the app offers "Open", "Open With" and "Show in Finder" instead of "Download". "Open With" lists every application that has registered itself for the file's type.