From a52ebde1d5fa3fa4716d291e2c72b9fc8e555a46 Mon Sep 17 00:00:00 2001 From: Jordan Orelli Date: Sun, 4 Dec 2016 17:31:18 -0800 Subject: [PATCH] explain how this all works --- README.md | 222 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..3be8099 --- /dev/null +++ b/README.md @@ -0,0 +1,222 @@ +# procmon + +procmon is a small Go program for OSX that watches App launch and terminate +events in AppKit. The Go program directly links against the +[AppKit](https://developer.apple.com/reference/appkit) framework and uses it to +subscribe to the +[NSNotificationCenter](https://developer.apple.com/reference/foundation/nsnotificationcenter) +notifications generated by the OS when the user launches or terminates an App. +The observer itself is writing in Objective-C. The Objective-C observer is +accessed through a simple C function that may be accessed by a Go program. The +Objective-C observer, upon seeing notifications, invokes a Go function +directly, passing control back to our Go program. + +## installation + +Via Go Get: `go get github.com/jordanorelli/procmon` + +You can also clone this package and build it with `go build`. The Go toolchain +will invoke cgo transparently on your behalf. There should be no reason to +invoke the cgo toolchain manually; that should only be of interest for +debugging and learning purposes. + +## construction + +[`procmon.go`](blob/master/procmon.go) is the single Go file of interest to the +Go toolchain. + +### triggering the cgo generation and link step + +Accessing cgo requires importing the pseudo-package `C`. It's important to +understand that there is no literal `C` package in the Go standard library. +Every project that uses cgo generates _its own_ `C` package transparently. + +When invoking `import "C"`, the commend that _immediately_ precedes the import +directive contains a set of instructions to feed to cgo, as follows: + +```go +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework AppKit +#include "procmon.h" +*/ +import "C" +``` + +Any lines starting with `#cgo` indicate compiler directives. These are passed +to the cgo tool and are used to invoke the necessary compiler and linker. We +use this flags to indicate that we want to invoke the Objective-C compiler and +link agains the AppKit framework. + +The other lines in this comment, that is, the lines that do _not_ begin with +`#cgo` are passed to the C compiler as if thy were in a C header file. This is +where we `#include "procmon.h"`, the C header file for the C code that we want +to invoke. + +Down in the Go program's `main` function, we spawn a goroutine to listen on a +channel for changes: + +```go + go reportChanges() +``` + +the `reportChanges` function simply reads values off of a channel and prints +them: + +```go +func reportChanges() { + for change := range appChanges { + switch change.stateChange { + case stateStarted: + fmt.Printf("started: %s\n", change.appname) + case stateEnded: + fmt.Printf("terminated: %s\n", change.appname) + } + } +} +``` + +Back in `main`, we invoke the C function defined in our C header file: +```go + C.MonitorProcesses() +``` + +The cgo toolchain automatically associated `procmon.c` with our header file +`procmon.h` that we imported in our cgo import comment. [The implementation of +the `MonitorProcesses` function appears in +`procmon.c`](blob/3767628a0e24c6bccd463a2616f7f5226d4e1c9c/procmon.c#L5): + +```obj-c +void MonitorProcesses() { + [[ProcWatcher shared] startWatching]; + [[NSRunLoop currentRunLoop] run]; +} +``` + +This function does two things: it starts by accessing a singleton of our +Objective-C class `ProcWatcher` (that's `[ProcWatcher shared]`, which is defined +[here](blob/3767628a0e24c6bccd463a2616f7f5226d4e1c9c/ProcWatcher.m#L6)) and +invoking its `startWatching` method. This subscribes our `ProcWatcher` instance +to OS notifications. We'll take a look at what the notification subscription +looks like in a bit. + +#### sidebar: the Run Loop + +After signing up for the notifications, we access the current processes' +runloop with `[NSRunLoop currentRunLoop]` and call its +[`run`](https://developer.apple.com/reference/foundation/nsrunloop/1412430-run?language=objc) +method to run the run loop. There are two reasons why we need to start the +runloop. The first has to do with the mechanics of AppKit. NSRunLoop represents +the event loop underpinning our notification center. Without the runloop +running, the notification center won't ever pick up any notifications. Apple +has a wealth of documentation with respect to the mechanics of Run Loops. If +you're _extremely curious_ about this part of the project, [this +page](https://developer.apple.com/library/content/documentation/Cocoa/Conceptual/Multithreading/RunLoopManagement/RunLoopManagement.html) +has some great literature on how the Run Loop is operating inside of AppKit. + +The other reason we invoke the runloop in this way is that calling our +runloop's run method blocks until the runloop itself terminates. Since we're +invoking the C function from within the Go program's `main` function, we're +blocking Go's `main` function, thus preventing `main` from returning. If `main` +returns in the Go program, the Go runtime ends the process, which is _not_ what +we want. So this call gives us two things: it sets up the notification system +infrastructure, and it prevents our program from terminating. + +#### back to observing NSNotifications + +[The `startWatching` +method](blob/3767628a0e24c6bccd463a2616f7f5226d4e1c9c/ProcWatcher.m#L6) +accesses the current OSX user's +[`NSWorkspace`](https://developer.apple.com/reference/appkit/nsworkspace). The +`NSWorkspace` handle allows us to hook into +[`NSNotificationCenter`](https://developer.apple.com/reference/foundation/nsnotificationcenter) +to subscribe to notifications in the user's workspace. We specifically +subscribe to the `NSWorkspaceDidLaunchApplicationNotification` and +`NSWorkspaceDidTerminateApplicationNotification` notifications. Here's the +subscription to the `NSWorkspaceDidLaunchApplicationNotification` notification, +which is signaled by the operating system to inform an observer that an +application has been launched by the user: + +```obj-c + void (^handleAppLaunch) (NSNotification*) = ^(NSNotification* note) { + NSDictionary* info = note.userInfo; + NSRunningApplication* app = info[NSWorkspaceApplicationKey]; + NSString* bundleId = app.bundleIdentifier; + + AppStarted((GoString){bundleId.UTF8String, bundleId.length}); + }; + + id observerLaunch = [notifications + addObserverForName: NSWorkspaceDidLaunchApplicationNotification + object: workspace + queue: [NSOperationQueue mainQueue] + usingBlock: handleAppLaunch]; +``` + +The notable feature here is: we pass a callback to our notification center, and +within that callback, we invoke a curious function: `AppStarted`. That function +isn't defined anywhere in our C or Objective-C code: it's defined [in our +original Go file +`procmon.go`](blob/3767628a0e24c6bccd463a2616f7f5226d4e1c9c/procmon.go#L28-L31): + +```go +//export AppStarted +func AppStarted(name string) { + appChanges <- appStateChange{stateStarted, name} +} +``` + +The `//export AppStarted` line before the definition of the Go function informs +cgo that we'd like the function to be exported for use by C with the name +AppStarted. I gave it the same name in C and Go but the names don't have to be +the same; you could `//export SomethingElse` or even `//export something_else` +and invoke it from C as `something_else`. + +Because we're exporting a function for use by C, cgo will generate some +bridging code in C that can be imported by our own C code. This allows our own +C code to call back into the Go program and invoke Go functions. cgo will +silently generate this C header file behind the scenes. That C header file, +which is given the totally obvious and well-documented name `_cgo_export.h` is +generated when you run `go build` by cgo, used to help compile our C code, and +then deleted. You won't notice it getting written and deleted because it goes +by so quickly, but it's there, and it's on disk when our C code gets compiled. +In order to access those definitions from our C code, our C code has to import +this fleeting header file. In this project, that inclusion happens in +`ProcWatcher.m` +[here](blob/3767628a0e24c6bccd463a2616f7f5226d4e1c9c/ProcWatcher.m#L1), which +looks like this: + +```c +#include "_cgo_export.h" +``` + +Any time you access a Go function from C, you almost certainly need to import +the `_cgo_export.h` header file. Importing this header file makes the Go +function accessible to the Objective-C code _as a C function_ which will +automatically cross-call into Go, having the following signature: + +```c +void AppStarted(GoString p0); +``` + +And _that_ is the function that we're invoking in our NSNotification observer +when we call this: + +```obj-c +AppStarted((GoString){bundleId.UTF8String, bundleId.length}); +``` + +The `GoString` type is used to convert a null-terminated C string into a Go +string, which appears as a struct at the C level, having the following +definition (and transitive definitions): + +```c +typedef struct { const char *p; GoInt n; } GoString; +typedef GoInt64 GoInt; +typedef long long GoInt64; +``` + +Anyway, calling that C function invokes the corresponding Go function +`AppStarted`, which writes a value onto a channel. That value is read off of +the channel by our `reportChanges` goroutine and used to print out the name of +the App that had been launched or terminated by the user.