Building native Electron modules in Objective-C++
Dara is a meeting notepad for macOS, built on Electron. That gets you pretty far, but there were a few things we needed that it doesn't expose - which app is using the mic, the system audio permission Apple added in 14.4, and trackpad haptics for drag & drop. So I wrote 3 small native modules in Objective-C++, all compiled through Node-API (N-API). Writing up the 2 interesting ones here + how it's all built and shipped.
Mic detector
When a meeting starts we want to prompt you to hit record, so we need to know when the mic turns on and which app turned it on. There's no one macOS API that gives you both, so the detector is split into two layers.
Layer 1 checks if the mic is on at all. A background std::thread polls every 500ms, goes through all audio devices with AudioObjectGetPropertyData and checks kAudioDevicePropertyDeviceIsRunningSomewhere on the inputs:
AudioObjectPropertyAddress runningAddress = {
kAudioDevicePropertyDeviceIsRunningSomewhere,
kAudioObjectPropertyScopeInput,
kAudioObjectPropertyElementMain
};
UInt32 isRunning = 0;
AudioObjectGetPropertyData(deviceID, &runningAddress, 0, nullptr, &runningSize, &isRunning);That only tells you the mic is running.
Layer 2 finds the app. I load Apple's private LoggingSupport.framework at runtime through NSBundle, open an OSLogEventLiveStream filtered on subsystem == 'com.apple.coremedia', and watch for the CoreMedia session messages - some of them include a PID.
None of this is in an SDK header. The interfaces for OSLogEventLiveStream and OSLogEventProxy are reverse-engineered, I took them from Patrick Wardle's OverSight (huge thanks):
@interface OSLogEventLiveStream : NSObject
- (void)activate;
- (void)invalidate;
- (void)setFilterPredicate:(NSPredicate*)predicate;
- (void)setEventHandler:(void(^)(id))callback;
@property(nonatomic) unsigned long long flags;
@endOnce there's a PID: proc_pidpath -> file path -> walk up to the .app bundle -> read its bundleIdentifier. That's usually the right app. I also add whatever app is in front as a backup, ranked lower.
Both layers run off the main thread, so every callback into JS goes through napi_threadsafe_function. The OSLog callback and the polling thread share the detected PID behind a std::mutex, and resolving candidates has to happen on the main queue via dispatch_sync since NSRunningApplication needs that. On the JS side it's an EventEmitter that fires microphoneActive / microphoneInactive with the candidates ranked.
Audio capture permission
We also record system audio during meetings. In 14.4 Apple made that its own permission, separate from the mic one, and there's no public API to check or request it - so the module calls the private TCC functions directly:
extern "C" {
int TCCAccessPreflight(CFStringRef service, CFDictionaryRef options);
void TCCAccessRequest(CFStringRef service, CFDictionaryRef options, void (^callback)(Boolean granted));
}
#define kTCCServiceListenEvent CFSTR("ListenEvent")TCCAccessPreflight returns the current state sync (0 = authorized, 1 = denied, anything else = not determined). TCCAccessRequest shows the system dialog and calls back async, which I bridge to JS with Napi::ThreadSafeFunction. All of it sits behind @available(macOS 14.4, *), older versions get "not-available".
Build + packaging
All 3 modules live in one binding.gyp, compiled with -ObjC++, each linking only the frameworks it needs.
Native code can't run from inside an ASAR archive, so in prod the .node binaries go in asarUnpack. To load them in both dev and prod I try a few paths:
const searchPaths = [
path.join(process.cwd(), "native/build/Release/haptics.node"),
path.join(process.cwd(), "apps/desktop/native/build/Release/haptics.node"),
path.join(
process.resourcesPath || "",
"app.asar.unpacked/native/build/Release/haptics.node",
),
];Every native call is wrapped in try/catch + optional chaining, so if a module is missing the app keeps working without that feature. I went with Node-API over NAN for ABI stability across Electron versions - no recompiling on minor bumps.