ブログ

Objective-Cを使用してElectronアプリでMacOSのアクティブウィンドウを追跡する

Objective-Cを使用してElectronアプリでMacOSのアクティブウィンドウを追跡する

この記事では、Objective-C++を使用してMacOSでElectronアプリのアクティブウィンドウを簡単に追跡する方法を紹介します。Electronなしでネイティブにも使用できます。

執筆者

Gáspár Balázsi

(ChatGPTによる翻訳)

Published

OCT 05, 2022

Topics

#dev

Length

7 min read

VS CodeエディタでのObjective-Cコード。

背景

Electron アプリケーション内で MacOS のネイティブコード(Objective-C)を使用して、アクティブウィンドウの追跡機能を実装し、NAPI を介して Electron アプリにバインドします。これは、ユーザーが一つのウィンドウから別のウィンドウに切り替えるたびに、ネイティブ側から常にアプリに通知することを意味します。ユーザーがウィンドウ間でどのように切り替えるか(例:CMD + Tab、一つのアプリから別のアプリにクリックで切り替えるなど)に関係なく、このチュートリアルの終わりには、Javascript の世界でアクティブウィンドウの ID を取得できるようになります。後でウィンドウ ID を使用し、スクリーンレコーディングを行うなどのことも可能ですが、それはこのチュートリアルの範囲外です。

<br />

カバーする内容:

  1. electron-react-boilerplateを使用して Electron アプリを作成します(すでに既存の Electron アプリがある場合は、それを出発点として使用しても構いません)。執筆時点での Electron のバージョンはv20.0.2です。
  2. ネイティブコードを書きます。
    1. 必要なファイルを作成します(activeWindowObserver.hactiveWindowObserver.mmnativeWindows.mm
    2. activeWindowObserver.hを書きます
    3. activeWindowObserver.mmを実装します
    4. nativeWindows.mmを実装します
  3. electron-rebuildでビルドスクリプトをセットアップします。
  4. ネイティブコードのための Typescript ラッパーを作成します。
  5. main Javascript プロセスからそれを呼び出し、結果をコンソールにログします。

前提条件:

  • アプリはAccessibility権限が必要ですので、ターミナルから実行する場合は、ターミナルにAccessibility権限を付与し、後でスタンドアロンアプリとしてビルドする場合は、アプリに付与してください。
VSCode のターミナルから実行する場合は、VSCode に権限が与えられていることを確認してください。これは、ターミナルを生成するオーナープロセスとして VSCode が機能するためです。他の統合ターミナルにも同様のことが適用されます。
  • ある程度、Typescript と Electron に精通していることが前提となります。
<br /><br />

1. Electron のボイラープレートを作成する

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

あなたは基本的な Electron のボイラープレートを持っており、そのフォルダのルートに位置しているはずです。

2. ネイティブコードを書く

NAPI をインストール

このチュートリアルでは、ObjectiveC++コードを Node にバインドするためにnode-addon-apiを使用します。このチュートリアルではv4.3.0を使用します。すぐにビルドのための必要なファイルを作成しますが、設定について詳しく読みたい場合は、GitHub で読むことが可能です。

npm i node-addon-api@4.3.0

ネイティブモジュールのフォルダ構造を作成する

私たちのモジュールはnative-windowsと呼び、その中にmacosフォルダを作成します。これは、Windows にも拡張する可能性があるためです。

mkdir -p src/native-modules/native-windows/macos

NAPI をセットアップする

src/native-modules/native-windowsに、以下の内容でbinding.gypという名前のファイルを作成します:

{
  "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"]
          }
        ]
      ]
    }
  ]
}

.gypファイルは基本的に、ObjectiveC++プログラムをコンパイルするために使用される Makefiles を生成するための JSON ファイルです。これはnode-gypによって消費され、これは私たちが使用するビルドツールです。私たちのアプリは MacOS でビルドされると想定しています。MacOS 特有の API を使用するためには Cocoa フレームワークが必要になります。Node-gyp は、モジュールのバインディングを含む.nodeファイルを私たちのObjectiveC++ソースコードからコンパイルし、それをjsファイルにインポートすることができます。

また、以下の内容でここにpackage.jsonファイルを追加します:

{
  "name": "native-windows",
  "version": "0.1.0",
  "scripts": {
    "install": "node-gyp rebuild"
  },
  "gypfile": true
}

このファイルは基本的に、このモジュールが NodeJS にバインドするC++モジュールであることを示しています。

必要なファイルを作成する

src/native-modules/native-windows/macosフォルダに、activeWindowObserver.hactiveWindowObserver.mm、およびnativeWindows.mmファイルを作成します。

.hファイルはヘッダーファイルです。C++に慣れていない場合は、.hファイルをインターフェースと考え、この場合.mmファイルで実装されているものを定義するものと考えてください。

activeWindowObserver.mmは、.hファイルで定義されているものの実装を含んでいます。.mm拡張子はObjectiveC++ための特有なものです。

