node-mac-recorder 2.24.14 → 2.24.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,207 @@
1
+ // Exercise the production detector with real AppKit images, including detached
2
+ // bitmap copies as returned for cursors belonging to other applications.
3
+ #import "../src/cursor_tracker.mm"
4
+
5
+ static Napi::Value RunCursorTests(const Napi::CallbackInfo &info) {
6
+ @autoreleasepool {
7
+ NSMutableArray<NSString *> *failures = [NSMutableArray array];
8
+ NSUInteger checks = 0;
9
+ auto expect = [&](NSString *actual, NSString *expected, NSString *label) {
10
+ checks++;
11
+ if (!StringsEqual(actual, expected)) {
12
+ [failures addObject:[NSString stringWithFormat:@"%@: expected %@, got %@", label, expected, actual]];
13
+ }
14
+ };
15
+ InitializeCursorFingerprintMap();
16
+ auto checkCursor = [&](NSCursor *cursor, NSString *expected, NSString *label) {
17
+ expect(normalizeCursorTypeForDesktop(cursorTypeFromNSCursor(cursor)), expected, label);
18
+ NSImage *image = cursor.image;
19
+ if (!image || image.size.width <= 0 || image.size.height <= 0) {
20
+ [failures addObject:[label stringByAppendingString:@": AppKit image unavailable"]];
21
+ return;
22
+ }
23
+ for (NSUInteger scale = 1; scale <= 2; scale++) {
24
+ NSSize size = image.size;
25
+ NSBitmapImageRep *bitmap = [[[NSBitmapImageRep alloc]
26
+ initWithBitmapDataPlanes:NULL pixelsWide:lround(size.width * scale)
27
+ pixelsHigh:lround(size.height * scale) bitsPerSample:8 samplesPerPixel:4
28
+ hasAlpha:YES isPlanar:NO colorSpaceName:NSDeviceRGBColorSpace
29
+ bytesPerRow:0 bitsPerPixel:0] autorelease];
30
+ bitmap.size = size;
31
+ [NSGraphicsContext saveGraphicsState];
32
+ [NSGraphicsContext setCurrentContext:[NSGraphicsContext graphicsContextWithBitmapImageRep:bitmap]];
33
+ [image drawInRect:NSMakeRect(0, 0, size.width, size.height) fromRect:NSZeroRect
34
+ operation:NSCompositingOperationCopy fraction:1.0];
35
+ [NSGraphicsContext restoreGraphicsState];
36
+ NSImage *copy = [[[NSImage alloc] initWithSize:size] autorelease];
37
+ [copy addRepresentation:bitmap];
38
+ NSCursor *detached = [[[NSCursor alloc] initWithImage:copy hotSpot:cursor.hotSpot] autorelease];
39
+ expect(normalizeCursorTypeForDesktop(cursorTypeFromNSCursor(detached)), expected,
40
+ [NSString stringWithFormat:@"%@ detached %lux", label, (unsigned long)scale]);
41
+ }
42
+ };
43
+
44
+ NSDictionary *names = @{
45
+ @"contextualMenuCursor": @"default", @"dragLinkCursor": @"alias",
46
+ @"move": @"all-scroll", @"help": @"help", @"zoomOutCursor": @"zoom-out",
47
+ @"n-resize": @"ns-resize", @"s-resize": @"ns-resize",
48
+ @"e-resize": @"col-resize", @"w-resize": @"col-resize",
49
+ @"ne-resize": @"nesw-resize", @"sw-resize": @"nesw-resize",
50
+ @"nw-resize": @"nwse-resize", @"se-resize": @"nwse-resize",
51
+ @"resizeNorthEastCursor": @"nesw-resize", @"resizeNorthWestCursor": @"nwse-resize",
52
+ @"row-resize": @"row-resize", @"col-resize": @"col-resize",
53
+ @"resizeNorthSouthCursor": @"ns-resize", @"resizeEastWestCursor": @"col-resize"
54
+ };
55
+ for (NSString *name in names) {
56
+ expect(normalizeCursorTypeForDesktop(cursorTypeFromCursorName(name)), names[name], name);
57
+ }
58
+ expect(cursorTypeFromCursorName(@"unknown-resize"), nil, @"unknown direction must remain unknown");
59
+
60
+ checkCursor(NSCursor.arrowCursor, @"default", @"arrow");
61
+ checkCursor(NSCursor.pointingHandCursor, @"pointer", @"pointing hand");
62
+ checkCursor(NSCursor.IBeamCursor, @"text", @"text");
63
+ checkCursor(NSCursor.crosshairCursor, @"crosshair", @"crosshair");
64
+ checkCursor(NSCursor.openHandCursor, @"grab", @"open hand");
65
+ checkCursor(NSCursor.closedHandCursor, @"grabbing", @"closed hand");
66
+ checkCursor(NSCursor.dragCopyCursor, @"copy", @"copy");
67
+ checkCursor(NSCursor.dragLinkCursor, @"alias", @"alias");
68
+ checkCursor(NSCursor.operationNotAllowedCursor, @"not-allowed", @"not allowed");
69
+ if ([NSCursor instancesRespondToSelector:NSSelectorFromString(@"_coreCursorType")]) {
70
+ checkCursor([[[MRSystemReferenceCursor alloc] initWithCoreType:39] autorelease], @"all-scroll", @"CoreCursor move");
71
+ checkCursor([[[MRSystemReferenceCursor alloc] initWithCoreType:11] autorelease], @"grabbing", @"CoreCursor closed hand");
72
+ checkCursor([[[MRSystemReferenceCursor alloc] initWithCoreType:12] autorelease], @"grab", @"CoreCursor open hand");
73
+ }
74
+ if (@available(macOS 15.0, *)) {
75
+ checkCursor(NSCursor.zoomInCursor, @"zoom-in", @"zoom in");
76
+ checkCursor(NSCursor.zoomOutCursor, @"zoom-out", @"zoom out");
77
+ for (NSUInteger direction = 1; direction <= 3; direction++) {
78
+ checkCursor([NSCursor rowResizeCursorInDirections:(NSVerticalDirections)direction], @"row-resize", @"row");
79
+ checkCursor([NSCursor columnResizeCursorInDirections:(NSHorizontalDirections)direction], @"col-resize", @"column");
80
+ const NSCursorFrameResizePosition positions[] = {
81
+ NSCursorFrameResizePositionTop, NSCursorFrameResizePositionBottom,
82
+ NSCursorFrameResizePositionLeft, NSCursorFrameResizePositionRight,
83
+ NSCursorFrameResizePositionTopLeft, NSCursorFrameResizePositionBottomRight,
84
+ NSCursorFrameResizePositionTopRight, NSCursorFrameResizePositionBottomLeft
85
+ };
86
+ NSArray *expected = @[@"ns-resize", @"ns-resize", @"col-resize", @"col-resize",
87
+ @"nwse-resize", @"nwse-resize", @"nesw-resize", @"nesw-resize"];
88
+ for (NSUInteger i = 0; i < 8; i++) {
89
+ checkCursor([NSCursor frameResizeCursorFromPosition:positions[i]
90
+ inDirections:(NSCursorFrameResizeDirections)direction], expected[i],
91
+ [NSString stringWithFormat:@"frame %lu direction %lu", (unsigned long)i, (unsigned long)direction]);
92
+ }
93
+ }
94
+ }
95
+
96
+ NSDictionary *resources = @{
97
+ @"help": @"help", @"busybutclickable": @"progress", @"move": @"all-scroll",
98
+ @"zoomin": @"zoom-in", @"zoomout": @"zoom-out",
99
+ @"resizenortheast": @"nesw-resize", @"resizesouthwest": @"nesw-resize",
100
+ @"resizenorthwest": @"nwse-resize", @"resizesoutheast": @"nwse-resize",
101
+ @"resizenortheastsouthwest": @"nesw-resize", @"resizenorthwestsoutheast": @"nwse-resize",
102
+ @"resizenorthsouth": @"ns-resize", @"resizeupdown": @"row-resize"
103
+ };
104
+ NSString *resourceRoot = @"/System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/HIServices.framework/Versions/A/Resources/cursors";
105
+ for (NSString *resource in resources) {
106
+ NSString *directory = [resourceRoot stringByAppendingPathComponent:resource];
107
+ NSImage *image = [[[NSImage alloc] initWithContentsOfFile:[directory stringByAppendingPathComponent:@"cursor.pdf"]] autorelease];
108
+ if (!image) continue; // Legacy resources are optional on future macOS versions.
109
+ NSDictionary *metadata = [NSDictionary dictionaryWithContentsOfFile:[directory stringByAppendingPathComponent:@"info.plist"]];
110
+ NSCursor *cursor = [[[NSCursor alloc] initWithImage:image
111
+ hotSpot:NSMakePoint([metadata[@"hotx"] doubleValue], [metadata[@"hoty"] doubleValue])] autorelease];
112
+ checkCursor(cursor, resources[resource], resource);
113
+ }
114
+
115
+ // Unrelated custom cursors may have exactly the old heuristic dimensions.
116
+ NSImage *custom = [[[NSImage alloc] initWithSize:NSMakeSize(22, 22)] autorelease];
117
+ [custom lockFocus];
118
+ [[NSColor redColor] setFill];
119
+ NSRectFill(NSMakeRect(0, 0, 22, 22));
120
+ [custom unlockFocus];
121
+ NSCursor *customCursor = [[[NSCursor alloc] initWithImage:custom hotSpot:NSMakePoint(11, 11)] autorelease];
122
+ expect(cursorTypeFromNSCursor(customCursor), @"default", @"unknown 22x22 custom image");
123
+
124
+ ResetCursorEventHistory();
125
+ RememberCursorEvent(CGPointMake(100, 100), @"default", @"move");
126
+ expect(ShouldEmitCursorEvent(CGPointMake(100, 100), @"nesw-resize", @"move") ? @"yes" : @"no", @"yes", @"stationary shape change");
127
+ expect(ShouldEmitCursorEvent(CGPointMake(100, 100), @"default", @"move") ? @"yes" : @"no", @"no", @"stationary duplicate");
128
+ RememberCursorEvent(CGPointMake(100, 100), @"nwse-resize", @"drag");
129
+ expect(ShouldEmitCursorEvent(CGPointMake(100, 100), @"nesw-resize", @"drag") ? @"yes" : @"no", @"yes", @"stationary drag shape change");
130
+ ResetCursorEventHistory();
131
+
132
+ Napi::Object result = Napi::Object::New(info.Env());
133
+ result.Set("checks", Napi::Number::New(info.Env(), checks));
134
+ result.Set("failures", Napi::String::New(info.Env(), [[failures componentsJoinedByString:@"\n"] UTF8String]));
135
+ return result;
136
+ }
137
+ }
138
+
139
+ // Opt-in integration test: briefly display owned cursors and read them back from
140
+ // WindowServer. Restore the previous system cursor even when an assertion fails.
141
+ static Napi::Value RunLiveCursorTests(const Napi::CallbackInfo &info) {
142
+ @autoreleasepool {
143
+ [NSApplication sharedApplication];
144
+ InitializeCursorFingerprintMap();
145
+ NSMutableArray *failures = [NSMutableArray array];
146
+ NSMutableArray<NSCursor *> *cursors = [NSMutableArray arrayWithObjects:NSCursor.arrowCursor,
147
+ NSCursor.IBeamCursor, NSCursor.pointingHandCursor, NSCursor.openHandCursor,
148
+ NSCursor.closedHandCursor, NSCursor.dragCopyCursor, NSCursor.dragLinkCursor,
149
+ NSCursor.operationNotAllowedCursor, NSCursor.crosshairCursor, nil];
150
+ NSMutableArray *types = [NSMutableArray arrayWithArray:@[@"default", @"text", @"pointer",
151
+ @"grab", @"grabbing", @"copy", @"alias", @"not-allowed", @"crosshair"]];
152
+ if ([NSCursor instancesRespondToSelector:NSSelectorFromString(@"_coreCursorType")]) {
153
+ [cursors addObject:[[[MRSystemReferenceCursor alloc] initWithCoreType:39] autorelease]];
154
+ [types addObject:@"all-scroll"];
155
+ }
156
+ if (@available(macOS 15.0, *)) {
157
+ [cursors addObjectsFromArray:@[NSCursor.zoomInCursor, NSCursor.zoomOutCursor,
158
+ NSCursor.columnResizeCursor, NSCursor.rowResizeCursor,
159
+ [NSCursor frameResizeCursorFromPosition:NSCursorFrameResizePositionTop inDirections:NSCursorFrameResizeDirectionsAll],
160
+ [NSCursor frameResizeCursorFromPosition:NSCursorFrameResizePositionTopLeft inDirections:NSCursorFrameResizeDirectionsAll],
161
+ [NSCursor frameResizeCursorFromPosition:NSCursorFrameResizePositionTopRight inDirections:NSCursorFrameResizeDirectionsAll]]];
162
+ [types addObjectsFromArray:@[@"zoom-in", @"zoom-out", @"col-resize", @"row-resize", @"ns-resize", @"nwse-resize", @"nesw-resize"]];
163
+ }
164
+ NSCursor *previous = [[NSCursor currentSystemCursor] retain];
165
+ NSRunningApplication *previousApp = [[NSWorkspace sharedWorkspace].frontmostApplication retain];
166
+ NSPoint mouse = NSEvent.mouseLocation;
167
+ [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory];
168
+ NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(mouse.x - 80, mouse.y - 60, 160, 120)
169
+ styleMask:NSWindowStyleMaskTitled backing:NSBackingStoreBuffered defer:NO];
170
+ window.releasedWhenClosed = NO;
171
+ window.title = @"Cursor detection test";
172
+ [window disableCursorRects];
173
+ @try {
174
+ [window makeKeyAndOrderFront:nil];
175
+ [NSApp activateIgnoringOtherApps:YES];
176
+ [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.2]];
177
+ for (NSUInteger i = 0; i < cursors.count; i++) {
178
+ [cursors[i] set];
179
+ [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.03]];
180
+ NSString *actual = getCursorType();
181
+ if (!StringsEqual(actual, types[i])) {
182
+ NSCursor *system = NSCursor.currentSystemCursor;
183
+ [failures addObject:[NSString stringWithFormat:@"%@: got %@ (system image %@, hotspot %@)",
184
+ types[i], actual, NSStringFromSize(system.image.size), NSStringFromPoint(system.hotSpot)]];
185
+ }
186
+ }
187
+ } @finally {
188
+ [window close];
189
+ [window release];
190
+ [previousApp activateWithOptions:NSApplicationActivateIgnoringOtherApps];
191
+ [previousApp release];
192
+ [previous set];
193
+ [previous release];
194
+ }
195
+ Napi::Object result = Napi::Object::New(info.Env());
196
+ result.Set("checks", Napi::Number::New(info.Env(), cursors.count));
197
+ result.Set("failures", Napi::String::New(info.Env(), [[failures componentsJoinedByString:@"\n"] UTF8String]));
198
+ return result;
199
+ }
200
+ }
201
+
202
+ static Napi::Object InitProbe(Napi::Env env, Napi::Object exports) {
203
+ exports.Set("run", Napi::Function::New(env, RunCursorTests));
204
+ exports.Set("runLive", Napi::Function::New(env, RunLiveCursorTests));
205
+ return exports;
206
+ }
207
+ NODE_API_MODULE(cursor_detection_probe, InitProbe)
@@ -0,0 +1,37 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { execFileSync } = require('node:child_process');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+
8
+ test('native system cursor detection preserves resize axes, shapes and stationary changes', {
9
+ skip: process.platform !== 'darwin', timeout: 120000,
10
+ }, (t) => {
11
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'recorder-cursor-test-'));
12
+ t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
13
+ const version = process.versions.node;
14
+ const candidates = [
15
+ process.env.npm_config_nodedir && path.join(process.env.npm_config_nodedir, 'include/node'),
16
+ path.join(os.homedir(), 'Library/Caches/node-gyp', version, 'include/node'),
17
+ path.join(os.homedir(), '.cache/node-gyp', version, 'include/node'),
18
+ path.resolve(path.dirname(process.execPath), '../include/node'),
19
+ ].filter(Boolean);
20
+ const headers = candidates.find((p) => fs.existsSync(path.join(p, 'node_api.h')));
21
+ assert.ok(headers, 'Node headers required; run npm run rebuild first');
22
+ const binary = path.join(directory, 'cursor-probe.node');
23
+ execFileSync('xcrun', ['clang++', '-std=c++17', '-bundle', '-undefined', 'dynamic_lookup',
24
+ '-DNAPI_DISABLE_CPP_EXCEPTIONS', '-Wno-deprecated-declarations',
25
+ '-I', headers, '-I', path.dirname(require.resolve('node-addon-api')),
26
+ path.join(__dirname, 'cursor-detection-probe.mm'), '-o', binary,
27
+ '-framework', 'AppKit', '-framework', 'ApplicationServices', '-framework', 'Carbon',
28
+ ], { timeout: 60000, stdio: 'pipe' });
29
+ const result = require(binary).run();
30
+ t.diagnostic(`${result.checks} native cursor checks`);
31
+ assert.equal(result.failures, '');
32
+ if (process.env.MAC_RECORDER_TEST_LIVE_CURSOR === '1') {
33
+ const live = require(binary).runLive();
34
+ t.diagnostic(`${live.checks} WindowServer cursor checks`);
35
+ assert.equal(live.failures, '');
36
+ }
37
+ });
@@ -0,0 +1,26 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { spawnSync } = require('node:child_process');
4
+ const path = require('node:path');
5
+
6
+ test('Chromium move and hand cursors survive native capture and press/release', {
7
+ skip: process.platform !== 'darwin', timeout: 20000,
8
+ }, (t) => {
9
+ let electron = process.env.MAC_RECORDER_ELECTRON;
10
+ if (!electron) {
11
+ try { electron = require('electron'); } catch {}
12
+ }
13
+ assert.equal(typeof electron, 'string', 'Set MAC_RECORDER_ELECTRON to the Electron executable');
14
+ const env = { ...process.env };
15
+ delete env.ELECTRON_RUN_AS_NODE;
16
+ const run = spawnSync(electron, [path.join(__dirname, 'electron-cursor-fixture.cjs')], {
17
+ env, encoding: 'utf8', timeout: 18000,
18
+ });
19
+ assert.ifError(run.error);
20
+ const output = run.stdout.split('\n').find(line => line.startsWith('CURSOR_RESULTS='));
21
+ assert.ok(output, run.stderr || 'Electron did not return cursor samples');
22
+ const results = JSON.parse(output.slice('CURSOR_RESULTS='.length));
23
+ assert.deepEqual(results.filter(({ expected, actual }) => expected !== actual), []);
24
+ assert.equal(run.status, 0, run.stderr);
25
+ t.diagnostic(`${results.length} real Electron CSS cursor checks`);
26
+ });
@@ -0,0 +1,67 @@
1
+ // Run inside Electron. This uses Chromium's actual CSS cursors, which may differ
2
+ // from rendering the raw HIServices PDF into an NSImage.
3
+ const { app, BrowserWindow, screen } = require('electron');
4
+ const addon = require('../build/Release/mac_recorder.node');
5
+ const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
6
+ let window;
7
+
8
+ app.whenReady().then(async () => {
9
+ const point = screen.getCursorScreenPoint();
10
+ window = new BrowserWindow({
11
+ x: point.x - 100, y: point.y - 60, width: 200, height: 120,
12
+ frame: false, alwaysOnTop: true, skipTaskbar: true,
13
+ webPreferences: { nodeIntegration: false, contextIsolation: true },
14
+ });
15
+ await window.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(
16
+ '<html><body style="margin:0;width:100vw;height:100vh;background:#eee;font:14px sans-serif;display:grid;place-items:center">Cursor detection test</body></html>'
17
+ ));
18
+ window.focus();
19
+ await wait(200);
20
+ const results = [];
21
+ const check = (label, expected) => {
22
+ const actual = addon.getCursorPosition(false)?.cursorType;
23
+ results.push({ label, expected, actual });
24
+ };
25
+ // Repeated transitions catch stale seed/image caches as well as wrong shapes.
26
+ for (const [css, expected] of [
27
+ ['default', 'default'], ['move', 'all-scroll'], ['all-scroll', 'all-scroll'],
28
+ ['grab', 'grab'], ['grabbing', 'grabbing'], ['grab', 'grab'], ['move', 'all-scroll'],
29
+ ['-webkit-grab', 'grab'], ['-webkit-grabbing', 'grabbing'],
30
+ ['help', 'help'], ['progress', 'progress'], ['cell', 'crosshair'],
31
+ ['ns-resize', 'ns-resize'], ['row-resize', 'row-resize'],
32
+ ['nesw-resize', 'nesw-resize'], ['nwse-resize', 'nwse-resize'],
33
+ ['zoom-in', 'zoom-in'], ['zoom-out', 'zoom-out'],
34
+ ]) {
35
+ await window.webContents.executeJavaScript(`document.body.style.cursor=${JSON.stringify(css)}; document.body.textContent=${JSON.stringify(css)}`);
36
+ window.webContents.sendInputEvent({ type: 'mouseMove', x: 100, y: 60 });
37
+ await wait(60);
38
+ check(css, expected);
39
+ }
40
+
41
+ // A real renderer switches the displayed hand in response to press/release.
42
+ await window.webContents.executeJavaScript(`
43
+ document.body.style.cursor='grab';
44
+ document.body.onpointerdown=()=>document.body.style.cursor='grabbing';
45
+ document.body.onpointerup=()=>document.body.style.cursor='grab';
46
+ void 0;
47
+ `);
48
+ window.webContents.sendInputEvent({ type: 'mouseMove', x: 100, y: 60 });
49
+ await wait(60);
50
+ check('before press', 'grab');
51
+ window.webContents.sendInputEvent({ type: 'mouseDown', button: 'left', clickCount: 1, x: 100, y: 60 });
52
+ await wait(60);
53
+ check('during press', 'grabbing');
54
+ window.webContents.sendInputEvent({ type: 'mouseUp', button: 'left', clickCount: 1, x: 100, y: 60 });
55
+ await wait(60);
56
+ check('after release', 'grab');
57
+ console.log('CURSOR_RESULTS=' + JSON.stringify(results));
58
+ if (results.some(({ expected, actual }) => expected !== actual)) process.exitCode = 1;
59
+ }).catch(error => {
60
+ console.error(error);
61
+ process.exitCode = 1;
62
+ }).finally(() => {
63
+ if (window && !window.isDestroyed()) window.destroy();
64
+ app.exit(process.exitCode || 0);
65
+ });
66
+
67
+ setTimeout(() => app.exit(1), 15000).unref();