ブログ
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
背景
Electron アプリケーション内で MacOS のネイティブコード(Objective-C)を使用して、アクティブウィンドウの追跡機能を実装し、NAPI を介して Electron アプリにバインドします。これは、ユーザーが一つのウィンドウから別のウィンドウに切り替えるたびに、ネイティブ側から常にアプリに通知することを意味します。ユーザーがウィンドウ間でどのように切り替えるか(例:CMD + Tab、一つのアプリから別のアプリにクリックで切り替えるなど)に関係なく、このチュートリアルの終わりには、Javascript の世界でアクティブウィンドウの ID を取得できるようになります。後でウィンドウ ID を使用し、スクリーンレコーディングを行うなどのことも可能ですが、それはこのチュートリアルの範囲外です。
<br />カバーする内容:
- electron-react-boilerplateを使用して Electron アプリを作成します(すでに既存の Electron アプリがある場合は、それを出発点として使用しても構いません)。執筆時点での Electron のバージョンは
v20.0.2
です。 - ネイティブコードを書きます。
- 必要なファイルを作成します(
activeWindowObserver.h
、activeWindowObserver.mm
、nativeWindows.mm
) activeWindowObserver.h
を書きますactiveWindowObserver.mm
を実装しますnativeWindows.mm
を実装します
electron-rebuild
でビルドスクリプトをセットアップします。- ネイティブコードのための Typescript ラッパーを作成します。
main
Javascript プロセスからそれを呼び出し、結果をコンソールにログします。
前提条件:
- アプリは
Accessibility
権限が必要ですので、ターミナルから実行する場合は、ターミナルにAccessibility
権限を付与し、後でスタンドアロンアプリとしてビルドする場合は、アプリに付与してください。
VSCode のターミナルから実行する場合は、VSCode に権限が与えられていることを確認してください。これは、ターミナルを生成するオーナープロセスとして VSCode が機能するためです。他の統合ターミナルにも同様のことが適用されます。
- ある程度、Typescript と Electron に精通していることが前提となります。
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.h
、activeWindowObserver.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を使用すべきか?
次のReact NativeプロジェクトにExpoを使用するべきかどうかについての終わりのない議論が終わりに近づいています。ネタバレ注意:ExpoもReact Nativeも勝者です。
#dev
•
APR 10, 2024
•
16 min read
詳細はこちら
管理されたExpoワークフローReact NativeアプリケーションでGoogle OAuthを使用してFirebaseに認証する(Expo Goと互換性あり)
Typescriptを使用したReact Native ExpoアプリでExpo Goと互換性のあるGoogle OAuthログインでFirebase認証を設定するプロセスをご案内します。
#dev
•
AUG 23, 2023
•
12 min read