Skip to content
Open
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
5 changes: 5 additions & 0 deletions QuickMD/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions QuickMD/QuickMD.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -50,6 +51,7 @@
A1B2C3D400000014 /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = "<group>"; };
A1B2C3D400000015 /* TableOfContentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableOfContentsView.swift; sourceTree = "<group>"; };
A1B2C3D400000016 /* ThemePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePickerView.swift; sourceTree = "<group>"; };
A1B2C3D400000017 /* SandboxAccessManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxAccessManager.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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;
};
Expand Down
2 changes: 2 additions & 0 deletions QuickMD/QuickMD/QuickMD.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@
<true/>
<key>com.apple.security.print</key>
<true/>
<key>com.apple.security.files.bookmarks.app-scope</key>
<true/>
</dict>
</plist>
132 changes: 132 additions & 0 deletions QuickMD/QuickMD/SandboxAccessManager.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
65 changes: 61 additions & 4 deletions QuickMD/QuickMD/Views/ImageBlockView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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? {
Expand Down