From ee38e6f1c792f857eeaee0505afc1f9459c3afe4 Mon Sep 17 00:00:00 2001 From: Erik Verbruggen Date: Wed, 10 Nov 2021 18:05:55 +0100 Subject: [PATCH] macOS: Use local socket to communicate with finder extension --- .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../FinderSyncExt/FinderSync.h | 6 + .../FinderSyncExt/FinderSync.m | 56 +++- .../FinderSyncExt-Bridging-Header.h | 5 + .../FinderSyncExt/LineProcessorV1.swift | 67 +++++ .../FinderSyncExt/LocalSocketClient.swift | 274 ++++++++++++++++++ .../FinderSyncExt/SyncClientProxy.h | 1 + .../FinderSyncExt/SyncClientProxy.m | 2 +- .../project.pbxproj | 24 +- src/gui/guiutility_mac.mm | 10 +- src/gui/socketapi/CMakeLists.txt | 6 +- src/gui/socketapi/socketapi.h | 2 +- 12 files changed, 444 insertions(+), 17 deletions(-) create mode 100644 shell_integration/MacOSX/OwnCloud.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 shell_integration/MacOSX/OwnCloudFinderSync/FinderSyncExt/FinderSyncExt-Bridging-Header.h create mode 100644 shell_integration/MacOSX/OwnCloudFinderSync/FinderSyncExt/LineProcessorV1.swift create mode 100644 shell_integration/MacOSX/OwnCloudFinderSync/FinderSyncExt/LocalSocketClient.swift diff --git a/shell_integration/MacOSX/OwnCloud.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/shell_integration/MacOSX/OwnCloud.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000000..18d981003d6 --- /dev/null +++ b/shell_integration/MacOSX/OwnCloud.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/shell_integration/MacOSX/OwnCloudFinderSync/FinderSyncExt/FinderSync.h b/shell_integration/MacOSX/OwnCloudFinderSync/FinderSyncExt/FinderSync.h index 803c5270ffb..2f924718cba 100644 --- a/shell_integration/MacOSX/OwnCloudFinderSync/FinderSyncExt/FinderSync.h +++ b/shell_integration/MacOSX/OwnCloudFinderSync/FinderSyncExt/FinderSync.h @@ -15,6 +15,8 @@ #import #import + +#import "FinderSyncExt-Swift.h" #import "SyncClientProxy.h" @interface FinderSync : FIFinderSync @@ -24,6 +26,10 @@ NSString *_shareMenuTitle; NSMutableDictionary *_strings; NSMutableArray *_menuItems; + NSCondition *_menuIsComplete; } +@property LineProcessorV1 *v1LineProcessor; +@property LocalSocketClient *localSocketClient; + @end diff --git a/shell_integration/MacOSX/OwnCloudFinderSync/FinderSyncExt/FinderSync.m b/shell_integration/MacOSX/OwnCloudFinderSync/FinderSyncExt/FinderSync.m index a0f791882c2..cc4fd45f15e 100644 --- a/shell_integration/MacOSX/OwnCloudFinderSync/FinderSyncExt/FinderSync.m +++ b/shell_integration/MacOSX/OwnCloudFinderSync/FinderSyncExt/FinderSync.m @@ -55,11 +55,23 @@ - (instancetype)init // the sandboxed App Extension needs. // https://developer.apple.com/library/mac/documentation/Security/Conceptual/AppSandboxDesignGuide/AppSandboxInDepth/AppSandboxInDepth.html#//apple_ref/doc/uid/TP40011183-CH3-SW24 NSString *serverName = [socketApiPrefix stringByAppendingString:@".socketApi"]; - //NSLog(@"FinderSync serverName %@", serverName); - - _syncClientProxy = [[SyncClientProxy alloc] initWithDelegate:self serverName:serverName]; + NSLog(@"FinderSync serverName %@", serverName); + NSURL *container = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:socketApiPrefix]; + NSURL *socketPath = [container URLByAppendingPathComponent:@"GUI.socket" isDirectory:false]; + + _syncClientProxy = [[SyncClientProxy alloc] initWithDelegate:self + serverName:serverName]; + if (socketPath.path) { + self.v1LineProcessor = [[LineProcessorV1 alloc] initWithDelegate:self]; + self.localSocketClient = [[LocalSocketClient alloc] initWithSocketPath:socketPath.path + lineProcessor:self.v1LineProcessor]; + [self.localSocketClient start]; + } else { + self.localSocketClient = nil; + } _registeredDirectories = [[NSMutableSet alloc] init]; _strings = [[NSMutableDictionary alloc] init]; + _menuIsComplete = [[NSCondition alloc] init]; [_syncClientProxy start]; return self; @@ -76,7 +88,11 @@ - (void)requestBadgeIdentifierForURL:(NSURL *)url } NSString* normalizedPath = [[url path] decomposedStringWithCanonicalMapping]; - [_syncClientProxy askForIcon:normalizedPath isDirectory:isDir]; + if (self.localSocketClient.isConnected) { + [self.localSocketClient askForIcon:normalizedPath isDirectory:isDir]; + } else { + [_syncClientProxy askForIcon:normalizedPath isDirectory:isDir]; + } } #pragma mark - Menu and toolbar item support @@ -116,8 +132,17 @@ - (NSMenu *)menuForMenuKind:(FIMenuKind)whichMenu }]; NSString *paths = [self selectedPathsSeparatedByRecordSeparator]; - // calling this IPC calls us back from client with several MENU_ITEM entries and then our askOnSocket returns again - [_syncClientProxy askOnSocket:paths query:@"GET_MENU_ITEMS"]; + if (!self.localSocketClient.isConnected) { + // calling this IPC calls us back from client with several MENU_ITEM entries and then our askOnSocket returns again + [_syncClientProxy askOnSocket:paths query:@"GET_MENU_ITEMS"]; + } else { + [self.localSocketClient askOnSocket:paths query:@"GET_MENU_ITEMS"]; + + // The NSConnection based communication is blocking until the answer has come in. The local socket based + // communication uses non-blocking I/O, so we have to wait here until the menu items have delivered + // asynchronously. + [self waitForMenuToArrive]; + } id contextMenuTitle = [_strings objectForKey:@"CONTEXT_MENU_TITLE"]; if (contextMenuTitle && !onlyRootsSelected) { @@ -147,11 +172,22 @@ - (NSMenu *)menuForMenuKind:(FIMenuKind)whichMenu return nil; } +- (void)waitForMenuToArrive +{ + [self->_menuIsComplete lock]; + [self->_menuIsComplete wait]; + [self->_menuIsComplete unlock]; +} + - (void)subMenuActionClicked:(id)sender { long idx = [(NSMenuItem*)sender tag]; NSString *command = [[_menuItems objectAtIndex:idx] valueForKey:@"command"]; NSString *paths = [self selectedPathsSeparatedByRecordSeparator]; - [_syncClientProxy askOnSocket:paths query:command]; + if (self.localSocketClient.isConnected) { + [self.localSocketClient askOnSocket:paths query:command]; + } else { + [_syncClientProxy askOnSocket:paths query:command]; + } } #pragma mark - SyncClientProxyDelegate implementation @@ -188,10 +224,16 @@ - (void)resetMenuItems { _menuItems = [[NSMutableArray alloc] init]; } + - (void)addMenuItem:(NSDictionary *)item { [_menuItems addObject:item]; } +- (void)menuHasCompleted +{ + [self->_menuIsComplete signal]; +} + - (void)connectionDidDie { [_strings removeAllObjects]; diff --git a/shell_integration/MacOSX/OwnCloudFinderSync/FinderSyncExt/FinderSyncExt-Bridging-Header.h b/shell_integration/MacOSX/OwnCloudFinderSync/FinderSyncExt/FinderSyncExt-Bridging-Header.h new file mode 100644 index 00000000000..9e4ed3055f6 --- /dev/null +++ b/shell_integration/MacOSX/OwnCloudFinderSync/FinderSyncExt/FinderSyncExt-Bridging-Header.h @@ -0,0 +1,5 @@ +// +// Use this file to import your target's public headers that you would like to expose to Swift. +// + +#import "SyncClientProxy.h" diff --git a/shell_integration/MacOSX/OwnCloudFinderSync/FinderSyncExt/LineProcessorV1.swift b/shell_integration/MacOSX/OwnCloudFinderSync/FinderSyncExt/LineProcessorV1.swift new file mode 100644 index 00000000000..97b4389ae6a --- /dev/null +++ b/shell_integration/MacOSX/OwnCloudFinderSync/FinderSyncExt/LineProcessorV1.swift @@ -0,0 +1,67 @@ +// +// LineProcessorV1.swift +// FinderSyncExt +// +// Created by Erik Verbruggen on 06-11-21. +// + +import Foundation +import OSLog + +class LineProcessorV1: NSObject, LineProcessor { + let delegate: SyncClientProxyDelegate + + @objc init(withDelegate delegate: SyncClientProxyDelegate) { + self.delegate = delegate + } + + private func log(_ str: String, type logType: OSLogType) { + // We cannot use OSLog's Logger class, because a lot of methods are only available in macOS 11.0 or higher. + os_log("%@", type: logType, str) + } + + /// Processes a line, where the trailing \n has already been stripped + func process(line: String) { + + self.log("Processing line '\(line)'", type: .debug) + let chunks = line.components(separatedBy: ":") + let command = chunks[0] + + switch command { + case "STATUS": + let result = chunks[1] + let path = chunks.suffix(from: 2).joined(separator: ":") + DispatchQueue.main.async { self.delegate.setResultForPath(path, result: result) } + case "UPDATE_VIEW": + let path = chunks[1] + DispatchQueue.main.async { self.delegate.reFetchFileNameCache(forPath: path) } + case "REGISTER_PATH": + let path = chunks[1] + DispatchQueue.main.async { self.delegate.registerPath(path) } + case "UNREGISTER_PATH": + let path = chunks[1] + DispatchQueue.main.async { self.delegate.unregisterPath(path) } + case "GET_STRINGS": + // BEGIN and END messages, do nothing. + break + case "STRING": + DispatchQueue.main.async { self.delegate.setString(chunks[1], value: chunks[2]) } + case "GET_MENU_ITEMS": + if chunks[1] == "BEGIN" { + DispatchQueue.main.async { self.delegate.resetMenuItems() } + } else { + // Do NOT run this on the main queue! It might be blocked waiting for the menu to be completed. + delegate.menuHasCompleted() + } + case "MENU_ITEM": + let item = [ + "command" : chunks[1], + "flags" : chunks[2], + "text" : chunks[3] + ] + DispatchQueue.main.async { self.delegate.addMenuItem(item) } + default: + self.log("Unknown command '\(command)'", type: .error) + } + } +} diff --git a/shell_integration/MacOSX/OwnCloudFinderSync/FinderSyncExt/LocalSocketClient.swift b/shell_integration/MacOSX/OwnCloudFinderSync/FinderSyncExt/LocalSocketClient.swift new file mode 100644 index 00000000000..b6e84d0298e --- /dev/null +++ b/shell_integration/MacOSX/OwnCloudFinderSync/FinderSyncExt/LocalSocketClient.swift @@ -0,0 +1,274 @@ +// +// LocalSocketClient.swift +// FinderSyncExt +// +// Created by Erik Verbruggen on 04-11-21. +// + +import Foundation +import OSLog + +/// Process lines from the `LocalSocketClient`. +@objc protocol LineProcessor { + func process(line: String); +} + +/// Class handling the (asynchronous) communication with a server over a local (UNIX) socket. +/// +/// The implementation uses a `DispatchQueue` and `DispatchSource`s to handle asynchronous communication and thread +/// safety. All public/@objc function can be called from any thread/queue. The delegate that handles the +/// line-decoding is **not invoked on the UI thread**, but the (random) thread associated with the `DispatchQueue`! +/// If any UI work needs to be done, the class implementing the `LineProcessor` protocol should dispatch this work +/// on the main queue (so the UI thread) itself! +/// +/// Other than the `init(withSocketPath:, lineProcessor)` and the `start()` method, all work is done "on the dispatch +/// queue". The `localSocketQueue` is a serial dispatch queue (so a maximum of 1, and only 1, task is run at any +/// moment), which guarantees safe access to instance variables. Both `askOnSocket(_:, query:)` and +/// `askForIcon(_:, isDirectory:)` will internally dispatch the work on the `DispatchQueue`. +/// +/// Sending and receiving data to and from the socket, is handled by two `DispatchSource`s. These will run an event +/// handler when data can be read from resp. written to the socket. These handlers will also be run on the +/// `DispatchQueue`. +class LocalSocketClient: NSObject { + let socketPath: String + let lineProcessor: LineProcessor + + private var sock: Int32? + private var localSocketQueue = DispatchQueue.init(label: "localSocketQueue") + private var readSource: DispatchSourceRead? + private var writeSource: DispatchSourceWrite? + private var inBuffer = [UInt8]() + private var outBuffer = [UInt8]() + + @objc var isConnected: Bool { + get { + sock != nil + } + } + + @objc init(withSocketPath socketPath: String, lineProcessor: LineProcessor) { + self.socketPath = socketPath + self.lineProcessor = lineProcessor + + super.init() + + self.inBuffer.reserveCapacity(1000) + } + + private func log(_ str: String, type logType: OSLogType) { + if #available(macOSApplicationExtension 11.0, *) { + // NOTE: when support for 10.* is dropped, make an instance variable instead of instantiating the `Logger` + // object every time. + Logger().log(level: logType, "\(str, privacy: .public)") + } else { + os_log("%@", type: logType, str) + } + } + + @objc func start() { + var sa_un = sockaddr_un() + + let socketPathByteCount = socketPath.utf8.count + 1; // add 1 for the NUL terminator char + let maxByteCount = MemoryLayout.size(ofValue: sa_un.sun_path) + guard socketPathByteCount < maxByteCount else { + log("Socket path '\(socketPath)' is too long: \(socketPathByteCount) is longer than \(maxByteCount)", + type: .error) + return + } + + log("Opening local socket...", type: .debug) + + self.sock = socket(AF_LOCAL, SOCK_STREAM, 0) + guard self.sock != -1 else { + self.log("Cannot open socket: \(self.strErr())", type: .error) + self.restart() + return + } + + log("Local socket openned, now connecting to '\(self.socketPath)' ...", type: .debug) + + sa_un.sun_family = UInt8(AF_LOCAL & 0xff) + + let pathBytes = socketPath.utf8 + [0] + pathBytes.withUnsafeBytes { srcBuffer in + withUnsafeMutableBytes(of: &sa_un.sun_path) { dstPtr in + dstPtr.copyMemory(from: srcBuffer) + } + } + + let connStatus = withUnsafePointer(to: &sa_un) { sa_unPtr in + // We are now allowed to mess with the raw pointer to `sa_un`, and cast it to a `sockaddr` pointer. + // This is basically a barrier before and after this closure, so that all writes have been done before by + // the compiler, and that subsequent reads do not read old values (because Swift can't see if `connect` + // messes with the memory for which it receives a raw pointer). + sa_unPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { saPtr in + connect(self.sock!, saPtr, socklen_t(MemoryLayout.size)) + } + } + + guard connStatus != -1 else { + self.log("Cannot connect to '\(self.socketPath): \(self.strErr())", type: .error) + self.restart() + return + } + + let flags = fcntl(self.sock!, F_GETFL, 0) + guard -1 != fcntl(self.sock!, F_SETFL, flags | O_NONBLOCK) else { + self.log("Cannot set socket to non-blocking mode: \(self.strErr())", type: .error) + self.restart() + return + } + + self.readSource = DispatchSource.makeReadSource(fileDescriptor: self.sock!, queue: self.localSocketQueue) + self.readSource!.setEventHandler { self.readFromSocket() } + self.readSource!.setCancelHandler { + self.readSource = nil + self.closeConnection() + } + self.readSource!.resume() + + self.writeSource = DispatchSource.makeWriteSource(fileDescriptor: self.sock!, queue: self.localSocketQueue) + self.writeSource!.setEventHandler { self.writeToSocket() } + self.writeSource!.setCancelHandler { + self.writeSource = nil + self.closeConnection() + } + // The writeSource dispatch queue starts suspended; we will resume it when we have data to send (and suspend it + // again when our send buffer is empty). + + self.log("We have a connection! Starting I/O channel...", type: .info) + + self.askOnSocket("", query: "GET_STRINGS") + } + + private func restart() { + self.closeConnection() + + DispatchQueue.main.async { + Timer.scheduledTimer(withTimeInterval: 5, repeats: false, block: { _ in + self.start() + }); + } + } + + private func closeConnection() { + self.readSource?.cancel() + self.writeSource?.cancel() + self.readSource = nil + self.writeSource = nil + self.inBuffer.removeAll() + self.outBuffer.removeAll() + if let sock = self.sock { + close(sock) + self.sock = nil + } + } + + private func strErr() -> String { + let err = errno // copy error code now, in case something else happens + return String(utf8String: strerror(err)) ?? "Unknown error code (\(err))" + } + + @objc func askOnSocket(_ path: String, query verb: String) { + let line = "\(verb):\(path)\n" + self.localSocketQueue.async { + guard self.isConnected else { + // socket was closed while work was still scheduled on the queue + return + } + + self.log("Sending line '\(line)", type: .debug) + + let writeSourceIsSuspended = self.outBuffer.isEmpty + let uint8Data: [UInt8] = line.utf8 + [] + self.outBuffer.append(contentsOf: uint8Data) + + // Weird stuff happens when you call resume when the DispatchSource is already resumed, so: if we did NOT + // have any data in our output buffer before queueing more data, it must be suspended. + if writeSourceIsSuspended { + self.writeSource?.resume() // now we will get notified when we can write to the socket. + } + } + } + + private func writeToSocket() { + guard self.isConnected else { + // socket was closed while work was still scheduled on the queue + return + } + + guard !self.outBuffer.isEmpty else { + // the buffer is empty, suspend you-can-write-data notifications + self.writeSource!.suspend() + return + } + + let totalAmountOfBytes = self.outBuffer.count + let bytesWritten = self.outBuffer.withUnsafeBytes { ptr in + write(self.sock!, ptr.baseAddress, totalAmountOfBytes) + } + if bytesWritten == -1 { + if errno == EAGAIN { + // no space free in the buffer on the OS side, we're done + } else { + self.log("Error writing to local socket: \(self.strErr())", type: .error) + self.restart() + } + } else if bytesWritten > 0 { + self.outBuffer.removeFirst(bytesWritten) + if self.outBuffer.isEmpty { + // the buffer is empty, suspend you-can-write-data notifications + self.writeSource!.suspend() + } + } + } + + @objc func askForIcon(_ path: String, isDirectory: Bool) { + let verb = isDirectory ? "RETRIEVE_FOLDER_STATUS" : "RETRIEVE_FILE_STATUS" + self.askOnSocket(path, query: verb) + } + + private func readFromSocket() { + guard self.isConnected else { + // socket was closed while work was still scheduled on the queue + return + } + + let bufferLength = self.inBuffer.capacity / 2 + var buffer = [UInt8].init(repeating: 0, count: bufferLength) + + while true { + let bytesRead = buffer.withUnsafeMutableBytes { ptr in + read(self.sock!, ptr.baseAddress, bufferLength) + } + if bytesRead == -1 { + if errno == EAGAIN { + return // no bytes available, and no error, so we're done + } else { + self.log("Error reading from local socket: \(self.strErr())", type: .error) + self.closeConnection() + return // we've closed the connection, we're done + } + } else { + self.inBuffer.append(contentsOf: buffer[0.. #import +#import namespace OCC { @@ -54,9 +55,12 @@ QString Utility::socketApiSocketPath() { // This must match the code signing Team setting of the extension - // Example for developer builds (with ad-hoc signing identity): "" "com.owncloud.desktopclient" ".socketApi" - // Example for official signed packages: "9B5WD74GWJ." "com.owncloud.desktopclient" ".socketApi" - return QLatin1String(SOCKETAPI_TEAM_IDENTIFIER_PREFIX APPLICATION_REV_DOMAIN ".socketApi"); + // Example for all builds: "9B5WD74GWJ" "." "com.owncloud.desktopclient" + NSString *appGroupId = @SOCKETAPI_TEAM_IDENTIFIER_PREFIX "." APPLICATION_REV_DOMAIN; + + NSURL *container = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:appGroupId]; + NSURL *socketPath = [container URLByAppendingPathComponent:@"GUI.socket" isDirectory:false]; + return QString::fromNSString(socketPath.path); } } // namespace OCC diff --git a/src/gui/socketapi/CMakeLists.txt b/src/gui/socketapi/CMakeLists.txt index 78ce96cfda7..777e2d4df38 100644 --- a/src/gui/socketapi/CMakeLists.txt +++ b/src/gui/socketapi/CMakeLists.txt @@ -3,6 +3,6 @@ target_sources(owncloudCore PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/socketuploadjob.cpp ) -if( APPLE ) - target_sources(owncloudCore PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/socketapisocket_mac.mm) -endif() +#if( APPLE ) +# target_sources(owncloudCore PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/socketapisocket_mac.mm) +#endif() diff --git a/src/gui/socketapi/socketapi.h b/src/gui/socketapi/socketapi.h index 84229b1dd0c..1fc8e4546a7 100644 --- a/src/gui/socketapi/socketapi.h +++ b/src/gui/socketapi/socketapi.h @@ -22,7 +22,7 @@ #include "config.h" -#if defined(Q_OS_MAC) +#if defined(Q_OS_MAC) && 0 #include "socketapisocket_mac.h" #else #include