diff --git a/cull/CullApp.swift b/cull/CullApp.swift index 4dd8881..3c30d4a 100644 --- a/cull/CullApp.swift +++ b/cull/CullApp.swift @@ -86,8 +86,7 @@ struct CullApp: App { Divider() Button("Close Folder") { - session.sourceFolder = nil - session.groups = [] + session.closeSourceFolder() thumbnailCache.clearCache() } .keyboardShortcut("w") diff --git a/cull/Models/CullSession.swift b/cull/Models/CullSession.swift index 8ba8e5a..5e6bfa2 100644 --- a/cull/Models/CullSession.swift +++ b/cull/Models/CullSession.swift @@ -8,7 +8,17 @@ final class CullSession { get { UserDefaults.standard.object(forKey: "autoWriteXMP") as? Bool ?? true } set { UserDefaults.standard.set(newValue, forKey: "autoWriteXMP") } } - var sourceFolder: URL? + var sourceFolder: URL? { + didSet { + // Stop access on old folder + if let oldValue, sourceFolderAccessActive { + oldValue.stopAccessingSecurityScopedResource() + sourceFolderAccessActive = false + } + } + } + /// Whether we currently hold security-scoped access on sourceFolder + private var sourceFolderAccessActive = false var groups: [PhotoGroup] = [] var selectedGroupIndex: Int = 0 var selectedPhotoIndex: Int = 0 @@ -316,6 +326,25 @@ final class CullSession { zoomFaceIndex = nil } + // MARK: - Security-scoped folder access + + /// Open a folder with security-scoped access. Call this instead of setting sourceFolder directly. + func openSourceFolder(_ url: URL) { + _ = url.startAccessingSecurityScopedResource() + sourceFolderAccessActive = true + sourceFolder = url + } + + /// Close the current folder, releasing security-scoped access. + func closeSourceFolder() { + if let folder = sourceFolder, sourceFolderAccessActive { + folder.stopAccessingSecurityScopedResource() + sourceFolderAccessActive = false + } + sourceFolder = nil + groups = [] + } + // MARK: - Workspace persistence /// Debounced auto-save — coalesces rapid changes into a single write diff --git a/cull/Views/ContentView.swift b/cull/Views/ContentView.swift index 4fa892b..8543e20 100644 --- a/cull/Views/ContentView.swift +++ b/cull/Views/ContentView.swift @@ -22,7 +22,7 @@ struct ContentView: View { Text("No supported photos found") .font(.title3) .foregroundStyle(.secondary) - Button("Choose Another Folder") { session.sourceFolder = nil } + Button("Choose Another Folder") { session.closeSourceFolder() } } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { @@ -48,7 +48,7 @@ struct ContentView: View { @MainActor private func startImport(_ url: URL) { - session.sourceFolder = url + session.openSourceFolder(url) session.isImporting = true session.importProgress = 0.02 cache.clearCache() @@ -227,7 +227,7 @@ struct ContentView: View { } } catch { await MainActor.run { - s.sourceFolder = nil + s.closeSourceFolder() s.isImporting = false } } @@ -470,7 +470,7 @@ struct ContentView: View { } ToolbarItem(placement: .automatic) { - Button { session.sourceFolder = nil } label: { + Button { session.closeSourceFolder() } label: { Image(systemName: "folder") } .help("Open Folder") diff --git a/cull/Views/ExportSheet.swift b/cull/Views/ExportSheet.swift index 6be25ce..41f0107 100644 --- a/cull/Views/ExportSheet.swift +++ b/cull/Views/ExportSheet.swift @@ -121,12 +121,11 @@ struct ExportSheet: View { let sourceFolder = session.sourceFolder Task { - // Access security-scoped resources + // Access security-scoped resource for destination + // (source folder access is held by CullSession for its lifetime) let destAccess = destination.startAccessingSecurityScopedResource() - let srcAccess = sourceFolder?.startAccessingSecurityScopedResource() ?? false defer { if destAccess { destination.stopAccessingSecurityScopedResource() } - if srcAccess { sourceFolder?.stopAccessingSecurityScopedResource() } } let exportResult = await PhotoExporter.export(