From d7e2b30b5571ddbee1468239cca5c83f7bccdfcd Mon Sep 17 00:00:00 2001 From: Mariano Date: Mon, 2 Mar 2026 15:42:08 -0300 Subject: [PATCH] Add Security-Scoped Bookmarks for local file access --- QuickMD/CHANGELOG.md | 5 + QuickMD/QuickMD.xcodeproj/project.pbxproj | 4 + QuickMD/QuickMD/QuickMD.entitlements | 2 + QuickMD/QuickMD/SandboxAccessManager.swift | 132 +++++++++++++++++++++ QuickMD/QuickMD/Views/ImageBlockView.swift | 65 +++++++++- 5 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 QuickMD/QuickMD/SandboxAccessManager.swift diff --git a/QuickMD/CHANGELOG.md b/QuickMD/CHANGELOG.md index 9c4248b..bd9ac3b 100644 --- a/QuickMD/CHANGELOG.md +++ b/QuickMD/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to QuickMD will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.4.0] - 2026-03-02 + +### Added +- **Folder Access for Local Files:** QuickMD now prompts for folder access when opening documents that reference local images or linked files. Access is persisted via Security-Scoped Bookmarks so the prompt only appears once per folder. Fully App Store compliant — no temporary sandbox exceptions required. + ## [1.3.2] - 2026-02-24 ### Added diff --git a/QuickMD/QuickMD.xcodeproj/project.pbxproj b/QuickMD/QuickMD.xcodeproj/project.pbxproj index b9c3a9d..2399696 100644 --- a/QuickMD/QuickMD.xcodeproj/project.pbxproj +++ b/QuickMD/QuickMD.xcodeproj/project.pbxproj @@ -25,6 +25,7 @@ A1B2C3D401000010 /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000014 /* SearchBar.swift */; }; A1B2C3D401000011 /* TableOfContentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000015 /* TableOfContentsView.swift */; }; A1B2C3D401000012 /* ThemePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000016 /* ThemePickerView.swift */; }; + A1B2C3D401000013 /* SandboxAccessManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000017 /* SandboxAccessManager.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -50,6 +51,7 @@ A1B2C3D400000014 /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = ""; }; A1B2C3D400000015 /* TableOfContentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableOfContentsView.swift; sourceTree = ""; }; A1B2C3D400000016 /* ThemePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePickerView.swift; sourceTree = ""; }; + A1B2C3D400000017 /* SandboxAccessManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxAccessManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +87,7 @@ A1B2C3D400000009 /* TipJarManager.swift */, A1B2C3D40000000A /* TipJarView.swift */, A1B2C3D40000000C /* MarkdownExport.swift */, + A1B2C3D400000017 /* SandboxAccessManager.swift */, A1B2C3D40000000B /* TipJarProducts.storekit */, A1B2C3D400000005 /* Assets.xcassets */, A1B2C3D400000006 /* QuickMD.entitlements */, @@ -201,6 +204,7 @@ A1B2C3D401000010 /* SearchBar.swift in Sources */, A1B2C3D401000011 /* TableOfContentsView.swift in Sources */, A1B2C3D401000012 /* ThemePickerView.swift in Sources */, + A1B2C3D401000013 /* SandboxAccessManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/QuickMD/QuickMD/QuickMD.entitlements b/QuickMD/QuickMD/QuickMD.entitlements index 637aa6f..2a8aabd 100644 --- a/QuickMD/QuickMD/QuickMD.entitlements +++ b/QuickMD/QuickMD/QuickMD.entitlements @@ -10,5 +10,7 @@ com.apple.security.print + com.apple.security.files.bookmarks.app-scope + diff --git a/QuickMD/QuickMD/SandboxAccessManager.swift b/QuickMD/QuickMD/SandboxAccessManager.swift new file mode 100644 index 0000000..6d10df5 --- /dev/null +++ b/QuickMD/QuickMD/SandboxAccessManager.swift @@ -0,0 +1,132 @@ +import Foundation +import AppKit + +// MARK: - Sandbox Access Manager + +/// Manages Security-Scoped Bookmarks for granting read access to directories +/// containing local resources (images, linked files) referenced by markdown documents. +/// +/// When a user opens a `.md` file, the sandbox only grants access to that specific file. +/// This manager prompts the user (once per folder) via `NSOpenPanel` to grant access +/// to the parent directory, then persists the bookmark so future launches restore +/// access silently. +@MainActor +final class SandboxAccessManager { + static let shared = SandboxAccessManager() + + /// UserDefaults key prefix for stored bookmarks + private static let bookmarkKeyPrefix = "SandboxBookmark_" + + /// Directories currently being accessed (need to stop on cleanup) + private var activeAccessURLs: [URL] = [] + + private init() {} + + // MARK: - Public API + + /// Try to restore a previously saved bookmark for the given directory. + /// Returns `true` if access was successfully restored. + @discardableResult + func restoreAccess(for directoryURL: URL) -> Bool { + let key = Self.bookmarkKey(for: directoryURL) + guard let bookmarkData = UserDefaults.standard.data(forKey: key) else { + return false + } + + do { + var isStale = false + let url = try URL( + resolvingBookmarkData: bookmarkData, + options: .withSecurityScope, + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + + if isStale { + // Bookmark is stale — re-save it + if let newData = try? url.bookmarkData(options: .withSecurityScope) { + UserDefaults.standard.set(newData, forKey: key) + } + } + + if url.startAccessingSecurityScopedResource() { + activeAccessURLs.append(url) + return true + } + } catch { + // Bookmark resolution failed — remove the stale entry + UserDefaults.standard.removeObject(forKey: key) + } + + return false + } + + /// Ensure we have access to the parent directory of a file URL. + /// If a bookmark exists, restores it silently. Otherwise, prompts the user. + /// Returns `true` if access is available after this call. + @discardableResult + func ensureAccess(forParentOf fileURL: URL) -> Bool { + let parentDir = fileURL.deletingLastPathComponent() + + // Already have active access? + if activeAccessURLs.contains(where: { $0.path == parentDir.path }) { + return true + } + + // Try restoring a saved bookmark + if restoreAccess(for: parentDir) { + return true + } + + // No bookmark — prompt the user + return promptForAccess(to: parentDir) + } + + // MARK: - Private + + /// Present an NSOpenPanel pre-navigated to the target directory. + /// The user must click "Grant Access" to allow the app to read from that folder. + private func promptForAccess(to directoryURL: URL) -> Bool { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.canCreateDirectories = false + panel.directoryURL = directoryURL + panel.prompt = "Grant Access" + panel.message = "QuickMD needs access to this folder to display local images and open linked files. This is a one-time prompt per folder." + + guard panel.runModal() == .OK, let selectedURL = panel.url else { + return false + } + + // Save the bookmark + do { + let bookmarkData = try selectedURL.bookmarkData(options: .withSecurityScope) + let key = Self.bookmarkKey(for: selectedURL) + UserDefaults.standard.set(bookmarkData, forKey: key) + + if selectedURL.startAccessingSecurityScopedResource() { + activeAccessURLs.append(selectedURL) + return true + } + } catch { + // Failed to create bookmark — access denied + } + + return false + } + + /// Stop accessing all security-scoped resources. + func stopAllAccess() { + for url in activeAccessURLs { + url.stopAccessingSecurityScopedResource() + } + activeAccessURLs.removeAll() + } + + /// Generate a UserDefaults key for a directory path. + private static func bookmarkKey(for directoryURL: URL) -> String { + return bookmarkKeyPrefix + directoryURL.standardizedFileURL.path + } +} diff --git a/QuickMD/QuickMD/Views/ImageBlockView.swift b/QuickMD/QuickMD/Views/ImageBlockView.swift index 19bb2d3..ad41a25 100644 --- a/QuickMD/QuickMD/Views/ImageBlockView.swift +++ b/QuickMD/QuickMD/Views/ImageBlockView.swift @@ -24,6 +24,8 @@ struct ImageBlockView: View { /// Cached downsampled image for local files @State private var localImage: NSImage? @State private var isLoadingLocal = false + @State private var accessDenied = false + @State private var fileNotFound = false var body: some View { Group { @@ -77,6 +79,10 @@ struct ImageBlockView: View { } else if isLoadingLocal { ProgressView() .frame(height: 100) + } else if accessDenied { + accessDeniedView(for: fileURL) + } else if fileNotFound { + imageErrorView } else { Color.clear .frame(height: 100) @@ -86,21 +92,72 @@ struct ImageBlockView: View { } } - /// Load and downsample local image off the main thread + /// Placeholder shown when the user denied folder access for a local image. + private func accessDeniedView(for fileURL: URL) -> some View { + VStack(spacing: 8) { + Image(systemName: "lock.shield") + .font(.system(size: 24)) + .foregroundColor(.secondary) + Text("Can\u{2019}t load \u{201C}\(fileURL.lastPathComponent)\u{201D}") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + Button("Grant Folder Access") { + Task { await retryWithAccess(for: fileURL) } + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .frame(maxWidth: Self.maxDisplayWidth) + .padding(.vertical, 12) + } + + /// Load and downsample local image off the main thread. + /// If loading fails (e.g. sandbox denies access), prompts the user + /// to grant folder access and retries once. private func loadLocalImage(from url: URL) async { isLoadingLocal = true defer { isLoadingLocal = false } - // Load on background thread - let image = await Task.detached(priority: .userInitiated) { + // First attempt — try loading directly + let firstAttempt = await Task.detached(priority: .userInitiated) { + Self.loadDownsampledImage(from: url, maxPixelSize: Self.maxPixelDimension) + }.value + + if let image = firstAttempt { + await MainActor.run { self.localImage = image } + return + } + + // Check if the file actually exists before assuming sandbox denial. + // If it simply doesn't exist, there's nothing the user can do. + let fileExists = FileManager.default.fileExists(atPath: url.path) + guard fileExists else { + fileNotFound = true + return + } + + // File exists but couldn't load — likely sandbox. Request access and retry. + let granted = await SandboxAccessManager.shared.ensureAccess(forParentOf: url) + guard granted else { + accessDenied = true + return + } + + let retryAttempt = await Task.detached(priority: .userInitiated) { Self.loadDownsampledImage(from: url, maxPixelSize: Self.maxPixelDimension) }.value await MainActor.run { - self.localImage = image + self.localImage = retryAttempt } } + /// Retry loading after the user clicks "Grant Folder Access". + private func retryWithAccess(for url: URL) async { + accessDenied = false + await loadLocalImage(from: url) + } + /// Efficiently load and downsample image using ImageIO /// This prevents loading huge images (e.g., 4K) at full resolution private static func loadDownsampledImage(from url: URL, maxPixelSize: Int) -> NSImage? {