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

Objective-C code in a VS Code editor.

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:

  1. 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.
  2. We'll write the native code.
    1. Create the necessary files (activeWindowObserver.h, activeWindowObserver.mm, nativeWindows.mm)
    2. Write activeWindowObserver.h
    3. Implement activeWindowObserver.mm
    4. Implement nativeWindows.mm
  3. Setup build scripts with electron-rebuild.
  4. We'll create a Typescript wrapper for our native code.
  5. 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 the Accessibility 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.
<br /><br />

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 Forum
  • dealloc is used to remove observers
  • init we add an observer to the notification center of the shared workspace that let's us listen to changes between applications
  • receiveAppChangeNotification 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 id
  • removeWindowObserver detaches the observer
  • cleanUp removes the window change observer, and the app change observer
  • initActiveWindowObserver triggers the initialization of an instance of the above class, this is the function that we wrap with NAPI and call from JS
  • stopActiveWindowObserver 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!

Expo with React Native in 2024

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

Expo, Firebase and Google Sign In.

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

By clicking 'Accept' you agree to the use of all cookies as described in our Privacy Policy.

© 2024 Scriptide Ltd.

All rights reserved

D-U-N-S® Nr.: 40-142-5341

VAT ID (HU): HU27931114

Registration Number (HU): 01 09 357677

Privacy