Blog
Tracking active window on MacOS using Objective-C in an Electron app
Tracking active window on MacOS using Objective-C in an Electron app
In this article we'll show to track active windows on MacOS in an Electron app quite painlessly with Objective-C++. It can also be used natively without Electron.
Written by
Gáspár Balázsi
Published
OCT 05, 2022
Topics
#dev
Length
4 min read
Background
We will implement active window tracking functionality within an Electron application with native code (Objective-C) on MacOS and bind it to our Electron app via NAPI. What this means is that, we will always notify our app from the native side, whenever a user changes from one window to another. Regardless of how the user changes between windows (e.g: CMD + Tab, clicking from one app to another), by the end of this tutorial, we will be able to get the id of the active window in the Javascript world. So you can use the window id later on to, for example, do some screen recording but that's out of the scope of this tutorial.
<br />What will we cover:
- We'll use electron-react-boilerplate to create an Electron app (if you already have an existing Electron app, feel free to use that as a starting point). At the time of writing, Electron is at
v20.0.2
. - We'll write the native code.
- Create the necessary files (
activeWindowObserver.h
,activeWindowObserver.mm
,nativeWindows.mm
) - Write
activeWindowObserver.h
- Implement
activeWindowObserver.mm
- Implement
nativeWindows.mm
- Setup build scripts with
electron-rebuild
. - We'll create a Typescript wrapper for our native code.
- We will call it from our
main
Javascript process and log the results to the console.
Prerequisites:
- Your app needs
Accessibility
permission, so if you run it from the Terminal, make sure to grant theAccessibility
permission to the Terminal, and later, once you build it to a standalone app, to your app.
If you run it from VSCode's Terminal, make sure the permission is given to VSCode as that will be the owner process that spawns the Terminal. Same applies for any other integrated terminal.
- We assume that you're familiar with Typescript and Electron to a certain degree.
1. Create an Electron boilerplate
git clone --depth 1 --branch main https://github.com/electron-react-boilerplate/electron-react-boilerplate.git electron-active-window-tracking
cd electron-active-window-tracking
You should have a barebones electron boilerplate and stand inside the root of that folder.
2. Writing the native code
Install NAPI
We will use node-addon-api
for binding our ObjectiveC++ code to Node. In this tutorial, we use v4.3.0
. We'll create the necessary files for the build in a minute, but if you want to read more about setting it up, you can read it on github.
npm i node-addon-api@4.3.0
Create the folder structure for our native module
We will call our module native-windows
, and we will create a macos
folder in it, as we have the possibility to extend it for Windows as well.
mkdir -p src/native-modules/native-windows/macos
Setup NAPI
In src/native-modules/native-windows
, create a file named binding.gyp
with the below content:
{
"targets": [
{
"target_name": "nativeWindows",
"cflags!": ["-fno-exceptions"],
"cflags_cc!": ["-fno-exceptions"],
"include_dirs": ["<!@(node -p \"require('node-addon-api').include\")"],
"defines": ["NAPI_DISABLE_CPP_EXCEPTIONS"],
"conditions": [
[
"OS==\"mac\"",
{
"sources": [
"<!@(node -p \"require('fs').readdirSync('./macos').map(f=>'macos/'+f).join(' ')\")"
],
"libraries": ["-framework Cocoa"]
}
]
]
}
]
}
A .gyp
file is basically a JSON file that is used to generate Makefiles used to compile our ObjectiveC++
program. It's consumed by node-gyp
, which is the build tool we'll use. We assume that our app will be built on MacOS. We will need the Cocoa framework, to be able to use MacOS specific APIs. Node-gyp compiles a .node
file from our ObjectiveC++
source code, that contains the bindings for our module and that can be imported to a js
file.
Also add a package.json
file here, with the below content:
{
"name": "native-windows",
"version": "0.1.0",
"scripts": {
"install": "node-gyp rebuild"
},
"gypfile": true
}
This file basically marks that this module is a C++
module that we'll bind to NodeJS.
Create necessary files
In src/native-modules/native-windows/macos
folder, create a activeWindowObserver.h
, a activeWindowObserver.mm
, and a nativeWindows.mm
file.
The .h
file is a header file. If you're not familiar with C++
, consider .h
files as interfaces, that define what is implemented in the .mm
file in our case.
The activeWindowObserver.mm
contains the implementation for what's defined in the .h
file. The .mm
extension is specific for ObjectiveC++
.
The nativeWindows.mm
file contains the bindings that enable us to use our module from JS.
Write activeWindowObserver.h
#pragma once
#include <stdio.h>
#include <string>
#include <Cocoa/Cocoa.h>
#include <napi.h>
void initActiveWindowObserver(Napi::Env env, Napi::Function windowCallback);
void stopActiveWindowObserver(Napi::Env env);
@interface ActiveWindowObserver: NSObject
- (id) init;
- (void) dealloc;
- (void) cleanUp;
- (void) removeWindowObserver;
- (void) receiveAppChangeNotification:(NSNotification *) notification;
- (void) getActiveWindow;
@end
Implement activeWindowObserver.mm
#import "activeWindowObserver.h"
#include <iostream>
Napi::ThreadSafeFunction activeWindowChangedCallback;
ActiveWindowObserver *windowObserver;
auto napiCallback = [](Napi::Env env, Napi::Function jsCallback, std::string* data) {
jsCallback.Call({Napi::String::New(env, *data)});
delete data;
};
void windowChangeCallback(AXObserverRef observer, AXUIElementRef element, CFStringRef notificationName, void *refCon) {
if (CFStringCompare(notificationName, kAXMainWindowChangedNotification, 0) == kCFCompareEqualTo) {
NSTimeInterval delayInMSec = 30;
dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInMSec * NSEC_PER_MSEC));
dispatch_after(popTime, dispatch_get_main_queue(), ^(void){
NSLog(@"mainWindowChanged");
[(__bridge ActiveWindowObserver*)(refCon) getActiveWindow];
});
}
}
@implementation ActiveWindowObserver {
NSNumber *processId;
AXObserverRef observer;
}
- (void) dealloc
{
[[[NSWorkspace sharedWorkspace] notificationCenter] removeObserver:self];
[super dealloc];
}
- (id) init
{
self = [super init];
if (!self) return nil;
[[[NSWorkspace sharedWorkspace] notificationCenter] addObserver:self selector:@selector(receiveAppChangeNotification:) name:NSWorkspaceDidActivateApplicationNotification object:nil];
return self;
}
- (void) receiveAppChangeNotification:(NSNotification *) notification
{
[self removeWindowObserver];
int currentAppPid = [NSProcessInfo processInfo].processIdentifier;
NSDictionary<NSString*, NSRunningApplication*> *userInfo = [notification userInfo];
NSNumber *selectedProcessId = [userInfo valueForKeyPath:@"NSWorkspaceApplicationKey.processIdentifier"];
if (processId != nil && selectedProcessId.intValue == currentAppPid) {
return;
}
processId = selectedProcessId;
[self getActiveWindow];
AXUIElementRef appElem = AXUIElementCreateApplication(processId.intValue);
AXError createResult = AXObserverCreate(processId.intValue, windowChangeCallback, &observer);
if (createResult != kAXErrorSuccess) {
NSLog(@"Copy or create result failed");
return;
}
AXObserverAddNotification(observer, appElem, kAXMainWindowChangedNotification, (__bridge void *)(self));
CFRunLoopAddSource([[NSRunLoop currentRunLoop] getCFRunLoop], AXObserverGetRunLoopSource(observer), kCFRunLoopDefaultMode);
NSLog(@"Observers added");
}
- (void) getActiveWindow
{
CFArrayRef windowsRef = CGWindowListCopyWindowInfo(kCGWindowListOptionOnScreenOnly, kCGNullWindowID);
NSArray<NSDictionary*> *windowsArray = (NSArray *)CFBridgingRelease(windowsRef);
NSPredicate *pIdPredicate = [NSPredicate predicateWithFormat:@"kCGWindowOwnerPID == %@ && kCGWindowLayer == 0", processId];
NSArray *filteredWindows = [windowsArray filteredArrayUsingPredicate:pIdPredicate];
NSDictionary *activeWindow = filteredWindows.count > 0 ? filteredWindows.firstObject : Nil;
if (activeWindow == Nil) {
return;
}
NSLog(@"%@ activeWindow",activeWindow);
NSNumber *windowId = [activeWindow valueForKey:@"kCGWindowNumber"];
std::string *result = new std::string([[windowId stringValue] UTF8String]);
activeWindowChangedCallback.BlockingCall(result, napiCallback);
}
- (void) removeWindowObserver
{
if (observer != Nil) {
CFRunLoopRemoveSource([[NSRunLoop currentRunLoop] getCFRunLoop], AXObserverGetRunLoopSource(observer), kCFRunLoopDefaultMode);
CFRelease(observer);
observer = Nil;
}
}
- (void) cleanUp
{
[[[NSWorkspace sharedWorkspace] notificationCenter] removeObserver:self];
[self removeWindowObserver];
}
@end
void initActiveWindowObserver(Napi::Env env, Napi::Function windowCallback) {
activeWindowChangedCallback = Napi::ThreadSafeFunction::New(env, windowCallback, "ActiveWindowChanged", 0, 1);
windowObserver = [[ActiveWindowObserver alloc] init];
}
void stopActiveWindowObserver(Napi::Env env) {
[windowObserver cleanUp];
windowObserver = Nil;
activeWindowChangedCallback.Abort();
activeWindowChangedCallback = Nil;
}
Explanation of the above code:
windowChangedCallback
is used to track changes between windows that belong to the same application You might wonder why that 30ms delay is before fetching again the frontmost window. Without delay, we always get the windows in the order of the previous state, so it was always one step behind what actually the user sees. Presumably it is caused by the lack of synchronicity of the APIs that we use, although it is just a guess. However, I filed an issue to Apple about this strange behaviour, as it might be a bug. In case you are interested in the future of this issue, you can track it on the following link: Apple Forumdealloc
is used to remove observersinit
we add an observer to the notification center of the shared workspace that let's us listen to changes between applicationsreceiveAppChangeNotification
is the handler itself that's attached in init. In this handler, we define another observer, that subscribes to the window changes that belong to the same application (windowChangeCallback
).getActiveWindow
is responsible for getting the frontmost window's idremoveWindowObserver
detaches the observercleanUp
removes the window change observer, and the app change observerinitActiveWindowObserver
triggers the initialization of an instance of the above class, this is the function that we wrap with NAPI and call from JSstopActiveWindowObserver
triggers the destruction of our instance and removes every listener
Implement nativeWindows.mm
#import <Cocoa/Cocoa.h>
#import <stdio.h>
#import <napi.h>
#import "./activeWindowObserver.h"
void StartActiveWindowObserverMethod(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
initActiveWindowObserver(env, info[0].As<Napi::Function>());
}
void StopActiveWindowObserverMethod(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
stopActiveWindowObserver(env);
}
Napi::Object NativeWindows(Napi::Env env, Napi::Object exports) {
exports.Set(Napi::String::New(env, "startActiveWindowObserver"),
Napi::Function::New(env, StartActiveWindowObserverMethod));
exports.Set(Napi::String::New(env, "stopActiveWindowObserver"),
Napi::Function::New(env, StopActiveWindowObserverMethod));
return exports;
}
NODE_API_MODULE(nativeWindows, NativeWindows)
Pretty straightforward, we just wrap the two methods we exposed from our module and make it available from JS.
3. Setup build scripts with electron-rebuild
npm i -D electron-rebuild
Add the following lines to our main package.json
file, that's in the Electron project's root.
{
"scripts": {
...,
"native-modules:rebuild": "npm run native-modules:clean && npx electron-rebuild -f -m src/native-modules/native-windows",
"native-modules:rebuild:arm": "npm run native-modules:rebuild -- --arch arm64",
"native-modules:clean": "rimraf src/native-modules/**/bin && rimraf src/native-modules/**/build"
}
}
If you have no errors in your native code, you should be able to build your module correctly by running npm run native-modules:rebuild
or npm run native-modules:rebuild:arm
depending on which platform you want to build for in your root folder.
It should have a similar output to this:
4. Create a Typescript wrapper for our native code
Add the below lines to .eslintrc.js
if you've started from the electron-react-boilerplate
{
rules: {
...,
'class-methods-use-this': 'off',
'import/prefer-default-export': 'off',
}
}
Add the following to src/native-modules/native-windows/index.ts
interface Addon {
startActiveWindowObserver: (callback: (activeWindow: string) => void) => void;
stopActiveWindowObserver: () => void;
}
// eslint-disable-next-line
const addon: Addon = require('./build/Release/nativeWindows.node');
class NativeWindows {
/**
* Subscribes to active window changes
*/
public startActiveWindowObserver(callback: (window: string | null) => void) {
addon.startActiveWindowObserver(callback);
}
public stopActiveWindowObserver() {
addon.stopActiveWindowObserver();
}
}
export const nativeWindows = new NativeWindows();
We export a nativeWindows
variable that's our instantiated class with correcty typing, that imports our native code as addon
from the compiled code.
5. Call our module from main.ts
Inside main.ts
add the following import:
import { nativeWindows } from '../native-modules/native-windows';
Somewhere below, after the app is ready, add the following code to test what we've done:
nativeWindows.startActiveWindowObserver((windowId) => {
console.log('native window id', windowId);
});
After that, start the app with npm start
.
You should see logs like:
🎉This should be it, you're done. You should be able to track active windows on MacOS by this point.🎉
The complete code is available on our Github on the following link: https://github.com/scriptide-tech/blog-tracking-active-window-macos-objective-c-electron
Scriptide is a highly skilled software development company that specializes in custom, complex B2B software solutions. We offer a wide range of services, including digital transformation, web and mobile development, AI, blockchain, and more.
Get a free IT consultation. We are excited to hear from you.
You might also like these articles!
Click for details
Should I Use Expo for React Native in 2024?
The seemingly never-ending debate about whether you should use Expo for your next React Native project is coming to an end. Spoiler: both Expo and React Native are winners.
#dev
•
APR 10, 2024
•
9 min read
Click for details
Authenticate to Firebase with Google OAuth in a Managed Expo Workflow React Native Application that is compatible with Expo Go
We will guide you through the process of setting up a Firebase Authentication with Google OAuth login that is Expo Go compatible in a React Native Expo app that utilizes a managed workflow with Typescript.
#dev
•
AUG 23, 2023
•
7 min read