From 589abba918025a47d9ae13a6dc5176cee107ed17 Mon Sep 17 00:00:00 2001 From: Stephen Cooper <1970249+scooper4711@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:43:36 +0200 Subject: [PATCH 1/2] feat: Keep the chosen download folder as a bookmark A security-scoped bookmark reaches the folder from inside the App Sandbox and follows it when it moves. A folder chosen before has only its path and gets a bookmark at the next launch, while the app can still reach it. Prepares for #22. --- .../Storage/ChosenDownloadFolder.swift | 61 +++++++++++++++++++ .../Store/LibraryEnvironment.swift | 8 +++ .../ScrollkeeperKit/Store/LibraryStore.swift | 14 ++--- .../StoreDownloadTests.swift | 43 +++++++++++++ docs/specs/library-manager/design.md | 9 +++ docs/specs/library-manager/requirements.md | 4 ++ 6 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 Sources/ScrollkeeperKit/Storage/ChosenDownloadFolder.swift 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/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. From 21dd823565e38e3a94b2764ba4ad1ce8d04463ab Mon Sep 17 00:00:00 2001 From: Stephen Cooper <1970249+scooper4711@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:43:36 +0200 Subject: [PATCH 2/2] docs: Plan the shared App Group download folder Pawn Shop, Field Guide and Turnkeeper read Scrollkeeper's downloads; in the sandbox they need a folder shared through an App Group. For #22, after the Developer ID (#23). --- docs/specs/app-handoff/design.md | 49 ++++++++++++++++++++++---- docs/specs/app-handoff/requirements.md | 25 +++++++++++++ 2 files changed, 67 insertions(+), 7 deletions(-) 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.