Skip to content

Commit

Permalink
macOS: Use local socket to communicate with finder extension
Browse files Browse the repository at this point in the history
  • Loading branch information
erikjv committed Nov 17, 2021
1 parent 3535be9 commit 6d1bbee
Show file tree
Hide file tree
Showing 15 changed files with 587 additions and 712 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?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>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,19 @@

#import <Cocoa/Cocoa.h>
#import <FinderSync/FinderSync.h>
#import "SyncClientProxy.h"

@interface FinderSync : FIFinderSync <SyncClientProxyDelegate>
{
SyncClientProxy *_syncClientProxy;
NSMutableSet *_registeredDirectories;
NSString *_shareMenuTitle;
NSMutableDictionary *_strings;
NSMutableArray *_menuItems;
#import "FinderSyncExt-Swift.h"
#import "SyncClient.h"

@interface FinderSync : FIFinderSync <SyncClientDelegate> {
NSMutableSet *_registeredDirectories;
NSString *_shareMenuTitle;
NSMutableDictionary *_strings;
NSMutableArray *_menuItems;
NSCondition *_menuIsComplete;
}

@property LineProcessorV1 *v1LineProcessor;
@property LocalSocketClient *localSocketClient;

@end
311 changes: 170 additions & 141 deletions shell_integration/MacOSX/OwnCloudFinderSync/FinderSyncExt/FinderSync.m

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//

#import "SyncClient.h"
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//
// LineProcessorV1.swift
// FinderSyncExt
//
// Created by Erik Verbruggen <erik@verbruggen.consulting> on 06-11-21.
//

import Foundation
import OSLog

class LineProcessorV1: NSObject, LineProcessor {
let delegate: SyncClientDelegate

@objc init(withDelegate delegate: SyncClientDelegate) {
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)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
//
// LocalSocketClient.swift
// FinderSyncExt
//
// Created by Erik Verbruggen <erik@verbruggen.consulting> 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<sockaddr_un>.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..<bytesRead])
self.processInBuffer()
// see if there is more to read: restart the loop
}
}
}

private func processInBuffer() {
let separator: UInt8 = 0xa // byte value for '\n'
while true {
if let pos = self.inBuffer.firstIndex(of: separator) {
self.inBuffer[pos] = 0 // add NUL terminator
let newLine = String(cString: &self.inBuffer)
self.inBuffer.removeFirst(pos + 1) // remove the line from the buffer, including the NUL terminator
self.lineProcessor.process(line: newLine)
} else {
// no separator, we're done
return;
}
}
}
}
Loading

0 comments on commit 6d1bbee

Please sign in to comment.