Index

Building native Electron modules in Objective-C++

January 2026

Dara is a meeting notepad for macOS, built on Electron. Parts of it require access to things Electron doesn't expose: identifying which app is using the microphone, checking the system audio capture permission Apple added in 14.4, and trackpad haptics for drag-and-drop. I wrote three native modules in Objective-C++ to cover these, all compiled through Node-API.

Microphone detector

The app needs to know when a meeting starts so it can prompt the user to begin recording. The way I built this is by monitoring microphone usage across the system and identifying which app activated it. macOS doesn't have a single API for that, so the module works in two layers.

The first layer handles detection. A background std::thread polls every 500ms, enumerating all audio devices via AudioObjectGetPropertyData and checking kAudioDevicePropertyDeviceIsRunningSomewhere on each input device:

AudioObjectPropertyAddress runningAddress = {
    kAudioDevicePropertyDeviceIsRunningSomewhere,
    kAudioObjectPropertyScopeInput,
    kAudioObjectPropertyElementMain
};
UInt32 isRunning = 0;
AudioObjectGetPropertyData(deviceID, &runningAddress, 0, nullptr, &runningSize, &isRunning);

This tells you the mic is active, but not which process is using it.

App identification comes from the second layer, which taps Apple's private LoggingSupport.framework. The framework is loaded at runtime via NSBundle. From there, the module creates an OSLogEventLiveStream filtered on subsystem == 'com.apple.coremedia' and watches for CoreMedia session messages that contain a PID.

None of this is in any SDK header. The Objective-C interfaces for OSLogEventLiveStream and OSLogEventProxy are reverse-engineered from OverSight by Patrick Wardle:

@interface OSLogEventLiveStream : NSObject
- (void)activate;
- (void)invalidate;
- (void)setFilterPredicate:(NSPredicate*)predicate;
- (void)setEventHandler:(void(^)(id))callback;
@property(nonatomic) unsigned long long flags;
@end

When a PID is extracted, proc_pidpath resolves it to a file path, then the path is walked upward to find the parent .app bundle and its bundleIdentifier. This is the high-confidence candidate. The frontmost app is added as a fallback at lower confidence.

Both layers run off the main thread, so all JS callbacks go through napi_threadsafe_function. The detected PID is shared between the OSLog callback and the polling thread behind a std::mutex, and candidate resolution dispatches to the main queue via dispatch_sync because NSRunningApplication requires it. The JS wrapper extends EventEmitter and emits microphoneActive/microphoneInactive with ranked candidates.

Audio capture permissions

The app records system audio during meetings, not just microphone input. In macOS 14.4, Apple added a separate permission for this that's distinct from the standard microphone permission. There's no public API to check or request it, so the module uses Apple's private TCC (Transparency, Consent, and Control) 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 current permission state synchronously: 0 = authorized, 1 = denied, anything else = not determined. TCCAccessRequest triggers the system dialog and returns the result asynchronously, bridged to JS through Napi::ThreadSafeFunction. Gated on @available(macOS 14.4, *), returns "not-available" on older versions.

Build and packaging

All modules live in a single binding.gyp, compiled with -ObjC++, each linking against the frameworks it needs. The build step has a || echo 'native module build skipped' fallback so non-macOS contributors can still build the TypeScript parts.

For production, the .node binaries go into asarUnpack since native code can't execute from inside an ASAR archive. Loading handles dev vs production through multi-path resolution:

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 with optional chaining so the app degrades gracefully if a module is missing. The modules are built with Node-API rather than NAN for ABI stability across Electron versions, which means no recompilation on minor version bumps.