macOS安全书签(Security-Scoped Bookmark)
macOS安全书签(Security-Scoped Bookmark)

macOS安全书签(Security-Scoped Bookmark)

“安全书签”(Security-Scoped Bookmark)是 macOS 沙盒应用中的一种机制,允许 App 在用户授权之后,长期访问某个文件或目录,即使应用退出或重启后依然有效。

为什么需要安全书签?

当 App 启用了 App Sandbox(沙盒机制) 时:

App 默认无法访问用户文件系统中的任意文件。

即使用户通过 NSOpenPanel 选择了文件,也只能在当前会话中访问,应用关闭后权限就丢失。

示例:

struct ContentView: View {
    let nsURL = URL(fileURLWithPath: "/Users/fangjunyu.com/Wallpaper/wallhaven-6og5gx.jpeg")
    @State private var getURL: URL = URL(fileURLWithPath: "")
    var body: some View {
        ZStack {
            Color.clear
                .frame(width: 300,height:200)
            VStack {
                if let image = NSImage(contentsOf: nsURL) {
                    Image(nsImage: image)
                        .resizable()
                        .scaledToFit()
                        .frame(width: 300)
                } else {
                    Text("默认URL没有获取到图片")
                }
                if let imageF = NSImage(contentsOf: getURL) {
                    Image(nsImage: imageF )
                        .resizable()
                        .scaledToFit()
                        .frame(width: 300)
                } else {
                    Text("未使用 NSOpenPanel 获取到图片文件")
                }
                
                Button("按钮") {
                    let panel = NSOpenPanel()
                    panel.canChooseFiles = true
                    panel.canChooseDirectories = false
                    if panel.runModal() == .OK, let url = panel.url {
                        getURL = url
                    }
                }
            }
        }
    }
}

在这个代码示例中,分别通过nsURL和getURL显示两张图片,nsURL是固定的URL,getURL是通过NSOpenPanel获取的URL。

当打开应用时,没有显示任何图片。

当通过NSOpenPanel选择其他的图片时。

NSOpenPanel获取到图片的URL和权限,显示对应的图片。但是,因为nsURL是固定的URL,因为还没有固定URL的权限,所以无法显示。

当我们选择nsURL对应的图片时:

可以看到,nsURL和getURL都可以显示图片。这是因为,当我们通过NSOpenPanel获取固定URL的图片时,我们都能够获取到固定URL图片的权限,才可以显示。

否则,即使我们固定URL的路径是正确的,也会因为权限的问题导致无法获取URL对应的文件。

当我们退出应用,重新打开时,不会显示任何的图片。因为我们关闭应用后,获取的文件权限丢失,因此只能重新通过NSOpenPanel获取文件的权限。

解决方案:用安全书签(bookmark) 把用户授权的访问“记住”,下次可以还原访问权限。

安全书签是什么?

它是一个可保存的 Data 对象,用来记录访问权限信息。可以将它保存到用户的 UserDefaults 或数据库中。

工作流程概览

1、用户通过 NSOpenPanel 选择文件:系统临时授予权限。

2、创建安全书签(bookmarkData):使用 url.bookmarkData(options:) 创建。

3、将书签数据持久化:保存到磁盘、UserDefaults、数据库。

4、下次启动时读取书签并还原 URL:使用 URL(resolvingBookmarkData:) 恢复访问权限。

5、开启“访问作用域”:startAccessingSecurityScopedResource()。

6、使用文件操作。

7、完成后停止访问:stopAccessingSecurityScopedResource()。

创建和恢复安全书签

第一次用户选择文件并保存书签:

let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = false

if panel.runModal() == .OK, let url = panel.url {
    do {
        let bookmark = try url.bookmarkData(options: [.withSecurityScope], includingResourceValuesForKeys: nil, relativeTo: nil)
        UserDefaults.standard.set(bookmark, forKey: "MyFileBookmark")
        print("书签保存成功")
    } catch {
        print("书签创建失败: \(error)")
    }
}

2、之后恢复访问:

if let bookmark = UserDefaults.standard.data(forKey: "MyFileBookmark") {
    var isStale = false
    do {
        let url = try URL(resolvingBookmarkData: bookmark, options: [.withSecurityScope], relativeTo: nil, bookmarkDataIsStale: &isStale)

        if url.startAccessingSecurityScopedResource() {
            // ✅ 现在可以访问文件
            let content = try String(contentsOf: url)
            print("文件内容: \(content)")

            url.stopAccessingSecurityScopedResource()
        } else {
            print("无法访问资源")
        }
    } catch {
        print("解析书签失败: \(error)")
    }
}

