Skip to content

Commit 8bf3f77

Browse files
Add file picker for sandbox-compatible Safari bookmarks access.
Replace the hardcoded ~/Library/Safari/Bookmarks.plist path with an NSOpenPanel-based flow that persists a security-scoped bookmark via UserDefaults. On first launch the user selects the file once; subsequent launches resolve the stored bookmark automatically. The panel filters to .plist files and validates the selected file contains a Safari reading list before accepting it. Demo mode bypasses the picker entirely.
1 parent c65e3d9 commit 8bf3f77

File tree

5 files changed

+266
-34
lines changed

5 files changed

+266
-34
lines changed
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import AppKit
2+
import Foundation
3+
import UniformTypeIdentifiers
4+
5+
@MainActor
6+
final class BookmarkAccessManager: ObservableObject {
7+
enum State: Equatable {
8+
case checking
9+
case needsPermission
10+
case ready(URL)
11+
case failed(String)
12+
}
13+
14+
@Published var state: State = .checking
15+
16+
private static let bookmarkDataKey = "SafariBookmarksPlistBookmarkData"
17+
private var accessedURL: URL?
18+
19+
func resolveAccess() {
20+
guard let data = UserDefaults.standard.data(forKey: Self.bookmarkDataKey) else {
21+
state = .needsPermission
22+
return
23+
}
24+
25+
do {
26+
var isStale = false
27+
let url = try URL(
28+
resolvingBookmarkData: data,
29+
options: [.withSecurityScope],
30+
relativeTo: nil,
31+
bookmarkDataIsStale: &isStale
32+
)
33+
34+
if isStale {
35+
if let refreshedData = try? url.bookmarkData(
36+
options: [.withSecurityScope],
37+
includingResourceValuesForKeys: nil,
38+
relativeTo: nil
39+
) {
40+
UserDefaults.standard.set(refreshedData, forKey: Self.bookmarkDataKey)
41+
}
42+
}
43+
44+
guard url.startAccessingSecurityScopedResource() else {
45+
state = .needsPermission
46+
return
47+
}
48+
49+
accessedURL = url
50+
state = .ready(url)
51+
} catch {
52+
state = .needsPermission
53+
}
54+
}
55+
56+
func promptUserToSelectFile() {
57+
let panel = NSOpenPanel()
58+
panel.title = "Select Safari Bookmarks.plist"
59+
panel.message = "Navigate to ~/Library/Safari/ and select Bookmarks.plist"
60+
panel.canChooseFiles = true
61+
panel.canChooseDirectories = false
62+
panel.allowsMultipleSelection = false
63+
panel.allowedContentTypes = [UTType.propertyList]
64+
panel.treatsFilePackagesAsDirectories = true
65+
66+
let safariDir = FileManager.default.homeDirectoryForCurrentUser
67+
.appending(path: "Library/Safari", directoryHint: .isDirectory)
68+
panel.directoryURL = safariDir
69+
70+
let response = panel.runModal()
71+
guard response == .OK, let url = panel.url else {
72+
return
73+
}
74+
75+
if let validationError = validateBookmarksPlist(at: url) {
76+
state = .failed(validationError)
77+
return
78+
}
79+
80+
do {
81+
let bookmarkData = try url.bookmarkData(
82+
options: [.withSecurityScope],
83+
includingResourceValuesForKeys: nil,
84+
relativeTo: nil
85+
)
86+
UserDefaults.standard.set(bookmarkData, forKey: Self.bookmarkDataKey)
87+
88+
guard url.startAccessingSecurityScopedResource() else {
89+
state = .failed("Could not access the selected file. Please try again.")
90+
return
91+
}
92+
93+
accessedURL = url
94+
state = .ready(url)
95+
} catch {
96+
state = .failed("Failed to save file access: \(error.localizedDescription)")
97+
}
98+
}
99+
100+
private func validateBookmarksPlist(at url: URL) -> String? {
101+
guard let data = try? Data(contentsOf: url) else {
102+
return "Could not read the selected file."
103+
}
104+
105+
var format = PropertyListSerialization.PropertyListFormat.binary
106+
guard let plist = try? PropertyListSerialization.propertyList(
107+
from: data, options: [], format: &format
108+
) as? [String: Any] else {
109+
return "The selected file is not a valid property list."
110+
}
111+
112+
guard containsReadingList(in: plist) else {
113+
return "The selected file does not appear to be Safari's Bookmarks.plist — no reading list data was found."
114+
}
115+
116+
return nil
117+
}
118+
119+
private func containsReadingList(in node: [String: Any]) -> Bool {
120+
if let title = node["Title"] as? String, title == "com.apple.ReadingList" {
121+
return true
122+
}
123+
if let children = node["Children"] as? [[String: Any]] {
124+
return children.contains(where: containsReadingList)
125+
}
126+
return false
127+
}
128+
129+
deinit {
130+
if let url = accessedURL {
131+
url.stopAccessingSecurityScopedResource()
132+
}
133+
}
134+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import SwiftUI
2+
3+
struct BookmarkAccessView: View {
4+
@ObservedObject var accessManager: BookmarkAccessManager
5+
6+
var body: some View {
7+
VStack(spacing: 20) {
8+
Image(systemName: "book.closed.fill")
9+
.font(.system(size: 48))
10+
.foregroundStyle(.secondary)
11+
12+
Text("Access to Safari Reading List")
13+
.font(.title2.weight(.semibold))
14+
15+
Text("This app needs access to your Safari Bookmarks.plist file to display your reading list. Select the file once and you won't be asked again.")
16+
.multilineTextAlignment(.center)
17+
.foregroundStyle(.secondary)
18+
.frame(maxWidth: 400)
19+
20+
Button("Select Bookmarks.plist\u{2026}") {
21+
accessManager.promptUserToSelectFile()
22+
}
23+
.buttonStyle(.borderedProminent)
24+
.controlSize(.large)
25+
26+
if case let .failed(message) = accessManager.state {
27+
Text(message)
28+
.foregroundStyle(.red)
29+
.font(.callout)
30+
.multilineTextAlignment(.center)
31+
.frame(maxWidth: 400)
32+
}
33+
34+
Text("The file is usually located at:\n~/Library/Safari/Bookmarks.plist")
35+
.font(.callout)
36+
.foregroundStyle(.tertiary)
37+
.multilineTextAlignment(.center)
38+
}
39+
.padding(40)
40+
.frame(maxWidth: .infinity, maxHeight: .infinity)
41+
}
42+
}

Sources/ReadLater/ReadLaterApp.swift

Lines changed: 80 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,24 @@ import SwiftUI
44
@main
55
struct ReadLaterApp: App {
66
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
7-
@StateObject private var smartFolderStore: SmartFolderStore
8-
@StateObject private var viewModel: ReadingListViewModel
7+
@StateObject private var accessManager = BookmarkAccessManager()
8+
9+
private let isDemoMode = isDemoDataModeEnabled
910

1011
init() {
1112
FaviconPipelineConfiguration.configureSharedPipeline()
12-
13-
let store = SmartFolderStore()
14-
_smartFolderStore = StateObject(wrappedValue: store)
15-
let demoItems = Self.isDemoDataModeEnabled ? DemoReadingListData.makeItems() : nil
16-
_viewModel = StateObject(
17-
wrappedValue: ReadingListViewModel(
18-
smartFolderStore: store,
19-
demoItems: demoItems
20-
)
21-
)
2213
}
2314

2415
var body: some Scene {
2516
WindowGroup {
26-
ContentView(viewModel: viewModel, smartFolderStore: smartFolderStore)
27-
.task {
28-
viewModel.reload()
29-
}
17+
if isDemoMode {
18+
DemoContentWrapper()
19+
} else {
20+
accessGatedView
21+
.task {
22+
accessManager.resolveAccess()
23+
}
24+
}
3025
}
3126
.defaultSize(width: 1100, height: 720)
3227
.windowResizability(.contentMinSize)
@@ -41,21 +36,81 @@ struct ReadLaterApp: App {
4136
}
4237
}
4338

44-
private static var isDemoDataModeEnabled: Bool {
45-
let arguments = ProcessInfo.processInfo.arguments
46-
if arguments.contains("--demo-data") {
47-
return true
39+
@ViewBuilder
40+
private var accessGatedView: some View {
41+
switch accessManager.state {
42+
case .checking:
43+
ProgressView("Checking access\u{2026}")
44+
.frame(maxWidth: .infinity, maxHeight: .infinity)
45+
case .needsPermission, .failed:
46+
BookmarkAccessView(accessManager: accessManager)
47+
case let .ready(url):
48+
MainContentWrapper(bookmarksPlistURL: url)
4849
}
50+
}
51+
}
4952

50-
let environment = ProcessInfo.processInfo.environment
51-
guard let rawFlag = environment["READING_LIST_DEMO"]?.lowercased() else {
52-
return false
53-
}
53+
private struct MainContentWrapper: View {
54+
let bookmarksPlistURL: URL
55+
56+
@StateObject private var smartFolderStore = SmartFolderStore()
57+
@StateObject private var viewModel: ReadingListViewModel
5458

55-
return ["1", "true", "yes", "on"].contains(rawFlag)
59+
init(bookmarksPlistURL: URL) {
60+
self.bookmarksPlistURL = bookmarksPlistURL
61+
let store = SmartFolderStore()
62+
_smartFolderStore = StateObject(wrappedValue: store)
63+
let service = SafariReadingListService(bookmarksPlistURL: bookmarksPlistURL)
64+
_viewModel = StateObject(
65+
wrappedValue: ReadingListViewModel(service: service, smartFolderStore: store)
66+
)
67+
}
68+
69+
var body: some View {
70+
ContentView(viewModel: viewModel, smartFolderStore: smartFolderStore)
71+
.task {
72+
viewModel.reload()
73+
}
5674
}
5775
}
5876

77+
private struct DemoContentWrapper: View {
78+
@StateObject private var smartFolderStore: SmartFolderStore
79+
@StateObject private var viewModel: ReadingListViewModel
80+
81+
init() {
82+
let store = SmartFolderStore()
83+
_smartFolderStore = StateObject(wrappedValue: store)
84+
_viewModel = StateObject(
85+
wrappedValue: ReadingListViewModel(
86+
smartFolderStore: store,
87+
demoItems: DemoReadingListData.makeItems()
88+
)
89+
)
90+
}
91+
92+
var body: some View {
93+
ContentView(viewModel: viewModel, smartFolderStore: smartFolderStore)
94+
.task {
95+
viewModel.reload()
96+
}
97+
}
98+
}
99+
100+
private let isDemoDataModeEnabled: Bool = {
101+
let arguments = ProcessInfo.processInfo.arguments
102+
if arguments.contains("--demo-data") {
103+
return true
104+
}
105+
106+
let environment = ProcessInfo.processInfo.environment
107+
guard let rawFlag = environment["READING_LIST_DEMO"]?.lowercased() else {
108+
return false
109+
}
110+
111+
return ["1", "true", "yes", "on"].contains(rawFlag)
112+
}()
113+
59114
final class AppDelegate: NSObject, NSApplicationDelegate {
60115
func applicationDidFinishLaunching(_: Notification) {
61116
NSApp.setActivationPolicy(.regular)

Sources/ReadLater/ReadingListViewModel.swift

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,17 @@ final class ReadingListViewModel: ObservableObject {
4949
private let smartFolderStore: SmartFolderStore
5050
private let demoItems: [ReadingListItem]?
5151

52-
init(
53-
service: SafariReadingListService = SafariReadingListService(),
54-
smartFolderStore: SmartFolderStore,
55-
demoItems: [ReadingListItem]? = nil
56-
) {
52+
init(service: SafariReadingListService, smartFolderStore: SmartFolderStore) {
5753
self.service = service
5854
self.smartFolderStore = smartFolderStore
55+
demoItems = nil
56+
}
57+
58+
init(smartFolderStore: SmartFolderStore, demoItems: [ReadingListItem]) {
59+
let dummyURL = FileManager.default.homeDirectoryForCurrentUser
60+
.appending(path: "Library/Safari/Bookmarks.plist", directoryHint: .notDirectory)
61+
service = SafariReadingListService(bookmarksPlistURL: dummyURL)
62+
self.smartFolderStore = smartFolderStore
5963
self.demoItems = demoItems
6064
}
6165

Sources/ReadLater/SafariReadingListService.swift

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,7 @@ enum ReadingListWriteError: LocalizedError {
2828
struct SafariReadingListService: Sendable {
2929
let bookmarksPlistURL: URL
3030

31-
init(
32-
bookmarksPlistURL: URL = FileManager.default.homeDirectoryForCurrentUser
33-
.appending(path: "Library/Safari/Bookmarks.plist", directoryHint: .notDirectory)
34-
) {
31+
init(bookmarksPlistURL: URL) {
3532
self.bookmarksPlistURL = bookmarksPlistURL
3633
}
3734

0 commit comments

Comments
 (0)