node-mac-recorder 2.22.15 → 2.22.17
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.
- package/binding.gyp +1 -0
- package/electron-safe-binding.gyp +1 -0
- package/electron-safe-index.js +196 -104
- package/index.js +8 -355
- package/lib/cursorCapture/displayInfo.js +110 -0
- package/lib/cursorCapture/polling.js +462 -0
- package/package.json +1 -1
- package/src/cursor_tracker.mm +42 -113
- package/src/electron_safe/cursor_tracker_electron.mm +34 -0
- package/src/text_input_ax_snapshot.h +3 -0
- package/src/text_input_ax_snapshot.mm +161 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const { resolveCursorDisplayInfo } = require("./displayInfo");
|
|
5
|
+
|
|
6
|
+
const IS_ELECTRON = !!(
|
|
7
|
+
process &&
|
|
8
|
+
process.versions &&
|
|
9
|
+
process.versions.electron
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
const TEXT_INPUT_SAMPLE_MS = IS_ELECTRON ? 280 : 95;
|
|
13
|
+
|
|
14
|
+
const TEXT_INPUT_GRACE_MS = IS_ELECTRON ? 3200 : 600;
|
|
15
|
+
|
|
16
|
+
function shouldCaptureCursorSample(lastCapturedData, currentData) {
|
|
17
|
+
if (!lastCapturedData) {
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
const last = lastCapturedData;
|
|
21
|
+
if (currentData.type !== last.type) {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
if (
|
|
25
|
+
Math.abs(currentData.x - last.x) >= 2 ||
|
|
26
|
+
Math.abs(currentData.y - last.y) >= 2
|
|
27
|
+
) {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
if (currentData.cursorType !== last.cursorType) {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function transformGlobalToVideo(globalX, globalY, d) {
|
|
37
|
+
if (!d || !d.videoRelative) {
|
|
38
|
+
return {
|
|
39
|
+
x: globalX,
|
|
40
|
+
y: globalY,
|
|
41
|
+
coordinateSystem: "global",
|
|
42
|
+
outsideVideo: false,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const displayRelativeX = globalX - d.displayX;
|
|
46
|
+
const displayRelativeY = globalY - d.displayY;
|
|
47
|
+
const x = displayRelativeX - d.videoOffsetX;
|
|
48
|
+
const y = displayRelativeY - d.videoOffsetY;
|
|
49
|
+
const outsideVideo =
|
|
50
|
+
x < 0 ||
|
|
51
|
+
y < 0 ||
|
|
52
|
+
x >= d.videoWidth ||
|
|
53
|
+
y >= d.videoHeight;
|
|
54
|
+
return {
|
|
55
|
+
x,
|
|
56
|
+
y,
|
|
57
|
+
coordinateSystem: outsideVideo
|
|
58
|
+
? "video-relative-outside"
|
|
59
|
+
: "video-relative",
|
|
60
|
+
outsideVideo,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function transformInputFrameGlobal(ifr, d) {
|
|
65
|
+
if (!ifr || typeof ifr !== "object") {
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
const ox = Number(ifr.x);
|
|
69
|
+
const oy = Number(ifr.y);
|
|
70
|
+
const tw = transformGlobalToVideo(ox, oy, d);
|
|
71
|
+
return {
|
|
72
|
+
x: tw.x,
|
|
73
|
+
y: tw.y,
|
|
74
|
+
width: Number(ifr.width) || 0,
|
|
75
|
+
height: Number(ifr.height) || 0,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function tryAppendTextInput(
|
|
80
|
+
recorder,
|
|
81
|
+
nativeBinding,
|
|
82
|
+
filepath,
|
|
83
|
+
position,
|
|
84
|
+
timestamp,
|
|
85
|
+
) {
|
|
86
|
+
if (typeof nativeBinding.getTextInputSnapshot !== "function") {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (timestamp < TEXT_INPUT_GRACE_MS) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const ct = position.cursorType || "";
|
|
93
|
+
if (ct !== "text" && ct !== "vertical-text") {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const wall = Date.now();
|
|
97
|
+
if (wall - (recorder._tiSampleWallMs || 0) < TEXT_INPUT_SAMPLE_MS) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
recorder._tiSampleWallMs = wall;
|
|
101
|
+
|
|
102
|
+
let snap = null;
|
|
103
|
+
try {
|
|
104
|
+
snap = nativeBinding.getTextInputSnapshot();
|
|
105
|
+
} catch {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (
|
|
109
|
+
!snap ||
|
|
110
|
+
!Number.isFinite(snap.caretX) ||
|
|
111
|
+
!Number.isFinite(snap.caretY)
|
|
112
|
+
) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const d = recorder.cursorDisplayInfo;
|
|
117
|
+
const caretT = transformGlobalToVideo(snap.caretX, snap.caretY, d);
|
|
118
|
+
const mouseGX = position.x;
|
|
119
|
+
const mouseGY = position.y;
|
|
120
|
+
const mouseT = transformGlobalToVideo(mouseGX, mouseGY, d);
|
|
121
|
+
|
|
122
|
+
const inputFrameVid = transformInputFrameGlobal(snap.inputFrame, d);
|
|
123
|
+
|
|
124
|
+
const tiRow = {
|
|
125
|
+
x: mouseT.x,
|
|
126
|
+
y: mouseT.y,
|
|
127
|
+
timestamp,
|
|
128
|
+
unixTimeMs: wall,
|
|
129
|
+
cursorType: "text",
|
|
130
|
+
type: "textInput",
|
|
131
|
+
caretX: caretT.x,
|
|
132
|
+
caretY: caretT.y,
|
|
133
|
+
inputFrame: inputFrameVid,
|
|
134
|
+
coordinateSystem: caretT.coordinateSystem,
|
|
135
|
+
recordingType: d?.recordingType || "display",
|
|
136
|
+
videoInfo: d
|
|
137
|
+
? {
|
|
138
|
+
width: d.videoWidth,
|
|
139
|
+
height: d.videoHeight,
|
|
140
|
+
offsetX: d.videoOffsetX,
|
|
141
|
+
offsetY: d.videoOffsetY,
|
|
142
|
+
}
|
|
143
|
+
: {},
|
|
144
|
+
displayInfo: d
|
|
145
|
+
? {
|
|
146
|
+
displayId: d.displayId,
|
|
147
|
+
width: d.displayWidth,
|
|
148
|
+
height: d.displayHeight,
|
|
149
|
+
}
|
|
150
|
+
: {},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
if (
|
|
154
|
+
recorder.cursorCaptureFirstWrite &&
|
|
155
|
+
recorder.cursorCaptureSessionTimestamp
|
|
156
|
+
) {
|
|
157
|
+
tiRow._syncMetadata = {
|
|
158
|
+
videoStartTime: recorder.cursorCaptureSessionTimestamp,
|
|
159
|
+
cursorStartTime: recorder.cursorCaptureStartTime,
|
|
160
|
+
offsetMs:
|
|
161
|
+
recorder.cursorCaptureStartTime -
|
|
162
|
+
recorder.cursorCaptureSessionTimestamp,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const le = recorder._lastTextInputEmitted;
|
|
167
|
+
if (
|
|
168
|
+
le &&
|
|
169
|
+
Math.abs(le.caretX - tiRow.caretX) < 0.75 &&
|
|
170
|
+
Math.abs(le.caretY - tiRow.caretY) < 0.75 &&
|
|
171
|
+
timestamp - le.timestamp < 220
|
|
172
|
+
) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
recorder._lastTextInputEmitted = {
|
|
176
|
+
caretX: tiRow.caretX,
|
|
177
|
+
caretY: tiRow.caretY,
|
|
178
|
+
timestamp,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const jsonString = JSON.stringify(tiRow);
|
|
182
|
+
if (recorder.cursorCaptureFirstWrite) {
|
|
183
|
+
fs.appendFileSync(filepath, jsonString);
|
|
184
|
+
recorder.cursorCaptureFirstWrite = false;
|
|
185
|
+
} else {
|
|
186
|
+
fs.appendFileSync(filepath, "," + jsonString);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function queueDeferredTextInputSample(
|
|
191
|
+
recorder,
|
|
192
|
+
nativeBinding,
|
|
193
|
+
filepath,
|
|
194
|
+
position,
|
|
195
|
+
timestamp,
|
|
196
|
+
) {
|
|
197
|
+
if (typeof nativeBinding.getTextInputSnapshot !== "function") {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (timestamp < TEXT_INPUT_GRACE_MS) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const ct = position.cursorType || "";
|
|
204
|
+
if (ct !== "text" && ct !== "vertical-text") {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (recorder._tiDeferredPending) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
recorder._tiDeferredPending = true;
|
|
211
|
+
const pos = {
|
|
212
|
+
x: position.x,
|
|
213
|
+
y: position.y,
|
|
214
|
+
cursorType: position.cursorType,
|
|
215
|
+
eventType: position.eventType,
|
|
216
|
+
};
|
|
217
|
+
const ts = timestamp;
|
|
218
|
+
setImmediate(() => {
|
|
219
|
+
recorder._tiDeferredPending = false;
|
|
220
|
+
if (!recorder.cursorCaptureFile || recorder.cursorCaptureFile !== filepath) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
tryAppendTextInput(recorder, nativeBinding, filepath, pos, ts);
|
|
225
|
+
} catch {
|
|
226
|
+
/* ignore */
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function startCursorCapture(recorder, nativeBinding, intervalOrFilepath, options = {}) {
|
|
232
|
+
let filepath;
|
|
233
|
+
let interval = 20;
|
|
234
|
+
|
|
235
|
+
if (typeof intervalOrFilepath === "number") {
|
|
236
|
+
interval = Math.max(10, intervalOrFilepath);
|
|
237
|
+
filepath = `cursor-data-${Date.now()}.json`;
|
|
238
|
+
} else if (typeof intervalOrFilepath === "string") {
|
|
239
|
+
filepath = intervalOrFilepath;
|
|
240
|
+
} else {
|
|
241
|
+
throw new Error("Parameter must be interval (number) or filepath (string)");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (recorder.cursorCaptureInterval) {
|
|
245
|
+
throw new Error("Cursor capture is already running");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const syncStartTime = options.startTimestamp || Date.now();
|
|
249
|
+
|
|
250
|
+
if (options.multiWindowBounds && options.multiWindowBounds.length > 0) {
|
|
251
|
+
try {
|
|
252
|
+
const allWindows = await recorder.getWindows();
|
|
253
|
+
for (const windowInfo of options.multiWindowBounds) {
|
|
254
|
+
const windowData = allWindows.find(
|
|
255
|
+
(w) => w.id === windowInfo.windowId,
|
|
256
|
+
);
|
|
257
|
+
if (windowData) {
|
|
258
|
+
windowInfo.bounds = {
|
|
259
|
+
x: windowData.x || 0,
|
|
260
|
+
y: windowData.y || 0,
|
|
261
|
+
width: windowData.width || 0,
|
|
262
|
+
height: windowData.height || 0,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
} catch (error) {
|
|
267
|
+
console.warn(
|
|
268
|
+
"Failed to fetch window bounds for multi-window cursor tracking:",
|
|
269
|
+
error.message,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
await resolveCursorDisplayInfo(recorder, options);
|
|
275
|
+
|
|
276
|
+
return new Promise((resolve, reject) => {
|
|
277
|
+
try {
|
|
278
|
+
fs.writeFileSync(filepath, "[");
|
|
279
|
+
|
|
280
|
+
recorder.cursorCaptureFile = filepath;
|
|
281
|
+
recorder.cursorCaptureStartTime = syncStartTime;
|
|
282
|
+
recorder.cursorCaptureFirstWrite = true;
|
|
283
|
+
recorder.lastCapturedData = null;
|
|
284
|
+
recorder.cursorCaptureSessionTimestamp = recorder.sessionTimestamp;
|
|
285
|
+
recorder._tiSampleWallMs = 0;
|
|
286
|
+
recorder._lastTextInputEmitted = null;
|
|
287
|
+
recorder._tiDeferredPending = false;
|
|
288
|
+
|
|
289
|
+
recorder.cursorCaptureInterval = setInterval(() => {
|
|
290
|
+
try {
|
|
291
|
+
const position = nativeBinding.getCursorPosition();
|
|
292
|
+
const timestamp =
|
|
293
|
+
Date.now() - recorder.cursorCaptureStartTime;
|
|
294
|
+
|
|
295
|
+
let x = position.x;
|
|
296
|
+
let y = position.y;
|
|
297
|
+
let coordinateSystem = "global";
|
|
298
|
+
|
|
299
|
+
const di = recorder.cursorDisplayInfo;
|
|
300
|
+
if (di && di.videoRelative) {
|
|
301
|
+
const displayRelativeX = position.x - di.displayX;
|
|
302
|
+
const displayRelativeY = position.y - di.displayY;
|
|
303
|
+
x = displayRelativeX - di.videoOffsetX;
|
|
304
|
+
y = displayRelativeY - di.videoOffsetY;
|
|
305
|
+
coordinateSystem = "video-relative";
|
|
306
|
+
const outsideVideo =
|
|
307
|
+
x < 0 ||
|
|
308
|
+
y < 0 ||
|
|
309
|
+
x >= di.videoWidth ||
|
|
310
|
+
y >= di.videoHeight;
|
|
311
|
+
if (outsideVideo) {
|
|
312
|
+
coordinateSystem = "video-relative-outside";
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const cursorData = {
|
|
317
|
+
x,
|
|
318
|
+
y,
|
|
319
|
+
timestamp,
|
|
320
|
+
unixTimeMs: Date.now(),
|
|
321
|
+
cursorType: position.cursorType,
|
|
322
|
+
type: position.eventType || "move",
|
|
323
|
+
coordinateSystem,
|
|
324
|
+
recordingType: di?.recordingType || "display",
|
|
325
|
+
videoInfo: di
|
|
326
|
+
? {
|
|
327
|
+
width: di.videoWidth,
|
|
328
|
+
height: di.videoHeight,
|
|
329
|
+
offsetX: di.videoOffsetX,
|
|
330
|
+
offsetY: di.videoOffsetY,
|
|
331
|
+
}
|
|
332
|
+
: {},
|
|
333
|
+
displayInfo: di
|
|
334
|
+
? {
|
|
335
|
+
displayId: di.displayId,
|
|
336
|
+
width: di.displayWidth,
|
|
337
|
+
height: di.displayHeight,
|
|
338
|
+
}
|
|
339
|
+
: {},
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
if (di?.multiWindowBounds && di.multiWindowBounds.length > 0) {
|
|
343
|
+
const location = { hover: null, click: null };
|
|
344
|
+
let windowRelativeCoords = null;
|
|
345
|
+
for (const windowInfo of di.multiWindowBounds) {
|
|
346
|
+
if (windowInfo.bounds) {
|
|
347
|
+
const { x: wx, y: wy, width: ww, height: wh } =
|
|
348
|
+
windowInfo.bounds;
|
|
349
|
+
if (
|
|
350
|
+
position.x >= wx &&
|
|
351
|
+
position.x <= wx + ww &&
|
|
352
|
+
position.y >= wy &&
|
|
353
|
+
position.y <= wy + wh
|
|
354
|
+
) {
|
|
355
|
+
location.hover = windowInfo.windowId;
|
|
356
|
+
windowRelativeCoords = {
|
|
357
|
+
windowId: windowInfo.windowId,
|
|
358
|
+
x: position.x - wx,
|
|
359
|
+
y: position.y - wy,
|
|
360
|
+
windowWidth: ww,
|
|
361
|
+
windowHeight: wh,
|
|
362
|
+
};
|
|
363
|
+
const eventType = position.eventType || "";
|
|
364
|
+
if (
|
|
365
|
+
eventType === "mousedown" ||
|
|
366
|
+
eventType === "mouseup" ||
|
|
367
|
+
eventType === "drag" ||
|
|
368
|
+
eventType === "rightmousedown" ||
|
|
369
|
+
eventType === "rightmouseup" ||
|
|
370
|
+
eventType === "rightdrag"
|
|
371
|
+
) {
|
|
372
|
+
location.click = windowInfo.windowId;
|
|
373
|
+
}
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
cursorData.location = location;
|
|
379
|
+
if (windowRelativeCoords) {
|
|
380
|
+
cursorData.windowRelative = windowRelativeCoords;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (
|
|
385
|
+
recorder.cursorCaptureFirstWrite &&
|
|
386
|
+
recorder.cursorCaptureSessionTimestamp
|
|
387
|
+
) {
|
|
388
|
+
cursorData._syncMetadata = {
|
|
389
|
+
videoStartTime: recorder.cursorCaptureSessionTimestamp,
|
|
390
|
+
cursorStartTime: recorder.cursorCaptureStartTime,
|
|
391
|
+
offsetMs:
|
|
392
|
+
recorder.cursorCaptureStartTime -
|
|
393
|
+
recorder.cursorCaptureSessionTimestamp,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (shouldCaptureCursorSample(recorder.lastCapturedData, cursorData)) {
|
|
398
|
+
const jsonString = JSON.stringify(cursorData);
|
|
399
|
+
if (recorder.cursorCaptureFirstWrite) {
|
|
400
|
+
fs.appendFileSync(filepath, jsonString);
|
|
401
|
+
recorder.cursorCaptureFirstWrite = false;
|
|
402
|
+
} else {
|
|
403
|
+
fs.appendFileSync(filepath, "," + jsonString);
|
|
404
|
+
}
|
|
405
|
+
recorder.lastCapturedData = { ...cursorData };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
queueDeferredTextInputSample(
|
|
409
|
+
recorder,
|
|
410
|
+
nativeBinding,
|
|
411
|
+
filepath,
|
|
412
|
+
position,
|
|
413
|
+
timestamp,
|
|
414
|
+
);
|
|
415
|
+
} catch (error) {
|
|
416
|
+
console.error("Cursor capture error:", error);
|
|
417
|
+
}
|
|
418
|
+
}, interval);
|
|
419
|
+
|
|
420
|
+
recorder.emit("cursorCaptureStarted", filepath);
|
|
421
|
+
resolve(true);
|
|
422
|
+
} catch (error) {
|
|
423
|
+
reject(error);
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function stopCursorCapture(recorder) {
|
|
429
|
+
return new Promise((resolve, reject) => {
|
|
430
|
+
try {
|
|
431
|
+
if (!recorder.cursorCaptureInterval) {
|
|
432
|
+
return resolve(false);
|
|
433
|
+
}
|
|
434
|
+
clearInterval(recorder.cursorCaptureInterval);
|
|
435
|
+
recorder.cursorCaptureInterval = null;
|
|
436
|
+
|
|
437
|
+
if (recorder.cursorCaptureFile) {
|
|
438
|
+
fs.appendFileSync(recorder.cursorCaptureFile, "]");
|
|
439
|
+
recorder.cursorCaptureFile = null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
recorder.lastCapturedData = null;
|
|
443
|
+
recorder.cursorCaptureStartTime = null;
|
|
444
|
+
recorder.cursorCaptureFirstWrite = true;
|
|
445
|
+
recorder.cursorDisplayInfo = null;
|
|
446
|
+
recorder._tiSampleWallMs = 0;
|
|
447
|
+
recorder._lastTextInputEmitted = null;
|
|
448
|
+
recorder._tiDeferredPending = false;
|
|
449
|
+
|
|
450
|
+
recorder.emit("cursorCaptureStopped");
|
|
451
|
+
resolve(true);
|
|
452
|
+
} catch (error) {
|
|
453
|
+
reject(error);
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
module.exports = {
|
|
459
|
+
startCursorCapture,
|
|
460
|
+
stopCursorCapture,
|
|
461
|
+
shouldCaptureCursorSample,
|
|
462
|
+
};
|
package/package.json
CHANGED
package/src/cursor_tracker.mm
CHANGED
|
@@ -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
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
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
|
-
|
|
2062
|
-
|
|
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
|
-
|
|
2065
|
-
|
|
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;
|