node-mac-recorder 2.22.15 → 2.22.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,404 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const { resolveCursorDisplayInfo } = require("./displayInfo");
5
+
6
+ const TEXT_INPUT_SAMPLE_MS = 95;
7
+
8
+ function shouldCaptureCursorSample(lastCapturedData, currentData) {
9
+ if (!lastCapturedData) {
10
+ return true;
11
+ }
12
+ const last = lastCapturedData;
13
+ if (currentData.type !== last.type) {
14
+ return true;
15
+ }
16
+ if (
17
+ Math.abs(currentData.x - last.x) >= 2 ||
18
+ Math.abs(currentData.y - last.y) >= 2
19
+ ) {
20
+ return true;
21
+ }
22
+ if (currentData.cursorType !== last.cursorType) {
23
+ return true;
24
+ }
25
+ return false;
26
+ }
27
+
28
+ function transformGlobalToVideo(globalX, globalY, d) {
29
+ if (!d || !d.videoRelative) {
30
+ return {
31
+ x: globalX,
32
+ y: globalY,
33
+ coordinateSystem: "global",
34
+ outsideVideo: false,
35
+ };
36
+ }
37
+ const displayRelativeX = globalX - d.displayX;
38
+ const displayRelativeY = globalY - d.displayY;
39
+ const x = displayRelativeX - d.videoOffsetX;
40
+ const y = displayRelativeY - d.videoOffsetY;
41
+ const outsideVideo =
42
+ x < 0 ||
43
+ y < 0 ||
44
+ x >= d.videoWidth ||
45
+ y >= d.videoHeight;
46
+ return {
47
+ x,
48
+ y,
49
+ coordinateSystem: outsideVideo
50
+ ? "video-relative-outside"
51
+ : "video-relative",
52
+ outsideVideo,
53
+ };
54
+ }
55
+
56
+ function transformInputFrameGlobal(ifr, d) {
57
+ if (!ifr || typeof ifr !== "object") {
58
+ return {};
59
+ }
60
+ const ox = Number(ifr.x);
61
+ const oy = Number(ifr.y);
62
+ const tw = transformGlobalToVideo(ox, oy, d);
63
+ return {
64
+ x: tw.x,
65
+ y: tw.y,
66
+ width: Number(ifr.width) || 0,
67
+ height: Number(ifr.height) || 0,
68
+ };
69
+ }
70
+
71
+ function tryAppendTextInput(
72
+ recorder,
73
+ nativeBinding,
74
+ filepath,
75
+ position,
76
+ timestamp,
77
+ ) {
78
+ if (typeof nativeBinding.getTextInputSnapshot !== "function") {
79
+ return;
80
+ }
81
+ const wall = Date.now();
82
+ if (wall - (recorder._tiSampleWallMs || 0) < TEXT_INPUT_SAMPLE_MS) {
83
+ return;
84
+ }
85
+ recorder._tiSampleWallMs = wall;
86
+
87
+ let snap = null;
88
+ try {
89
+ snap = nativeBinding.getTextInputSnapshot();
90
+ } catch {
91
+ return;
92
+ }
93
+ if (
94
+ !snap ||
95
+ !Number.isFinite(snap.caretX) ||
96
+ !Number.isFinite(snap.caretY)
97
+ ) {
98
+ return;
99
+ }
100
+
101
+ const d = recorder.cursorDisplayInfo;
102
+ const caretT = transformGlobalToVideo(snap.caretX, snap.caretY, d);
103
+ const mouseGX = position.x;
104
+ const mouseGY = position.y;
105
+ const mouseT = transformGlobalToVideo(mouseGX, mouseGY, d);
106
+
107
+ const inputFrameVid = transformInputFrameGlobal(snap.inputFrame, d);
108
+
109
+ const tiRow = {
110
+ x: mouseT.x,
111
+ y: mouseT.y,
112
+ timestamp,
113
+ unixTimeMs: wall,
114
+ cursorType: "text",
115
+ type: "textInput",
116
+ caretX: caretT.x,
117
+ caretY: caretT.y,
118
+ inputFrame: inputFrameVid,
119
+ coordinateSystem: caretT.coordinateSystem,
120
+ recordingType: d?.recordingType || "display",
121
+ videoInfo: d
122
+ ? {
123
+ width: d.videoWidth,
124
+ height: d.videoHeight,
125
+ offsetX: d.videoOffsetX,
126
+ offsetY: d.videoOffsetY,
127
+ }
128
+ : {},
129
+ displayInfo: d
130
+ ? {
131
+ displayId: d.displayId,
132
+ width: d.displayWidth,
133
+ height: d.displayHeight,
134
+ }
135
+ : {},
136
+ };
137
+
138
+ if (
139
+ recorder.cursorCaptureFirstWrite &&
140
+ recorder.cursorCaptureSessionTimestamp
141
+ ) {
142
+ tiRow._syncMetadata = {
143
+ videoStartTime: recorder.cursorCaptureSessionTimestamp,
144
+ cursorStartTime: recorder.cursorCaptureStartTime,
145
+ offsetMs:
146
+ recorder.cursorCaptureStartTime -
147
+ recorder.cursorCaptureSessionTimestamp,
148
+ };
149
+ }
150
+
151
+ const le = recorder._lastTextInputEmitted;
152
+ if (
153
+ le &&
154
+ Math.abs(le.caretX - tiRow.caretX) < 0.75 &&
155
+ Math.abs(le.caretY - tiRow.caretY) < 0.75 &&
156
+ timestamp - le.timestamp < 220
157
+ ) {
158
+ return;
159
+ }
160
+ recorder._lastTextInputEmitted = {
161
+ caretX: tiRow.caretX,
162
+ caretY: tiRow.caretY,
163
+ timestamp,
164
+ };
165
+
166
+ const jsonString = JSON.stringify(tiRow);
167
+ if (recorder.cursorCaptureFirstWrite) {
168
+ fs.appendFileSync(filepath, jsonString);
169
+ recorder.cursorCaptureFirstWrite = false;
170
+ } else {
171
+ fs.appendFileSync(filepath, "," + jsonString);
172
+ }
173
+ }
174
+
175
+ async function startCursorCapture(recorder, nativeBinding, intervalOrFilepath, options = {}) {
176
+ let filepath;
177
+ let interval = 20;
178
+
179
+ if (typeof intervalOrFilepath === "number") {
180
+ interval = Math.max(10, intervalOrFilepath);
181
+ filepath = `cursor-data-${Date.now()}.json`;
182
+ } else if (typeof intervalOrFilepath === "string") {
183
+ filepath = intervalOrFilepath;
184
+ } else {
185
+ throw new Error("Parameter must be interval (number) or filepath (string)");
186
+ }
187
+
188
+ if (recorder.cursorCaptureInterval) {
189
+ throw new Error("Cursor capture is already running");
190
+ }
191
+
192
+ const syncStartTime = options.startTimestamp || Date.now();
193
+
194
+ if (options.multiWindowBounds && options.multiWindowBounds.length > 0) {
195
+ try {
196
+ const allWindows = await recorder.getWindows();
197
+ for (const windowInfo of options.multiWindowBounds) {
198
+ const windowData = allWindows.find(
199
+ (w) => w.id === windowInfo.windowId,
200
+ );
201
+ if (windowData) {
202
+ windowInfo.bounds = {
203
+ x: windowData.x || 0,
204
+ y: windowData.y || 0,
205
+ width: windowData.width || 0,
206
+ height: windowData.height || 0,
207
+ };
208
+ }
209
+ }
210
+ } catch (error) {
211
+ console.warn(
212
+ "Failed to fetch window bounds for multi-window cursor tracking:",
213
+ error.message,
214
+ );
215
+ }
216
+ }
217
+
218
+ await resolveCursorDisplayInfo(recorder, options);
219
+
220
+ return new Promise((resolve, reject) => {
221
+ try {
222
+ fs.writeFileSync(filepath, "[");
223
+
224
+ recorder.cursorCaptureFile = filepath;
225
+ recorder.cursorCaptureStartTime = syncStartTime;
226
+ recorder.cursorCaptureFirstWrite = true;
227
+ recorder.lastCapturedData = null;
228
+ recorder.cursorCaptureSessionTimestamp = recorder.sessionTimestamp;
229
+ recorder._tiSampleWallMs = 0;
230
+ recorder._lastTextInputEmitted = null;
231
+
232
+ recorder.cursorCaptureInterval = setInterval(() => {
233
+ try {
234
+ const position = nativeBinding.getCursorPosition();
235
+ const timestamp =
236
+ Date.now() - recorder.cursorCaptureStartTime;
237
+
238
+ let x = position.x;
239
+ let y = position.y;
240
+ let coordinateSystem = "global";
241
+
242
+ const di = recorder.cursorDisplayInfo;
243
+ if (di && di.videoRelative) {
244
+ const displayRelativeX = position.x - di.displayX;
245
+ const displayRelativeY = position.y - di.displayY;
246
+ x = displayRelativeX - di.videoOffsetX;
247
+ y = displayRelativeY - di.videoOffsetY;
248
+ coordinateSystem = "video-relative";
249
+ const outsideVideo =
250
+ x < 0 ||
251
+ y < 0 ||
252
+ x >= di.videoWidth ||
253
+ y >= di.videoHeight;
254
+ if (outsideVideo) {
255
+ coordinateSystem = "video-relative-outside";
256
+ }
257
+ }
258
+
259
+ const cursorData = {
260
+ x,
261
+ y,
262
+ timestamp,
263
+ unixTimeMs: Date.now(),
264
+ cursorType: position.cursorType,
265
+ type: position.eventType || "move",
266
+ coordinateSystem,
267
+ recordingType: di?.recordingType || "display",
268
+ videoInfo: di
269
+ ? {
270
+ width: di.videoWidth,
271
+ height: di.videoHeight,
272
+ offsetX: di.videoOffsetX,
273
+ offsetY: di.videoOffsetY,
274
+ }
275
+ : {},
276
+ displayInfo: di
277
+ ? {
278
+ displayId: di.displayId,
279
+ width: di.displayWidth,
280
+ height: di.displayHeight,
281
+ }
282
+ : {},
283
+ };
284
+
285
+ if (di?.multiWindowBounds && di.multiWindowBounds.length > 0) {
286
+ const location = { hover: null, click: null };
287
+ let windowRelativeCoords = null;
288
+ for (const windowInfo of di.multiWindowBounds) {
289
+ if (windowInfo.bounds) {
290
+ const { x: wx, y: wy, width: ww, height: wh } =
291
+ windowInfo.bounds;
292
+ if (
293
+ position.x >= wx &&
294
+ position.x <= wx + ww &&
295
+ position.y >= wy &&
296
+ position.y <= wy + wh
297
+ ) {
298
+ location.hover = windowInfo.windowId;
299
+ windowRelativeCoords = {
300
+ windowId: windowInfo.windowId,
301
+ x: position.x - wx,
302
+ y: position.y - wy,
303
+ windowWidth: ww,
304
+ windowHeight: wh,
305
+ };
306
+ const eventType = position.eventType || "";
307
+ if (
308
+ eventType === "mousedown" ||
309
+ eventType === "mouseup" ||
310
+ eventType === "drag" ||
311
+ eventType === "rightmousedown" ||
312
+ eventType === "rightmouseup" ||
313
+ eventType === "rightdrag"
314
+ ) {
315
+ location.click = windowInfo.windowId;
316
+ }
317
+ break;
318
+ }
319
+ }
320
+ }
321
+ cursorData.location = location;
322
+ if (windowRelativeCoords) {
323
+ cursorData.windowRelative = windowRelativeCoords;
324
+ }
325
+ }
326
+
327
+ if (
328
+ recorder.cursorCaptureFirstWrite &&
329
+ recorder.cursorCaptureSessionTimestamp
330
+ ) {
331
+ cursorData._syncMetadata = {
332
+ videoStartTime: recorder.cursorCaptureSessionTimestamp,
333
+ cursorStartTime: recorder.cursorCaptureStartTime,
334
+ offsetMs:
335
+ recorder.cursorCaptureStartTime -
336
+ recorder.cursorCaptureSessionTimestamp,
337
+ };
338
+ }
339
+
340
+ if (shouldCaptureCursorSample(recorder.lastCapturedData, cursorData)) {
341
+ const jsonString = JSON.stringify(cursorData);
342
+ if (recorder.cursorCaptureFirstWrite) {
343
+ fs.appendFileSync(filepath, jsonString);
344
+ recorder.cursorCaptureFirstWrite = false;
345
+ } else {
346
+ fs.appendFileSync(filepath, "," + jsonString);
347
+ }
348
+ recorder.lastCapturedData = { ...cursorData };
349
+ }
350
+
351
+ tryAppendTextInput(
352
+ recorder,
353
+ nativeBinding,
354
+ filepath,
355
+ position,
356
+ timestamp,
357
+ );
358
+ } catch (error) {
359
+ console.error("Cursor capture error:", error);
360
+ }
361
+ }, interval);
362
+
363
+ recorder.emit("cursorCaptureStarted", filepath);
364
+ resolve(true);
365
+ } catch (error) {
366
+ reject(error);
367
+ }
368
+ });
369
+ }
370
+
371
+ async function stopCursorCapture(recorder) {
372
+ return new Promise((resolve, reject) => {
373
+ try {
374
+ if (!recorder.cursorCaptureInterval) {
375
+ return resolve(false);
376
+ }
377
+ clearInterval(recorder.cursorCaptureInterval);
378
+ recorder.cursorCaptureInterval = null;
379
+
380
+ if (recorder.cursorCaptureFile) {
381
+ fs.appendFileSync(recorder.cursorCaptureFile, "]");
382
+ recorder.cursorCaptureFile = null;
383
+ }
384
+
385
+ recorder.lastCapturedData = null;
386
+ recorder.cursorCaptureStartTime = null;
387
+ recorder.cursorCaptureFirstWrite = true;
388
+ recorder.cursorDisplayInfo = null;
389
+ recorder._tiSampleWallMs = 0;
390
+ recorder._lastTextInputEmitted = null;
391
+
392
+ recorder.emit("cursorCaptureStopped");
393
+ resolve(true);
394
+ } catch (error) {
395
+ reject(error);
396
+ }
397
+ });
398
+ }
399
+
400
+ module.exports = {
401
+ startCursorCapture,
402
+ stopCursorCapture,
403
+ shouldCaptureCursorSample,
404
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-mac-recorder",
3
- "version": "2.22.15",
3
+ "version": "2.22.16",
4
4
  "description": "Native macOS screen recording package for Node.js applications",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -7,6 +7,7 @@
7
7
  #import <Accessibility/Accessibility.h>
8
8
  #import <dispatch/dispatch.h>
9
9
  #import "logging.h"
10
+ #import "text_input_ax_snapshot.h"
10
11
  #include <vector>
11
12
  #include <math.h>
12
13
 
@@ -1948,125 +1949,25 @@ void writeToFile(NSDictionary *cursorData) {
1948
1949
 
1949
1950
  // Text input event: Klavye basıldığında focused text field'in caret pozisyonunu yakala
1950
1951
  static void emitTextInputEvent(NSTimeInterval timestamp, NSTimeInterval unixTimeMs, CGPoint mouseLocation) {
1951
- // Throttle: Çok sık emit etme (performans için)
1952
1952
  if (unixTimeMs - g_lastTextInputEmitTime < TEXT_INPUT_THROTTLE_MS) {
1953
1953
  return;
1954
1954
  }
1955
1955
 
1956
- @autoreleasepool {
1957
- @try {
1958
- AXUIElementRef systemWide = AXUIElementCreateSystemWide();
1959
- if (!systemWide) return;
1960
-
1961
- AXUIElementRef focusedElement = NULL;
1962
- AXError focusErr = AXUIElementCopyAttributeValue(
1963
- systemWide, kAXFocusedUIElementAttribute, (CFTypeRef *)&focusedElement);
1964
-
1965
- if (focusErr != kAXErrorSuccess || !focusedElement) {
1966
- CFRelease(systemWide);
1967
- return;
1968
- }
1969
-
1970
- NSString *role = CopyAttributeString(focusedElement, kAXRoleAttribute);
1971
- BOOL isEditable = NO;
1972
- CopyAttributeBoolean(focusedElement, CFSTR("AXEditable"), &isEditable);
1973
-
1974
- CFTypeRef selectedRangeValue = NULL;
1975
- AXError rangeProbeErr = AXUIElementCopyAttributeValue(
1976
- focusedElement, CFSTR("AXSelectedTextRange"), (CFTypeRef *)&selectedRangeValue);
1977
- BOOL hasTextRange = (rangeProbeErr == kAXErrorSuccess && selectedRangeValue != NULL);
1978
-
1979
- // Electron/Chromium: gerçek yazma genelde AXSelectedTextRange ile gelir; AXWebArea tek başına tüm sayfa olabilir.
1980
- BOOL isStandardTextRole = StringEqualsAny(role, @[
1981
- @"AXTextField", @"AXTextArea", @"AXTextView",
1982
- @"AXTextEditor", @"AXSearchField",
1983
- @"AXComboBox"
1984
- ]);
1985
- BOOL isWebAreaWithCaret = [role isEqualToString:@"AXWebArea"] && hasTextRange;
1986
- BOOL isTextField = isStandardTextRole || isEditable || isWebAreaWithCaret || hasTextRange;
1987
-
1988
- if (!isTextField) {
1989
- if (selectedRangeValue) CFRelease(selectedRangeValue);
1990
- CFRelease(focusedElement);
1991
- CFRelease(systemWide);
1992
- return;
1993
- }
1994
-
1995
- // Input frame bilgisini al (AXPosition + AXSize)
1996
- CGPoint inputOrigin = CGPointZero;
1997
- CGSize inputSize = CGSizeZero;
1998
- AXValueRef positionValue = NULL;
1999
- AXValueRef sizeValue = NULL;
2000
-
2001
- AXUIElementCopyAttributeValue(focusedElement, kAXPositionAttribute, (CFTypeRef *)&positionValue);
2002
- AXUIElementCopyAttributeValue(focusedElement, kAXSizeAttribute, (CFTypeRef *)&sizeValue);
2003
-
2004
- if (positionValue) {
2005
- AXValueGetValue(positionValue, kAXValueTypeCGPoint, &inputOrigin);
2006
- CFRelease(positionValue);
2007
- }
2008
- if (sizeValue) {
2009
- AXValueGetValue(sizeValue, kAXValueTypeCGSize, &inputSize);
2010
- CFRelease(sizeValue);
2011
- }
2012
-
2013
- // Caret pozisyonunu al (AXSelectedTextRange → AXBoundsForRange)
2014
- CGPoint caretPos = CGPointMake(inputOrigin.x, inputOrigin.y);
2015
- BOOL hasCaretPos = NO;
2016
-
2017
- if (selectedRangeValue) {
2018
- CFTypeRef boundsValue = NULL;
2019
- AXError boundsErr = AXUIElementCopyParameterizedAttributeValue(
2020
- focusedElement, CFSTR("AXBoundsForRange"),
2021
- selectedRangeValue, &boundsValue);
2022
-
2023
- if (boundsErr == kAXErrorSuccess && boundsValue) {
2024
- CGRect caretBounds = CGRectZero;
2025
- if (AXValueGetValue((AXValueRef)boundsValue, kAXValueTypeCGRect, &caretBounds)) {
2026
- caretPos = CGPointMake(
2027
- caretBounds.origin.x,
2028
- caretBounds.origin.y + caretBounds.size.height / 2.0
2029
- );
2030
- hasCaretPos = YES;
2031
- }
2032
- CFRelease(boundsValue);
2033
- }
2034
- CFRelease(selectedRangeValue);
2035
- selectedRangeValue = NULL;
2036
- }
2037
-
2038
- // Caret alınamazsa input frame'in sol ortasını kullan
2039
- if (!hasCaretPos) {
2040
- caretPos = CGPointMake(inputOrigin.x + 4, inputOrigin.y + inputSize.height / 2.0);
2041
- }
2042
-
2043
- // textInput event'i oluştur ve dosyaya yaz
2044
- NSDictionary *textInputInfo = @{
2045
- @"x": @((int)mouseLocation.x),
2046
- @"y": @((int)mouseLocation.y),
2047
- @"timestamp": @(timestamp),
2048
- @"unixTimeMs": @(unixTimeMs),
2049
- @"cursorType": @"text",
2050
- @"type": @"textInput",
2051
- @"caretX": @((int)caretPos.x),
2052
- @"caretY": @((int)caretPos.y),
2053
- @"inputFrame": @{
2054
- @"x": @((int)inputOrigin.x),
2055
- @"y": @((int)inputOrigin.y),
2056
- @"width": @((int)inputSize.width),
2057
- @"height": @((int)inputSize.height)
2058
- }
2059
- };
1956
+ NSDictionary *snap = MRTextInputSnapshotDictionary();
1957
+ if (!snap) {
1958
+ return;
1959
+ }
2060
1960
 
2061
- writeToFile(textInputInfo);
2062
- g_lastTextInputEmitTime = unixTimeMs;
1961
+ NSMutableDictionary *textInputInfo = [NSMutableDictionary dictionaryWithDictionary:snap];
1962
+ textInputInfo[@"x"] = @((int)mouseLocation.x);
1963
+ textInputInfo[@"y"] = @((int)mouseLocation.y);
1964
+ textInputInfo[@"timestamp"] = @(timestamp);
1965
+ textInputInfo[@"unixTimeMs"] = @(unixTimeMs);
1966
+ textInputInfo[@"cursorType"] = @"text";
1967
+ textInputInfo[@"type"] = @"textInput";
2063
1968
 
2064
- CFRelease(focusedElement);
2065
- CFRelease(systemWide);
2066
- } @catch (NSException *exception) {
2067
- // Accessibility hata verirse sessizce devam et
2068
- }
2069
- }
1969
+ writeToFile(textInputInfo);
1970
+ g_lastTextInputEmitTime = unixTimeMs;
2070
1971
  }
2071
1972
 
2072
1973
  // Event callback for mouse events
@@ -2677,6 +2578,33 @@ Napi::Value GetCursorDebugInfo(const Napi::CallbackInfo& info) {
2677
2578
  }
2678
2579
  }
2679
2580
 
2581
+ static Napi::Value DictToNapiTextInputSnapshot(Napi::Env env, NSDictionary *snap) {
2582
+ Napi::Object o = Napi::Object::New(env);
2583
+ NSNumber *cx = snap[@"caretX"];
2584
+ NSNumber *cy = snap[@"caretY"];
2585
+ o.Set("caretX", Napi::Number::New(env, cx ? [cx doubleValue] : 0));
2586
+ o.Set("caretY", Napi::Number::New(env, cy ? [cy doubleValue] : 0));
2587
+ NSDictionary *frame = snap[@"inputFrame"];
2588
+ Napi::Object fo = Napi::Object::New(env);
2589
+ if ([frame isKindOfClass:[NSDictionary class]]) {
2590
+ fo.Set("x", Napi::Number::New(env, [frame[@"x"] doubleValue]));
2591
+ fo.Set("y", Napi::Number::New(env, [frame[@"y"] doubleValue]));
2592
+ fo.Set("width", Napi::Number::New(env, [frame[@"width"] doubleValue]));
2593
+ fo.Set("height", Napi::Number::New(env, [frame[@"height"] doubleValue]));
2594
+ }
2595
+ o.Set("inputFrame", fo);
2596
+ return o;
2597
+ }
2598
+
2599
+ static Napi::Value GetTextInputSnapshot(const Napi::CallbackInfo& info) {
2600
+ Napi::Env env = info.Env();
2601
+ NSDictionary *snap = MRTextInputSnapshotDictionary();
2602
+ if (!snap) {
2603
+ return env.Null();
2604
+ }
2605
+ return DictToNapiTextInputSnapshot(env, snap);
2606
+ }
2607
+
2680
2608
  // Export functions
2681
2609
  Napi::Object InitCursorTracker(Napi::Env env, Napi::Object exports) {
2682
2610
  exports.Set("startCursorTracking", Napi::Function::New(env, StartCursorTracking));
@@ -2684,6 +2612,7 @@ Napi::Object InitCursorTracker(Napi::Env env, Napi::Object exports) {
2684
2612
  exports.Set("getCursorPosition", Napi::Function::New(env, GetCursorPosition));
2685
2613
  exports.Set("getCursorTrackingStatus", Napi::Function::New(env, GetCursorTrackingStatus));
2686
2614
  exports.Set("getCursorDebugInfo", Napi::Function::New(env, GetCursorDebugInfo));
2615
+ exports.Set("getTextInputSnapshot", Napi::Function::New(env, GetTextInputSnapshot));
2687
2616
 
2688
2617
  return exports;
2689
2618
  }
@@ -2,6 +2,7 @@
2
2
  #import <CoreGraphics/CoreGraphics.h>
3
3
  #import <AppKit/AppKit.h>
4
4
  #import "../logging.h"
5
+ #import "../text_input_ax_snapshot.h"
5
6
 
6
7
  // Thread-safe cursor tracking for Electron
7
8
  static dispatch_queue_t g_cursorQueue = nil;
@@ -92,12 +93,45 @@ Napi::Value GetCursorPositionElectronSafe(const Napi::CallbackInfo& info) {
92
93
  }
93
94
  }
94
95
 
96
+ static Napi::Value DictToNapiTextInputSnapshotElectron(Napi::Env env, NSDictionary *snap) {
97
+ Napi::Object o = Napi::Object::New(env);
98
+ NSNumber *cx = snap[@"caretX"];
99
+ NSNumber *cy = snap[@"caretY"];
100
+ o.Set("caretX", Napi::Number::New(env, cx ? [cx doubleValue] : 0));
101
+ o.Set("caretY", Napi::Number::New(env, cy ? [cy doubleValue] : 0));
102
+ NSDictionary *frame = snap[@"inputFrame"];
103
+ Napi::Object fo = Napi::Object::New(env);
104
+ if ([frame isKindOfClass:[NSDictionary class]]) {
105
+ fo.Set("x", Napi::Number::New(env, [frame[@"x"] doubleValue]));
106
+ fo.Set("y", Napi::Number::New(env, [frame[@"y"] doubleValue]));
107
+ fo.Set("width", Napi::Number::New(env, [frame[@"width"] doubleValue]));
108
+ fo.Set("height", Napi::Number::New(env, [frame[@"height"] doubleValue]));
109
+ }
110
+ o.Set("inputFrame", fo);
111
+ return o;
112
+ }
113
+
114
+ Napi::Value GetTextInputSnapshotElectronSafe(const Napi::CallbackInfo& info) {
115
+ Napi::Env env = info.Env();
116
+ @try {
117
+ NSDictionary *snap = MRTextInputSnapshotDictionary();
118
+ if (!snap) {
119
+ return env.Null();
120
+ }
121
+ return DictToNapiTextInputSnapshotElectron(env, snap);
122
+ } @catch (NSException *e) {
123
+ NSLog(@"❌ getTextInputSnapshot: %@", e.reason);
124
+ return env.Null();
125
+ }
126
+ }
127
+
95
128
  // Initialize cursor tracker module
96
129
  Napi::Object InitCursorTrackerElectron(Napi::Env env, Napi::Object exports) {
97
130
  @try {
98
131
  initializeCursorQueue();
99
132
 
100
133
  exports.Set("getCursorPosition", Napi::Function::New(env, GetCursorPositionElectronSafe));
134
+ exports.Set("getTextInputSnapshot", Napi::Function::New(env, GetTextInputSnapshotElectronSafe));
101
135
 
102
136
  MRLog(@"✅ Electron-safe cursor tracker initialized");
103
137
  return exports;
@@ -0,0 +1,3 @@
1
+ #import <Foundation/Foundation.h>
2
+
3
+ NSDictionary *_Nullable MRTextInputSnapshotDictionary(void);