node-mac-recorder 2.22.14 → 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.14",
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,121 +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
- // Focused element text field mi kontrol et
1971
- NSString *role = CopyAttributeString(focusedElement, kAXRoleAttribute);
1972
- BOOL isEditable = NO;
1973
- CopyAttributeBoolean(focusedElement, CFSTR("AXEditable"), &isEditable);
1974
-
1975
- BOOL isTextField = StringEqualsAny(role, @[
1976
- @"AXTextField", @"AXTextArea", @"AXTextView",
1977
- @"AXTextEditor", @"AXSearchField",
1978
- @"AXComboBox"
1979
- ]) || isEditable;
1980
-
1981
- if (!isTextField) {
1982
- CFRelease(focusedElement);
1983
- CFRelease(systemWide);
1984
- return;
1985
- }
1986
-
1987
- // Input frame bilgisini al (AXPosition + AXSize)
1988
- CGPoint inputOrigin = CGPointZero;
1989
- CGSize inputSize = CGSizeZero;
1990
- AXValueRef positionValue = NULL;
1991
- AXValueRef sizeValue = NULL;
1992
-
1993
- AXUIElementCopyAttributeValue(focusedElement, kAXPositionAttribute, (CFTypeRef *)&positionValue);
1994
- AXUIElementCopyAttributeValue(focusedElement, kAXSizeAttribute, (CFTypeRef *)&sizeValue);
1995
-
1996
- if (positionValue) {
1997
- AXValueGetValue(positionValue, kAXValueTypeCGPoint, &inputOrigin);
1998
- CFRelease(positionValue);
1999
- }
2000
- if (sizeValue) {
2001
- AXValueGetValue(sizeValue, kAXValueTypeCGSize, &inputSize);
2002
- CFRelease(sizeValue);
2003
- }
2004
-
2005
- // Caret pozisyonunu al (AXSelectedTextRange → AXBoundsForRange)
2006
- CGPoint caretPos = CGPointMake(inputOrigin.x, inputOrigin.y);
2007
- BOOL hasCaretPos = NO;
2008
-
2009
- CFTypeRef selectedRangeValue = NULL;
2010
- AXError rangeErr = AXUIElementCopyAttributeValue(
2011
- focusedElement, CFSTR("AXSelectedTextRange"), &selectedRangeValue);
2012
-
2013
- if (rangeErr == kAXErrorSuccess && selectedRangeValue) {
2014
- CFTypeRef boundsValue = NULL;
2015
- AXError boundsErr = AXUIElementCopyParameterizedAttributeValue(
2016
- focusedElement, CFSTR("AXBoundsForRange"),
2017
- selectedRangeValue, &boundsValue);
2018
-
2019
- if (boundsErr == kAXErrorSuccess && boundsValue) {
2020
- CGRect caretBounds = CGRectZero;
2021
- if (AXValueGetValue((AXValueRef)boundsValue, kAXValueTypeCGRect, &caretBounds)) {
2022
- // Caret'in dikey ortası
2023
- caretPos = CGPointMake(
2024
- caretBounds.origin.x,
2025
- caretBounds.origin.y + caretBounds.size.height / 2.0
2026
- );
2027
- hasCaretPos = YES;
2028
- }
2029
- CFRelease(boundsValue);
2030
- }
2031
- CFRelease(selectedRangeValue);
2032
- }
2033
-
2034
- // Caret alınamazsa input frame'in sol ortasını kullan
2035
- if (!hasCaretPos) {
2036
- caretPos = CGPointMake(inputOrigin.x + 4, inputOrigin.y + inputSize.height / 2.0);
2037
- }
2038
-
2039
- // textInput event'i oluştur ve dosyaya yaz
2040
- NSDictionary *textInputInfo = @{
2041
- @"x": @((int)mouseLocation.x),
2042
- @"y": @((int)mouseLocation.y),
2043
- @"timestamp": @(timestamp),
2044
- @"unixTimeMs": @(unixTimeMs),
2045
- @"cursorType": @"text",
2046
- @"type": @"textInput",
2047
- @"caretX": @((int)caretPos.x),
2048
- @"caretY": @((int)caretPos.y),
2049
- @"inputFrame": @{
2050
- @"x": @((int)inputOrigin.x),
2051
- @"y": @((int)inputOrigin.y),
2052
- @"width": @((int)inputSize.width),
2053
- @"height": @((int)inputSize.height)
2054
- }
2055
- };
1956
+ NSDictionary *snap = MRTextInputSnapshotDictionary();
1957
+ if (!snap) {
1958
+ return;
1959
+ }
2056
1960
 
2057
- writeToFile(textInputInfo);
2058
- 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";
2059
1968
 
2060
- CFRelease(focusedElement);
2061
- CFRelease(systemWide);
2062
- } @catch (NSException *exception) {
2063
- // Accessibility hata verirse sessizce devam et
2064
- }
2065
- }
1969
+ writeToFile(textInputInfo);
1970
+ g_lastTextInputEmitTime = unixTimeMs;
2066
1971
  }
2067
1972
 
2068
1973
  // Event callback for mouse events
@@ -2673,6 +2578,33 @@ Napi::Value GetCursorDebugInfo(const Napi::CallbackInfo& info) {
2673
2578
  }
2674
2579
  }
2675
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
+
2676
2608
  // Export functions
2677
2609
  Napi::Object InitCursorTracker(Napi::Env env, Napi::Object exports) {
2678
2610
  exports.Set("startCursorTracking", Napi::Function::New(env, StartCursorTracking));
@@ -2680,6 +2612,7 @@ Napi::Object InitCursorTracker(Napi::Env env, Napi::Object exports) {
2680
2612
  exports.Set("getCursorPosition", Napi::Function::New(env, GetCursorPosition));
2681
2613
  exports.Set("getCursorTrackingStatus", Napi::Function::New(env, GetCursorTrackingStatus));
2682
2614
  exports.Set("getCursorDebugInfo", Napi::Function::New(env, GetCursorDebugInfo));
2615
+ exports.Set("getTextInputSnapshot", Napi::Function::New(env, GetTextInputSnapshot));
2683
2616
 
2684
2617
  return exports;
2685
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);