Skip to content

Commit

Permalink
Merge pull request #7110 from nextcloud/backport/6960/stable-3.14
Browse files Browse the repository at this point in the history
[stable-3.14] Feature/macos vfs locking
  • Loading branch information
mgallien authored Sep 12, 2024
2 parents c79ca8c + 44f640a commit 78a7256
Show file tree
Hide file tree
Showing 18 changed files with 531 additions and 81 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,15 @@ import OSLog
request _: NSFileProviderRequest,
completionHandler: @escaping (NSFileProviderItem?, Error?) -> Void
) -> Progress {
if let item = Item.storedItem(identifier: identifier, remoteInterface: ncKit) {
if ncAccount == nil {
Logger.fileProviderExtension.error(
"""
Not fetching item for identifier: \(identifier.rawValue, privacy: .public)
as account not set up yet.
"""
)
completionHandler(nil, NSFileProviderError(.notAuthenticated))
} else if let item = Item.storedItem(identifier: identifier, remoteInterface: ncKit) {
completionHandler(item, nil)
} else {
completionHandler(nil, NSFileProviderError(.noSuchItem))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,21 @@ class DocumentActionViewController: FPUIActionExtensionViewController {
) {
Logger.actionViewController.info("Preparing action: \(actionIdentifier, privacy: .public)")

if actionIdentifier == "com.nextcloud.desktopclient.FileProviderUIExt.ShareAction" {
switch (actionIdentifier) {
case "com.nextcloud.desktopclient.FileProviderUIExt.ShareAction":
prepare(childViewController: ShareViewController(itemIdentifiers))
case "com.nextcloud.desktopclient.FileProviderUIExt.LockFileAction":
prepare(childViewController: LockViewController(itemIdentifiers, locking: true))
case "com.nextcloud.desktopclient.FileProviderUIExt.UnlockFileAction":
prepare(childViewController: LockViewController(itemIdentifiers, locking: false))
default:
return
}

}

override func prepare(forError error: Error) {
Logger.actionViewController.info(
"""
Preparing for error: \(error.localizedDescription, privacy: .public)
"""
"Preparing for error: \(error.localizedDescription, privacy: .public)"
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ extension Logger {
private static var subsystem = Bundle.main.bundleIdentifier!

static let actionViewController = Logger(subsystem: subsystem, category: "actionViewController")
static let lockViewController = Logger(subsystem: subsystem, category: "lockViewController")
static let metadataProvider = Logger(subsystem: subsystem, category: "metadataProvider")
static let shareCapabilities = Logger(subsystem: subsystem, category: "shareCapabilities")
static let shareController = Logger(subsystem: subsystem, category: "shareController")
static let shareeDataSource = Logger(subsystem: subsystem, category: "shareeDataSource")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// FileProviderCommunication.swift
// FileProviderUIExt
//
// Created by Claudio Cambra on 30/7/24.
//

import FileProvider

enum FileProviderCommunicationError: Error {
case serviceNotFound
case remoteProxyObjectInvalid
}

func serviceConnection(
url: URL, interruptionHandler: @escaping () -> Void
) async throws -> FPUIExtensionService {
let services = try await FileManager().fileProviderServicesForItem(at: url)
guard let service = services[fpUiExtensionServiceName] else {
throw FileProviderCommunicationError.serviceNotFound
}
let connection: NSXPCConnection
connection = try await service.fileProviderConnection()
connection.remoteObjectInterface = NSXPCInterface(with: FPUIExtensionService.self)
connection.interruptionHandler = interruptionHandler
connection.resume()
guard let proxy = connection.remoteObjectProxy as? FPUIExtensionService else {
throw FileProviderCommunicationError.remoteProxyObjectInvalid
}
return proxy
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,32 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleDisplayName</key>
<string>$(OC_APPLICATION_NAME) File Provider UI Extension</string>
<key>CFBundleIdentifier</key>
<string>$(OC_APPLICATION_REV_DOMAIN).$(PRODUCT_NAME)</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleDisplayName</key>
<string>$(OC_APPLICATION_NAME) File Provider UI Extension</string>
<key>CFBundleIdentifier</key>
<string>$(OC_APPLICATION_REV_DOMAIN).$(PRODUCT_NAME)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionFileProviderActions</key>
<array>
<dict>
<key>NSExtensionFileProviderActionIdentifier</key>
<string>com.nextcloud.desktopclient.FileProviderUIExt.UnlockFileAction</string>
<key>NSExtensionFileProviderActionName</key>
<string>Unlock file</string>
<key>NSExtensionFileProviderActionActivationRule</key>
<string>SUBQUERY ( fileproviderItems, $fileproviderItem, $fileproviderItem.userInfo.locked != nil &amp;&amp; !($fileproviderItem.contentType.identifier UTI-CONFORMS-TO &quot;public.folder&quot;) ).@count &gt; 0</string>
</dict>
<dict>
<key>NSExtensionFileProviderActionActivationRule</key>
<string>SUBQUERY ( fileproviderItems, $fileproviderItem, $fileproviderItem.userInfo.locked == nil &amp;&amp; !($fileproviderItem.contentType.identifier UTI-CONFORMS-TO &quot;public.folder&quot;) ).@count &gt; 0</string>
<key>NSExtensionFileProviderActionName</key>
<string>Lock file</string>
<key>NSExtensionFileProviderActionIdentifier</key>
<string>com.nextcloud.desktopclient.FileProviderUIExt.LockFileAction</string>
</dict>
<dict>
<key>NSExtensionFileProviderActionActivationRule</key>
<string>TRUEPREDICATE</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
//
// LockViewController.swift
// FileProviderUIExt
//
// Created by Claudio Cambra on 30/7/24.
//

import AppKit
import FileProvider
import NextcloudFileProviderKit
import NextcloudKit
import OSLog
import QuickLookThumbnailing

class LockViewController: NSViewController {
let itemIdentifiers: [NSFileProviderItemIdentifier]
let locking: Bool

@IBOutlet weak var fileNameIcon: NSImageView!
@IBOutlet weak var fileNameLabel: NSTextField!
@IBOutlet weak var descriptionLabel: NSTextField!
@IBOutlet weak var closeButton: NSButton!
@IBOutlet weak var loadingIndicator: NSProgressIndicator!
@IBOutlet weak var warnImage: NSImageView!

public override var nibName: NSNib.Name? {
return NSNib.Name(self.className)
}

var actionViewController: DocumentActionViewController! {
return parent as? DocumentActionViewController
}

init(_ itemIdentifiers: [NSFileProviderItemIdentifier], locking: Bool) {
self.itemIdentifiers = itemIdentifiers
self.locking = locking
super.init(nibName: nil, bundle: nil)
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func viewDidLoad() {
guard let firstItem = itemIdentifiers.first else {
Logger.shareViewController.error("called without items")
closeAction(self)
return
}

Logger.lockViewController.info(
"""
Locking \(self.locking ? "enabled" : "disabled", privacy: .public) for items:
\(firstItem.rawValue, privacy: .public)
"""
)

Task {
await processItemIdentifier(firstItem)
}
}

@IBAction func closeAction(_ sender: Any) {
actionViewController.extensionContext.completeRequest()
}

private func stopIndicatingLoading() {
loadingIndicator.stopAnimation(self)
loadingIndicator.isHidden = true
warnImage.isHidden = false
}

private func presentError(_ error: String) {
Logger.lockViewController.error("Error: \(error, privacy: .public)")
descriptionLabel.stringValue = "Error: \(error)"
stopIndicatingLoading()
}

private func processItemIdentifier(_ itemIdentifier: NSFileProviderItemIdentifier) async {
guard let manager = NSFileProviderManager(for: actionViewController.domain) else {
fatalError("NSFileProviderManager isn't expected to fail")
}

do {
let itemUrl = try await manager.getUserVisibleURL(for: itemIdentifier)
guard itemUrl.startAccessingSecurityScopedResource() else {
Logger.lockViewController.error("Could not access scoped resource for item url!")
return
}
await updateFileDetailsDisplay(itemUrl: itemUrl)
itemUrl.stopAccessingSecurityScopedResource()
await lockOrUnlockFile(localItemUrl: itemUrl)
} catch let error {
let errorString = "Error processing item: \(error)"
Logger.lockViewController.error("\(errorString, privacy: .public)")
fileNameLabel.stringValue = "Could not lock unknown item…"
descriptionLabel.stringValue = errorString
}
}

private func updateFileDetailsDisplay(itemUrl: URL) async {
let lockAction = locking ? "Locking" : "Unlocking"
fileNameLabel.stringValue = "\(lockAction) file \(itemUrl.lastPathComponent)"

let request = QLThumbnailGenerator.Request(
fileAt: itemUrl,
size: CGSize(width: 48, height: 48),
scale: 1.0,
representationTypes: .icon
)
let generator = QLThumbnailGenerator.shared
let fileThumbnail = await withCheckedContinuation { continuation in
generator.generateRepresentations(for: request) { thumbnail, type, error in
if thumbnail == nil || error != nil {
Logger.lockViewController.error(
"Could not get thumbnail: \(error, privacy: .public)"
)
}
continuation.resume(returning: thumbnail)
}
}

fileNameIcon.image =
fileThumbnail?.nsImage ??
NSImage(systemSymbolName: "doc", accessibilityDescription: "doc")
}

private func lockOrUnlockFile(localItemUrl: URL) async {
descriptionLabel.stringValue = "Fetching file details…"

guard let itemIdentifier = await withCheckedContinuation({
(continuation: CheckedContinuation<NSFileProviderItemIdentifier?, Never>) -> Void in
NSFileProviderManager.getIdentifierForUserVisibleFile(
at: localItemUrl
) { identifier, domainIdentifier, error in
defer { continuation.resume(returning: identifier) }
guard error == nil else {
self.presentError("No item with identifier: \(error.debugDescription)")
return
}
}
}) else {
presentError("Could not get identifier for item, no shares can be acquired.")
return
}

do {
let connection = try await serviceConnection(url: localItemUrl, interruptionHandler: {
Logger.lockViewController.error("Service connection interrupted")
})
guard let serverPath = await connection.itemServerPath(identifier: itemIdentifier),
let credentials = await connection.credentials() as? Dictionary<String, String>,
let account = Account(dictionary: credentials),
!account.password.isEmpty
else {
presentError("Failed to get details from File Provider Extension.")
return
}
let serverPathString = serverPath as String
let kit = NextcloudKit()
kit.setup(
user: account.username,
userId: account.username,
password: account.password,
urlBase: account.serverUrl
)
// guard let capabilities = await fetchCapabilities() else {
guard let itemMetadata = await fetchItemMetadata(
itemRelativePath: serverPathString, kit: kit
) else {
presentError("Could not get item metadata.")
return
}

// Run lock state checks
if locking {
guard !itemMetadata.lock else {
presentError("File is already locked.")
return
}
} else {
guard itemMetadata.lock else {
presentError("File is already unlocked.")
return
}
}

descriptionLabel.stringValue =
"Communicating with server, \(locking ? "locking" : "unlocking") file…"

let serverUrlFileName = itemMetadata.serverUrl + "/" + itemMetadata.fileName
Logger.lockViewController.info(
"""
Locking file: \(serverUrlFileName, privacy: .public)
\(self.locking ? "locking" : "unlocking", privacy: .public)
"""
)

let error = await withCheckedContinuation { continuation in
kit.lockUnlockFile(
serverUrlFileName: serverUrlFileName,
shouldLock: locking,
completion: { _, error in
continuation.resume(returning: error)
}
)
}
if error == .success {
descriptionLabel.stringValue = "File \(self.locking ? "locked" : "unlocked")!"
warnImage.image = NSImage(
systemSymbolName: "checkmark.circle.fill",
accessibilityDescription: "checkmark.circle.fill"
)
stopIndicatingLoading()
if let manager = NSFileProviderManager(for: actionViewController.domain) {
do {
try await manager.signalEnumerator(for: itemIdentifier)
} catch let error {
presentError(
"""
Could not signal lock state change in virtual file.
Changes may take a while to be reflected on your Mac.
Error: \(error.localizedDescription)
""")
}
}
} else {
presentError("Could not lock file: \(error.errorDescription).")
}
} catch let error {
presentError("Could not lock file: \(error).")
}
}
}
Loading

0 comments on commit 78a7256

Please sign in to comment.