Add file provider for iOS

This commit is contained in:
世界
2025-12-19 14:35:17 +08:00
parent 675699d50d
commit 7bddeed04a
7 changed files with 623 additions and 2 deletions
@@ -0,0 +1,111 @@
import FileProvider
class FileProviderEnumerator: NSObject, NSFileProviderEnumerator {
private let directoryURL: URL?
private let workingDirectory: URL
private let isWorkingSet: Bool
init(url: URL, workingDirectory: URL) {
directoryURL = url
self.workingDirectory = workingDirectory
isWorkingSet = false
super.init()
}
init(workingSet: Bool, workingDirectory: URL) {
directoryURL = nil
self.workingDirectory = workingDirectory
isWorkingSet = workingSet
super.init()
}
func invalidate() {}
func enumerateItems(for observer: NSFileProviderEnumerationObserver, startingAt _: NSFileProviderPage) {
guard !isWorkingSet else {
var allItems: [FileProviderItem] = []
enumerateRecursively(at: workingDirectory, into: &allItems)
observer.didEnumerate(allItems)
observer.finishEnumerating(upTo: nil)
return
}
guard let url = directoryURL else {
observer.finishEnumerating(upTo: nil)
return
}
let contents = (try? FileManager.default.contentsOfDirectory(
at: url,
includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .creationDateKey, .contentModificationDateKey],
options: [.skipsHiddenFiles]
)) ?? []
let items: [FileProviderItem] = contents.map { childURL in
let identifier = itemIdentifier(for: childURL)
return FileProviderItem(url: childURL, identifier: identifier, workingDirectory: workingDirectory)
}
observer.didEnumerate(items)
observer.finishEnumerating(upTo: nil)
}
func enumerateChanges(for observer: NSFileProviderChangeObserver, from _: NSFileProviderSyncAnchor) {
var allItems: [FileProviderItem] = []
if isWorkingSet {
enumerateRecursively(at: workingDirectory, into: &allItems)
} else if let url = directoryURL {
let contents = (try? FileManager.default.contentsOfDirectory(
at: url,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]
)) ?? []
allItems = contents.map { childURL in
let identifier = itemIdentifier(for: childURL)
return FileProviderItem(url: childURL, identifier: identifier, workingDirectory: workingDirectory)
}
}
observer.didUpdate(allItems)
observer.finishEnumeratingChanges(upTo: currentSyncAnchor(), moreComing: false)
}
func currentSyncAnchor(completionHandler: @escaping (NSFileProviderSyncAnchor?) -> Void) {
completionHandler(currentSyncAnchor())
}
// MARK: - Helpers
private func currentSyncAnchor() -> NSFileProviderSyncAnchor {
let timestamp = Date().timeIntervalSince1970
return NSFileProviderSyncAnchor(withUnsafeBytes(of: timestamp) { Data($0) })
}
private func itemIdentifier(for url: URL) -> NSFileProviderItemIdentifier {
let relativePath = url.path.replacingOccurrences(of: workingDirectory.path + "/", with: "")
if relativePath == url.path || relativePath.isEmpty {
return .rootContainer
}
return NSFileProviderItemIdentifier(relativePath)
}
private func enumerateRecursively(at url: URL, into items: inout [FileProviderItem]) {
guard let contents = try? FileManager.default.contentsOfDirectory(
at: url,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
) else { return }
for childURL in contents {
let identifier = itemIdentifier(for: childURL)
items.append(FileProviderItem(url: childURL, identifier: identifier, workingDirectory: workingDirectory))
var isDir: ObjCBool = false
if FileManager.default.fileExists(atPath: childURL.path, isDirectory: &isDir), isDir.boolValue {
enumerateRecursively(at: childURL, into: &items)
}
}
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.io.nekohasekai.sfavt</string>
</array>
</dict>
</plist>
@@ -0,0 +1,213 @@
import FileProvider
import UniformTypeIdentifiers
class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension {
let domain: NSFileProviderDomain
private var workingDirectory: URL {
let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.io.nekohasekai.sfavt")!
return groupURL
.appendingPathComponent("Library", isDirectory: true)
.appendingPathComponent("Caches", isDirectory: true)
.appendingPathComponent("Working", isDirectory: true)
}
required init(domain: NSFileProviderDomain) {
self.domain = domain
super.init()
try? FileManager.default.createDirectory(at: workingDirectory, withIntermediateDirectories: true)
}
func invalidate() {}
// MARK: - NSFileProviderReplicatedExtension
func item(for identifier: NSFileProviderItemIdentifier,
request _: NSFileProviderRequest,
completionHandler: @escaping (NSFileProviderItem?, Error?) -> Void) -> Progress
{
let progress = Progress(totalUnitCount: 1)
if identifier == .rootContainer {
completionHandler(FileProviderItem(rootAt: workingDirectory), nil)
} else {
let url = fileURL(for: identifier)
if FileManager.default.fileExists(atPath: url.path) {
completionHandler(FileProviderItem(url: url, identifier: identifier, workingDirectory: workingDirectory), nil)
} else {
completionHandler(nil, NSFileProviderError(.noSuchItem))
}
}
progress.completedUnitCount = 1
return progress
}
func fetchContents(for itemIdentifier: NSFileProviderItemIdentifier,
version _: NSFileProviderItemVersion?,
request _: NSFileProviderRequest,
completionHandler: @escaping (URL?, NSFileProviderItem?, Error?) -> Void) -> Progress
{
let progress = Progress(totalUnitCount: 100)
let url = fileURL(for: itemIdentifier)
guard FileManager.default.fileExists(atPath: url.path) else {
completionHandler(nil, nil, NSFileProviderError(.noSuchItem))
return progress
}
let item = FileProviderItem(url: url, identifier: itemIdentifier, workingDirectory: workingDirectory)
completionHandler(url, item, nil)
progress.completedUnitCount = 100
return progress
}
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)
let parentURL = fileURL(for: itemTemplate.parentItemIdentifier)
let targetURL = parentURL.appendingPathComponent(itemTemplate.filename)
do {
if let contentType = itemTemplate.contentType, contentType.conforms(to: .folder) {
try FileManager.default.createDirectory(at: targetURL, withIntermediateDirectories: true)
} else if let sourceURL = url {
try FileManager.default.copyItem(at: sourceURL, to: targetURL)
} else {
FileManager.default.createFile(atPath: targetURL.path, contents: nil)
}
let identifier = itemIdentifier(for: targetURL)
let item = FileProviderItem(url: targetURL, identifier: identifier, workingDirectory: workingDirectory)
completionHandler(item, [], false, nil)
} catch {
completionHandler(nil, [], false, error)
}
progress.completedUnitCount = 100
return progress
}
func modifyItem(_ item: NSFileProviderItem,
baseVersion _: NSFileProviderItemVersion,
changedFields: NSFileProviderItemFields,
contents newContents: URL?,
options _: NSFileProviderModifyItemOptions,
request _: NSFileProviderRequest,
completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void) -> Progress
{
let progress = Progress(totalUnitCount: 100)
var currentURL = fileURL(for: item.itemIdentifier)
var newIdentifier = item.itemIdentifier
do {
if changedFields.contains(.filename) {
let newURL = currentURL.deletingLastPathComponent().appendingPathComponent(item.filename)
if currentURL != newURL {
try FileManager.default.moveItem(at: currentURL, to: newURL)
currentURL = newURL
newIdentifier = itemIdentifier(for: newURL)
}
}
if changedFields.contains(.parentItemIdentifier) {
let newParentURL = fileURL(for: item.parentItemIdentifier)
let newURL = newParentURL.appendingPathComponent(currentURL.lastPathComponent)
if currentURL != newURL {
try FileManager.default.moveItem(at: currentURL, to: newURL)
currentURL = newURL
newIdentifier = itemIdentifier(for: newURL)
}
}
if changedFields.contains(.contents), let contentsURL = newContents {
try FileManager.default.removeItem(at: currentURL)
try FileManager.default.copyItem(at: contentsURL, to: currentURL)
}
let resultItem = FileProviderItem(url: currentURL, identifier: newIdentifier, workingDirectory: workingDirectory)
completionHandler(resultItem, [], false, nil)
} catch {
completionHandler(nil, [], false, error)
}
progress.completedUnitCount = 100
return progress
}
func deleteItem(identifier: NSFileProviderItemIdentifier,
baseVersion _: NSFileProviderItemVersion,
options _: NSFileProviderDeleteItemOptions,
request _: NSFileProviderRequest,
completionHandler: @escaping (Error?) -> Void) -> Progress
{
let progress = Progress(totalUnitCount: 1)
let url = fileURL(for: identifier)
do {
if FileManager.default.fileExists(atPath: url.path) {
try FileManager.default.removeItem(at: url)
}
completionHandler(nil)
} catch {
completionHandler(error)
}
progress.completedUnitCount = 1
return progress
}
func enumerator(for containerItemIdentifier: NSFileProviderItemIdentifier,
request _: NSFileProviderRequest) throws -> NSFileProviderEnumerator
{
if containerItemIdentifier == .workingSet {
return FileProviderEnumerator(workingSet: true, workingDirectory: workingDirectory)
}
if containerItemIdentifier == .trashContainer {
throw NSFileProviderError(.noSuchItem)
}
let url: URL
if containerItemIdentifier == .rootContainer {
url = workingDirectory
} else {
url = fileURL(for: containerItemIdentifier)
}
guard FileManager.default.fileExists(atPath: url.path) else {
throw NSFileProviderError(.noSuchItem)
}
return FileProviderEnumerator(url: url, workingDirectory: workingDirectory)
}
// MARK: - Helper Methods
private func fileURL(for identifier: NSFileProviderItemIdentifier) -> URL {
if identifier == .rootContainer {
return workingDirectory
}
let relativePath = identifier.rawValue
return workingDirectory.appendingPathComponent(relativePath)
}
private func itemIdentifier(for url: URL) -> NSFileProviderItemIdentifier {
let relativePath = url.path.replacingOccurrences(of: workingDirectory.path + "/", with: "")
if relativePath == url.path || relativePath.isEmpty {
return .rootContainer
}
return NSFileProviderItemIdentifier(relativePath)
}
}
@@ -0,0 +1,101 @@
import FileProvider
import UniformTypeIdentifiers
class FileProviderItem: NSObject, NSFileProviderItem {
private let url: URL
private let fileAttributes: [FileAttributeKey: Any]
private let _identifier: NSFileProviderItemIdentifier
private let _parentIdentifier: NSFileProviderItemIdentifier
private let isRoot: Bool
init(rootAt url: URL) {
self.url = url
_identifier = .rootContainer
_parentIdentifier = .rootContainer
isRoot = true
fileAttributes = (try? FileManager.default.attributesOfItem(atPath: url.path)) ?? [:]
super.init()
}
init(url: URL, identifier: NSFileProviderItemIdentifier, workingDirectory: URL) {
self.url = url
_identifier = identifier
isRoot = false
fileAttributes = (try? FileManager.default.attributesOfItem(atPath: url.path)) ?? [:]
let parentPath = url.deletingLastPathComponent().path
if parentPath == workingDirectory.path {
_parentIdentifier = .rootContainer
} else {
let relativePath = parentPath.replacingOccurrences(of: workingDirectory.path + "/", with: "")
_parentIdentifier = NSFileProviderItemIdentifier(relativePath)
}
super.init()
}
var itemIdentifier: NSFileProviderItemIdentifier {
_identifier
}
var parentItemIdentifier: NSFileProviderItemIdentifier {
_parentIdentifier
}
var filename: String {
if isRoot {
return "sing-box"
}
return url.lastPathComponent
}
var contentType: UTType {
if isDirectory {
return .folder
}
return UTType(filenameExtension: url.pathExtension) ?? .data
}
var capabilities: NSFileProviderItemCapabilities {
[
.allowsReading,
.allowsWriting,
.allowsRenaming,
.allowsReparenting,
.allowsDeleting,
.allowsAddingSubItems,
.allowsContentEnumerating,
]
}
var itemVersion: NSFileProviderItemVersion {
let modDate = fileAttributes[.modificationDate] as? Date ?? Date()
let contentVersion = withUnsafeBytes(of: modDate.timeIntervalSince1970) { Data($0) }
return NSFileProviderItemVersion(contentVersion: contentVersion, metadataVersion: contentVersion)
}
var documentSize: NSNumber? {
guard !isDirectory else { return nil }
return fileAttributes[.size] as? NSNumber
}
var creationDate: Date? {
fileAttributes[.creationDate] as? Date
}
var contentModificationDate: Date? {
fileAttributes[.modificationDate] as? Date
}
var childItemCount: NSNumber? {
guard isDirectory else { return nil }
let contents = try? FileManager.default.contentsOfDirectory(atPath: url.path)
return NSNumber(value: contents?.count ?? 0)
}
private var isDirectory: Bool {
var isDir: ObjCBool = false
FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir)
return isDir.boolValue
}
}
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionFileProviderDocumentGroup</key>
<string>group.io.nekohasekai.sfavt</string>
<key>NSExtensionFileProviderSupportsEnumeration</key>
<true/>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.fileprovider-nonui</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).FileProviderExtension</string>
</dict>
</dict>
</plist>