Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions Sources/ScrollkeeperKit/Storage/ChosenDownloadFolder.swift
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 8 additions & 0 deletions Sources/ScrollkeeperKit/Store/LibraryEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand All @@ -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"
}
14 changes: 4 additions & 10 deletions Sources/ScrollkeeperKit/Store/LibraryStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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)
}
}
43 changes: 43 additions & 0 deletions Tests/ScrollkeeperKitTests/StoreDownloadTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
49 changes: 42 additions & 7 deletions docs/specs/app-handoff/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
`<team ID>.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/<group>/`; downloads go to its
`Scrollkeeper/Files/`. `LibraryEnvironment.live()` makes that the default
download folder on the Mac, and falls back to `<data folder>/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 `<data folder>/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.
25 changes: 25 additions & 0 deletions docs/specs/app-handoff/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,28 @@ does the same for any title with a PDF.
just before the files are opened, and `state=failed&reason=<text>` 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:
`<title>/<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.
9 changes: 9 additions & 0 deletions docs/specs/library-manager/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
4 changes: 4 additions & 0 deletions docs/specs/library-manager/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading