Highlight
Apple 在 macOS Monterey 为 FileProvider 加入基于 APFS dataless 文件的云同步能力,让云存储服务可以用用户空间 extension 接入 Finder、按需下载文件、同步本地和远程变更,并替代依赖 FUSE 或 KAUTH 的内核扩展方案。
核心内容
云盘 App 在 macOS 上最难做的一件事,是让云端文件像本地文件一样工作。
用户希望在 Finder 侧边栏看到自己的云盘。目录要能打开。文件要能被 cat、TextEdit、Photoshop 这类普通进程读取。磁盘空间不足时,系统还要能安全地回收本地副本。
过去,很多云存储服务会拦截文件系统调用。常见方案是 FUSE 或 KAUTH。它们靠近内核,能做到按需下载,但代价很高:安全边界复杂,调试困难,macOS 也在弃用内核扩展。
Apple 在这场 session 里给出的替代方案,是 macOS 上的 FileProvider 框架(File Provider framework)。开发者实现一个 App Extension。系统负责把它接入 Finder 和文件系统。
这个 extension 只处理三类事情:上传、下载、告诉系统远端发生了什么变化。系统负责本地文件变化检测、Finder 状态显示、安全保存、磁盘空间回收,以及文件协调。
从空目录开始
用户登录云盘账号后,App 创建一个 domain(域)。domain 代表用户可以访问的一棵云端文件树。系统把它放进 Finder 侧边栏,并在文件系统里创建根目录。
这个根目录一开始没有真实文件内容。它是 APFS 提供的 dataless directory(无数据目录)。用户可以进入目录,看到文件名,但内容还没有下载到本机。
当进程读取 dataless file(无数据文件)时,内核暂停这次读取。系统调用 extension 的 fetchContents。extension 下载文件,交回一个本地 URL。系统把内容填进原来的文件,再恢复被暂停的读取。
远程变化由 extension 主动通知
云端有人新建、删除或修改文件时,本机不会自动知道。服务端可以向 Mac 发 push notification。App 收到通知后,告诉系统 .workingSet 有变化。
系统随后调用 working set 的 enumerator。extension 通过 syncAnchor(同步锚点)只返回上次之后的变化。系统再异步更新用户可见的文件。
这里的关键是 APFS compare-and-swap。系统更新本地文件时,会避免覆盖尚未上传的本地修改。它还会配合 file coordination(文件协调)和 advisory locking(建议锁),减少与其他 App 的冲突。
本地变化由系统归并后交给 extension
用户在本地保存文件时,系统检测变化,并调用 modifyItem、createItem 或 deleteItem。extension 不需要处理每一个底层写入事件。
系统会把低层事件归并成适合同步的操作。例如,许多 App 使用 safe save(安全保存):先写临时文件,再替换原文件。系统会识别这种模式,并把 item identifier 映射到新的 file ID。
如果文件内容变了,系统会把变更后文件的 clone 交给 extension。这样,即使用户继续编辑,extension 也能上传一个一致的版本。
磁盘空间管理由系统兜底
本地文件上传完成后,系统知道它可以重新下载。磁盘空间紧张时,系统可以自动驱逐这类文件,把它们重新变成 dataless 文件。
这个过程不会调用 extension。系统会选择最少数量、最近最少使用的文件,为录制视频或下载系统更新腾出空间。
开发者仍然可以通过 FileProvider 的其他方法主动触发或阻止 eviction(驱逐),但紧急磁盘回收由系统处理。
详细内容
1. 创建 domain,把云盘放进 Finder
(12:31)FileProvider 的入口是 domain。演讲中说,domain 通常对应云服务上的一次登录会话。它有唯一 identifier。创建后通过 manager 添加,Finder 侧边栏就会出现入口。
import FileProvider
func addFruitBasketDomain() {
let domain = NSFileProviderDomain(
identifier: NSFileProviderDomainIdentifier("fruitbasket"),
displayName: "FruitBasket"
)
NSFileProviderManager.add(domain) { error in
if let error = error {
NSLog("Failed to add FileProvider domain: \(error)")
return
}
NSLog("FileProvider domain added")
}
}
关键点:
import FileProvider引入 FileProvider 框架。NSFileProviderDomain表示一棵可同步的文件树。identifier是 domain 的唯一标识,通常和用户登录会话绑定。displayName是 Finder 侧边栏显示给用户看的名字。NSFileProviderManager.add把 domain 注册给系统。- completion handler 用来接收注册失败的错误。
如果用户退出账号,可以移除 domain。演讲提到,这在开发测试阶段也有用,因为你会反复清理旧的 Finder 入口。
import FileProvider
func removeFruitBasketDomain(_ domain: NSFileProviderDomain) {
NSFileProviderManager.remove(domain) { error in
if let error = error {
NSLog("Failed to remove FileProvider domain: \(error)")
return
}
NSLog("FileProvider domain removed")
}
}
关键点:
- 参数
domain是要移除的 FileProvider domain。 NSFileProviderManager.remove告诉系统删除这个入口。- 用户登出时调用它,可以让 Finder 不再显示该云盘。
- 开发时调用它,可以清掉测试 domain。
2. 枚举目录,让 dataless 文件先出现在本地
(04:26)用户打开目录时,内核检测到 readdir 调用并暂停。系统调用 extension 的 enumerator。extension 从服务端取元数据,然后把若干 item 返回给系统。
演讲强调,目录枚举是分页的。你可以先返回一部分 item。系统会从上一次停下的位置继续请求。
import FileProvider
final class FruitEnumerator: NSObject, NSFileProviderEnumerator {
func enumerateItems(
for observer: NSFileProviderEnumerationObserver,
startingAt page: NSFileProviderPage
) {
let items: [NSFileProviderItem] = loadMetadataPage(startingAt: page)
let nextPage: NSFileProviderPage? = nextPageAfter(page)
observer.didEnumerate(items)
observer.finishEnumerating(upTo: nextPage)
}
}
关键点:
FruitEnumerator实现NSFileProviderEnumerator。enumerateItems在系统需要目录内容时调用。observer是 extension 回复系统的对象。page表示这次枚举从哪一页开始。loadMetadataPage代表从服务端读取文件名、大小、版本等元数据。observer.didEnumerate(items)把本页 item 交给系统。observer.finishEnumerating(upTo:)结束本页,并告诉系统是否还有下一页。
这些 item 会变成 Finder 里可见的目录项。文件内容还没下载,所以它们仍然是 dataless 文件。
3. 读取 dataless 文件时下载内容
(03:36)当进程读取 dataless 文件,系统调用 fetchContents。extension 下载文件内容到磁盘上的某个位置,然后用 completion handler 把 URL 交给系统。
import FileProvider
final class FruitProvider: NSObject, NSFileProviderReplicatedExtension {
func fetchContents(
for itemIdentifier: NSFileProviderItemIdentifier,
version requestedVersion: NSFileProviderItemVersion?,
request: NSFileProviderRequest,
completionHandler: @escaping (URL?, NSFileProviderItem?, Error?) -> Void
) -> Progress {
let progress = Progress(totalUnitCount: 100)
downloadFile(for: itemIdentifier, version: requestedVersion) { localURL, item, error in
if let error = error {
completionHandler(nil, nil, error)
return
}
completionHandler(localURL, item, nil)
}
return progress
}
}
关键点:
FruitProvider实现NSFileProviderReplicatedExtension。itemIdentifier是系统要求下载的文件。requestedVersion为nil时,文档说明下载最新版本。request携带这次系统请求的信息。completionHandler的第一个参数是下载后的本地文件 URL。completionHandler的第二个参数是文件的最终 item 状态。- 返回
Progress,系统可以在 Finder 里显示下载进度。
演示里,讲者用 Terminal 执行 cat 读取一个 dataless 文件。Xcode 断点停在 fetchContents 时,cat 也被阻塞。Finder 里的云图标变成进度指示器。completion handler 调用后,系统把下载好的内容替换到用户可见文件里,cat 继续执行。
(11:58)同一个文件第二次执行 cat 时,不再触发断点。文件已经是普通本地文件,读取不会再调用 extension。
4. 用 working set 同步远程变化
(05:27)远程变化的入口是 .workingSet。服务端有变化时,App 通知系统。系统随后向 working set enumerator 请求自上次 syncAnchor 之后的变化。
import FileProvider
func notifyRemoteChanges() {
NSFileProviderManager.default.signalEnumerator(for: .workingSet) { error in
if let error = error {
NSLog("Failed to signal working set: \(error)")
return
}
NSLog("Working set signaled")
}
}
关键点:
.workingSet是演讲中提到的特殊 enumerator。signalEnumerator(for:)提醒系统:指定位置有内容变化。- completion handler 只表示 signal 操作完成,不表示系统已经拉取完变化。
- 远程 push notification 到达 Mac 后,可以调用这个方法。
working set enumerator 需要实现当前同步锚点,以及从某个锚点开始的变化枚举。
import FileProvider
final class WorkingSetEnumerator: NSObject, NSFileProviderEnumerator {
func currentSyncAnchor(
completionHandler: @escaping (NSFileProviderSyncAnchor?) -> Void
) {
completionHandler(loadCurrentServerChangeToken())
}
func enumerateChanges(
for observer: NSFileProviderChangeObserver,
from syncAnchor: NSFileProviderSyncAnchor
) {
let changes = loadServerChanges(after: syncAnchor)
observer.didUpdate(changes.updatedItems)
observer.didDeleteItems(withIdentifiers: changes.deletedItemIdentifiers)
observer.finishEnumeratingChanges(upTo: changes.newSyncAnchor, moreComing: false)
}
}
关键点:
currentSyncAnchor返回当前服务端变化游标。syncAnchor由 extension 定义,系统只负责保存上次枚举到的位置。enumerateChanges返回指定锚点之后的变化。observer.didUpdate把新增或修改的 item 返回给系统。observer.didDeleteItems把删除的 item identifier 返回给系统。finishEnumeratingChanges交回新的锚点,供系统下次使用。
演讲里说,系统随后会异步更新用户可见文件,并用 APFS compare-and-swap 避免丢失本地变化。
5. 上传本地创建、修改和删除
(06:50)本地修改走相反方向。系统检测本地 item 变化,然后调用 extension 的 modifyItem。参数里包含精确的 changed fields(变化字段)。
(14:37)演讲建议最后实现 sync up,并展示了 create flow。系统把新 item、关注字段和内容 URL 交给 extension。extension 上传到服务器,再用 completion handler 返回远端确认后的 item。
import FileProvider
final class UploadingProvider: NSObject, NSFileProviderReplicatedExtension {
func createItem(
basedOn itemTemplate: NSFileProviderItem,
fields: NSFileProviderItemFields,
contents url: URL?,
options: NSFileProviderCreateItemOptions,
request: NSFileProviderRequest,
completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void
) -> Progress {
let progress = Progress(totalUnitCount: 100)
uploadNewItem(itemTemplate, changedFields: fields, contents: url) { remoteItem, remainingFields, error in
if let error = error {
completionHandler(nil, fields, false, error)
return
}
completionHandler(remoteItem, remainingFields, false, nil)
}
return progress
}
}
关键点:
itemTemplate是系统要求创建的 item。fields描述哪些字段需要处理,例如内容或扩展属性。url是文件内容位置;文件夹和符号链接可能没有内容。uploadNewItem代表把本地新 item 上传到服务端。remoteItem是服务端接受后的最终 item 状态。- completion handler 确认系统已经把这次变更交给 extension。
本地修改同理。系统调用 modifyItem,并在内容变化时提供一个文件 clone,让上传过程拿到稳定版本。
import FileProvider
final class ModifyingProvider: NSObject, NSFileProviderReplicatedExtension {
func modifyItem(
_ item: NSFileProviderItem,
baseVersion version: NSFileProviderItemVersion,
changedFields: NSFileProviderItemFields,
contents newContents: URL?,
options: NSFileProviderModifyItemOptions,
request: NSFileProviderRequest,
completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void
) -> Progress {
let progress = Progress(totalUnitCount: 100)
uploadModifiedItem(item, baseVersion: version, changedFields: changedFields, contents: newContents) { finalItem, remainingFields, error in
if let error = error {
completionHandler(nil, changedFields, false, error)
return
}
completionHandler(finalItem, remainingFields, false, nil)
}
return progress
}
}
关键点:
item是本地修改后的 item。version是这次修改基于的版本。changedFields告诉 extension 哪些字段发生了变化。newContents是系统提供的稳定内容 URL。finalItem是服务端处理完成后的最终状态。- 如果服务端检测到冲突,可以通过最终 item 状态让本地文件反映云端真相。
6. Finder 集成:图标装饰、菜单和预检提醒
(15:59)FileProvider 还提供可选的系统集成点。
图标装饰可以给 Finder item 加 badge、给文件夹做 emboss,或者显示共享状态。开发者通过 App 中声明的 UTType 提供自定义 artwork。
<key>NSExtensionFileProviderDecorations</key>
<array>
<dict>
<key>Identifier</key>
<string>com.example.fruitbasket.shared</string>
<key>BadgeImageType</key>
<string>com.example.fruitbasket.shared-badge</string>
<key>Category</key>
<string>Badge</string>
</dict>
</array>
关键点:
NSExtensionFileProviderDecorations在 extension 的Info.plist中声明装饰。Identifier是装饰的标识。BadgeImageType指向 App 中声明的 UTType artwork。Category表示这类装饰用于 badge。
上下文菜单动作让用户在 Finder 右键菜单里执行自定义操作。演讲提到,动作有带 UI 和不带 UI 两种,适用文件由 Info.plist 中的 NSPredicate 决定。
<key>NSExtensionFileProviderActions</key>
<array>
<dict>
<key>Identifier</key>
<string>com.example.fruitbasket.share</string>
<key>ActivationRule</key>
<string>TRUEPREDICATE</string>
</dict>
</array>
关键点:
NSExtensionFileProviderActions声明 Finder 上下文菜单动作。Identifier区分不同动作。ActivationRule用 predicate 控制哪些文件显示这个动作。- 适合实现“共享链接”“查看云端版本”“在网页中打开”这类操作。
预检提醒用于在用户执行可能有副作用的操作前弹出警告。演讲说明,提醒 UI 和触发条件也通过 Info.plist 配置。
核心启发
-
做什么:给团队云盘做原生 Finder 入口。 为什么值得做:FileProvider domain 可以把每个登录账号映射成 Finder 侧边栏入口,用户不用进入你的 App 才能找文件。 怎么开始:先创建
NSFileProviderDomain,用NSFileProviderManager.add注册;再实现目录 enumerator,让根目录可以列出服务端文件夹。 -
做什么:做一个“只在打开时下载”的素材库。 为什么值得做:APFS dataless 文件让 Finder 先显示完整目录结构,内容读取时才触发
fetchContents,可以节省本地磁盘。 怎么开始:让 enumerator 返回远端素材元数据;在fetchContents(for:version:request:completionHandler:)中下载原图或视频到本地 URL。 -
做什么:给设计文件加 Finder 共享状态 badge。 为什么值得做:图标装饰可以直接在 Finder 中显示文件共享状态,用户不用打开网页查看权限。 怎么开始:在
Info.plist声明 FileProvider decorations,并用 item metadata 决定哪些文件应用共享 badge。 -
做什么:实现 Finder 右键“复制共享链接”。 为什么值得做:上下文菜单动作可以把云服务的高频操作放到文件旁边,减少用户在 Finder 和网页之间切换。 怎么开始:在
Info.plist声明 FileProvider action,用 predicate 限定普通文件或文件夹,再在 action handler 中请求服务端生成链接。 -
做什么:给大文件上传做安全的本地编辑同步。 为什么值得做:系统在内容变化时提供文件 clone,extension 可以上传稳定版本,不会被用户后续编辑打断。 怎么开始:实现
modifyItem(_:baseVersion:changedFields:contents:options:request:completionHandler:),只在changedFields包含内容变化时读取contentsURL 并上传。
关联 Session
- What’s new in CloudKit — CloudKit 可作为云端元数据、权限和用户数据的后端参考。
- Automate CloudKit tests with cktool and declarative schema — 用命令行测试云端 schema 和数据状态,适合同步服务开发流程。
- What’s new in AppKit — 了解 macOS App 与 Finder 体验相邻的系统界面更新。
- Build Mail app extensions — 学习 macOS App Extension 的构建方式和系统集成思路。
- Faster and simpler notarization for Mac apps — 带 FileProvider extension 的 Mac App 发布前需要完成 notarization。
评论
GitHub Issues · utterances