nativeWindows.mmファイルには、JS からモジュールを使用できるようにするバインディングが含まれています。

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

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;
}
上記コードの説明:
  • windowChangedCallbackは、同じアプリケーションに属するウィンドウ間の変更を追跡するために使用されます。
    「なぜ 30ms の遅延が最前面のウィンドウを再度フェッチする前にあるのか」と疑問に思うかもしれません。遅延なしでは、常に前の状態の順序でウィンドウを取得するため、実際にユーザーが見ているものよりも常に 1 ステップ遅れていました。おそらくこれは、使用している API の同期の欠如によるものですが、これは単なる推測です。しかし、これがバグかもしれないため、この奇妙な動作について Apple に問題を報告しました。この問題の今後に興味がある場合は、次のリンクで追跡できます:Apple フォーラム
  • deallocは、オブザーバーを削除するために使用されます
  • initでは、アプリケーション間の変更を確認するために、共有ワークスペースの通知センターにオブザーバーを追加します
  • receiveAppChangeNotificationは、init でアタッチされたハンドラー自体です。このハンドラーで、同じアプリケーションに属するウィンドウの変更を購読する別のオブザーバーを定義します(windowChangeCallback)。
  • getActiveWindowは、最前面のウィンドウの ID を取得する責任があります
  • removeWindowObserverはオブザーバーをデタッチします
  • cleanUpは、ウィンドウの変更オブザーバーとアプリの変更オブザーバーを削除します
  • initActiveWindowObserverは、上記のクラスのインスタンスの初期化をトリガーします。これは、JS から呼び出す NAPI でラップする関数です
  • stopActiveWindowObserverは、私たちのインスタンスの破壊をトリガーし、すべてのリスナーを削除します

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)

かなり簡単で、モジュールから公開した 2 つのメソッドをラップして JS から利用可能にします。

3. electron-rebuildでビルドスクリプトをセットアップする

npm i -D electron-rebuild

Electron プロジェクトのルートにあるメインのpackage.jsonファイルに、以下の行を追加します。

{
  "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"
  }
}

ネイティブコードにエラーがない場合、ルートフォルダでnpm run native-modules:rebuildまたはnpm run native-modules:rebuild:armを実行することで、モジュールを正しくビルドできるはずです。これは、ビルドするプラットフォームに依存します。

これには、次のような出力があるはずです:

4. ネイティブコードのための Typescript ラッパーを作成する

electron-react-boilerplate から始めた場合は、以下の行を.eslintrc.jsに追加してください
{
  "rules": {
    ...,
    "class-methods-use-this": "off",
    "import/prefer-default-export": "off"
  }
}

以下を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();

nativeWindows変数をエクスポートします。これは、コンパイルされたコードからネイティブコードをaddonとしてインポートする、正しく型付けされたインスタンス化されたクラスです。

5. main.tsからモジュールを呼び出す

main.tsに以下のインポートを追加します:

import { nativeWindows } from '../native-modules/native-windows';

下記のコードを、アプリが準備完了した後のどこかに追加して、これまでの作業をテストします:

nativeWindows.startActiveWindowObserver((windowId) => {
  console.log('native window id', windowId);
});

その後、npm startでアプリを起動します。

次のようなログが表示されるはずです:

🎉 これで完了です。これで MacOS でアクティブなウィンドウを追跡できるようになるはずです。🎉

完全なコードは、以下のリンクの Github で利用可能です: https://github.com/scriptide-tech/blog-tracking-active-window-macos-objective-c-electron

Scriptideは、カスタムで複雑なB2Bソフトウェアソリューションに特化した高度なソフトウェア開発会社です。デジタルトランスフォーメーション、ウェブおよびモバイル開発、AI、ブロックチェーンなど、さまざまなソリューションを提供しています。

無料のIT相談を受けてください。お話しできることを楽しみにしています。

こちらの記事もおすすめです!

2024年のReact NativeでのExpo

詳細はこちら

2024年にReact NativeでExpoを使用すべきか?

次のReact NativeプロジェクトにExpoを使用するべきかどうかについての終わりのない議論が終わりに近づいています。ネタバレ注意:ExpoもReact Nativeも勝者です。

#dev

APR 10, 2024

16 min read

Expo、Firebase、Googleサインイン。

詳細はこちら

管理されたExpoワークフローReact NativeアプリケーションでGoogle OAuthを使用してFirebaseに認証する(Expo Goと互換性あり)

Typescriptを使用したReact Native ExpoアプリでExpo Goと互換性のあるGoogle OAuthログインでFirebase認証を設定するプロセスをご案内します。

#dev

AUG 23, 2023

12 min read

'承諾'をクリックすることで、こちらに記載されているすべてのクッキーの使用に同意します: プライバシーポリシー.

© 2024 Scriptide Ltd.

全著作権所有

D-U-N-S® 番号:40-142-5341

VAT ID(ハンガリー): HU27931114

会社登録番号(ハンガリー): 01 09 357677

プライバシーポリシー