代码解析

创建安全书签并保存

let bookmark = try url.bookmarkData(
    options: [.withSecurityScope],
    includingResourceValuesForKeys: nil,
    relativeTo: nil
)
UserDefaults.standard.set(bookmark, forKey: "MyFileBookmark")

1、url.bookmarkData(…)

url 是用户通过 NSOpenPanel 选中的文件路径。

bookmarkData 是一个可以序列化的二进制数据 (Data),它记录了这个文件的访问权限。

options: [.withSecurityScope]:

告诉系统创建一个具有“安全作用域”访问权限的书签,只有这样才能在 App 沙盒内持久访问该文件。

includingResourceValuesForKeys: nil:

可选项,表示是否同时缓存文件的一些元数据(比如文件大小、修改时间等),这里我们不需要,填 nil。

relativeTo: nil:

如果想创建相对路径书签,可设置一个 base URL;否则设为 nil 表示绝对路径。

2、UserDefaults.standard.set(bookmark, forKey: “MyFileBookmark”)

将上一步生成的 bookmarkData 存入用户默认设置中。

注意:bookmarkData 是 Data 类型,UserDefaults 支持存储它。

恢复安全书签并访问文件内容

if let bookmark = UserDefaults.standard.data(forKey: "MyFileBookmark") {
    var isStale = false
    do {
        let url = try URL(
            resolvingBookmarkData: bookmark,
            options: [.withSecurityScope],
            relativeTo: nil,
            bookmarkDataIsStale: &isStale
        )

1、UserDefaults.standard.data(forKey: …)

从之前保存的 UserDefaults 中读取书签数据。

2、URL(resolvingBookmarkData: …)

将 bookmarkData 反解析为一个 URL。

options: [.withSecurityScope] 表示我们打算恢复安全作用域权限。

bookmarkDataIsStale 是一个输出参数:

如果返回 true,说明书签“陈旧”,需要重新授权或重新创建书签。

var isStale = false

因此,当创建并绑定的isStale被系统修改为true时,表示书签已经失效。

3、获取权限

必须调用它,才能真正获得对该文件的读/写权限。

if url.startAccessingSecurityScopedResource() {

如果失败(返回 false),说明访问被拒绝或 URL 无效。

4、访问文件

let content = try String(contentsOf: url)

现在可以像访问 App 自己的文件一样,直接操作该 URL 对应的文件。

这里是读取文件内容并打印。

5、释放权限

url.stopAccessingSecurityScopedResource()

一定要调用 stopAccessing…() 来结束访问权限。

如果忘记释放,系统会记录访问泄漏,长时间后导致崩溃或拒绝访问。

注意事项

1、必须调用 startAccessingSecurityScopedResource(),否则无法访问资源。

2、用完调用 stopAccessingSecurityScopedResource(),释放权限。

3、支持访问文件夹,也可以创建目录书签。

4、可跨启动保留权限,书签保存后,可长期使用。

5、当调用stopAccessingSecurityScopedResource()释放权限后,url的权限就会失效,因此不要保存url,而是在获取权限后,通过url获取图片/文件的资源,然后再释放权限。

6、UserDefaults的键值存储知恩感保存一个书签(Data)。

相关文章

macOS文件选择对话框NSOpenPanel:https://fangjunyu.com/2025/06/27/macos%e6%96%87%e4%bb%b6%e9%80%89%e6%8b%a9%e5%af%b9%e8%af%9d%e6%a1%86nsopenpanel/

扩展知识

保存多个 URL 的书签数据

UserDefaults 的键值存储只能保存一个书签(Data)或一个简单数组。

可以将多个安全书签 Data 组织为字典或数组,并保存到 UserDefaults。以下是推荐的两种方案:

1、用 [String: Data] 字典保存多个书签
// 保存:每个文件 URL.path 作为 key,bookmarkData 作为 value
var bookmarks = UserDefaults.standard.dictionary(forKey: "Bookmarks") as? [String: Data] ?? [:]
let bookmark = try url.bookmarkData(options: [.withSecurityScope], includingResourceValuesForKeys: nil, relativeTo: nil)
bookmarks[url.path] = bookmark
UserDefaults.standard.set(bookmarks, forKey: "Bookmarks")

然后恢复访问:

if let bookmarks = UserDefaults.standard.dictionary(forKey: "Bookmarks") as? [String: Data] {
    for (path, data) in bookmarks {
        var isStale = false
        let url = try URL(resolvingBookmarkData: data, options: [.withSecurityScope], relativeTo: nil, bookmarkDataIsStale: &isStale)
        if url.startAccessingSecurityScopedResource() {
            // 使用 url
            url.stopAccessingSecurityScopedResource()
        }
    }}

2、用 [Data] 数组保存书签列表

// 添加书签
var bookmarks = UserDefaults.standard.array(forKey: "BookmarkArray") as? [Data] ?? []
let bookmark = try url.bookmarkData(options: [.withSecurityScope], includingResourceValuesForKeys: nil, relativeTo: nil)
bookmarks.append(bookmark)
UserDefaults.standard.set(bookmarks, forKey: "BookmarkArray")

恢复:

if let bookmarks = UserDefaults.standard.array(forKey: "BookmarkArray") as? [Data] {
    for bookmark in bookmarks {
        var isStale = false
        let url = try URL(resolvingBookmarkData: bookmark, options: [.withSecurityScope], relativeTo: nil, bookmarkDataIsStale: &isStale)
        if url.startAccessingSecurityScopedResource() {
            // 使用 url
            url.stopAccessingSecurityScopedResource()
        }
    }
}

推荐:使用 [String: Data] 字典,它有以下优点:

1、可快速查找已保存路径(去重)。

2、可以为每个图片保存书签、缩略图、文件名等元信息。

3、更适合绑定 SwiftUI 的视图列表。

完整代码

下面是通过NSOpenPanel获取多个图片并保存到安全书签中。

每次打开应用时,可以通过恢复按钮显示安全书签中的所有图片。

import SwiftUI
import AppKit

struct ContentView: View {
    @State private var tmpImages = [NSImage()]
    var body: some View {
        ZStack {
            Color.clear
                .frame(width: 300,height:200)
            VStack {
                ForEach(tmpImages,id: \.self) { item in
                    Image(nsImage: item)
                        .resizable()
                        .scaledToFit()
                        .frame(width: 300)
                }
                Button("获取图片按钮") {
                    let panel = NSOpenPanel()
                    panel.canChooseFiles = true
                    panel.canChooseDirectories = false
                    panel.allowsMultipleSelection = true
                    if panel.runModal() == .OK, let url = panel.url {
                        tmpImages = []
                        do {
                            var bookmarks:[Data] = []
                            for file in panel.urls {
                                let bookmark = try file.bookmarkData(options: [.withSecurityScope], includingResourceValuesForKeys: nil, relativeTo: nil)
                                print("bookmarks:\(bookmarks)")
                                bookmarks.append(bookmark)
                                print("当前添加的图片文件路径:\(file)")
                                if let selectedImg = NSImage(contentsOf: file) {
                                    tmpImages.append(selectedImg)
                                }
                            }
                            UserDefaults.standard.set(bookmarks, forKey: "BookmarkArray")
                            
                            print("书签保存成功")
                        } catch {
                            print("书签创建失败: \(error)")
                        }
                        
                    }
                }
                Button("恢复书签") {
                    if let bookmarks = UserDefaults.standard.array(forKey: "BookmarkArray") as? [Data] {
                        tmpImages = []
                        for bookmark in bookmarks {                            var isStale = false
                            do {
                                let url = try URL(resolvingBookmarkData: bookmark, options: [.withSecurityScope], relativeTo: nil, bookmarkDataIsStale: &isStale)
                                if url.startAccessingSecurityScopedResource() {
                                    print("获取书签保存的URL:\(url)")
                                    if let selectedImg = NSImage(contentsOf: url) {
                                        tmpImages.append(selectedImg)
                                    } else {
                                        print("获取书签URL失败")
                                    }
                                     url.stopAccessingSecurityScopedResource()
                                } else {
                                    print("无法访问资源")
                                }
                            } catch {
                                print("解析书签失败: \(error)")
                            }
                        }
                    }

                }
            }
        }
    }
}
   

如果您认为这篇文章给您带来了帮助,您可以在此通过支付宝或者微信打赏网站开发者。

欢迎加入我们的 微信交流群QQ交流群,交流更多精彩内容!
微信交流群二维码 QQ交流群二维码

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注