node-mac-recorder 2.15.0 → 2.15.2
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/.claude/settings.local.json +3 -1
- package/ELECTRON_SAFE_README.md +372 -0
- package/build-electron-safe.js +135 -0
- package/electron-safe-binding.gyp +67 -0
- package/electron-safe-index.js +399 -0
- package/examples/electron-integration-example.js +230 -0
- package/examples/electron-preload.js +46 -0
- package/examples/electron-renderer.html +634 -0
- package/package.json +5 -2
- package/src/electron_safe/audio_capture_electron.mm +137 -0
- package/src/electron_safe/cursor_tracker_electron.mm +90 -0
- package/src/electron_safe/mac_recorder_electron.mm +337 -0
- package/src/electron_safe/screen_capture_electron.h +30 -0
- package/src/electron_safe/screen_capture_electron.mm +558 -0
- package/src/electron_safe/window_selector_electron.mm +279 -0
- package/src/screen_capture_kit.mm +77 -23
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
#import "screen_capture_electron.h"
|
|
2
|
+
#import <AVFoundation/AVFoundation.h>
|
|
3
|
+
#import <CoreMedia/CoreMedia.h>
|
|
4
|
+
#import <AppKit/AppKit.h>
|
|
5
|
+
|
|
6
|
+
// Thread-safe recording state management
|
|
7
|
+
static SCStream * API_AVAILABLE(macos(12.3)) g_electronSafeStream = nil;
|
|
8
|
+
static BOOL g_electronSafeIsRecording = NO;
|
|
9
|
+
static NSString *g_electronSafeOutputPath = nil;
|
|
10
|
+
static dispatch_queue_t g_electronSafeQueue = nil;
|
|
11
|
+
|
|
12
|
+
// Initialize the safe queue once
|
|
13
|
+
static void initializeSafeQueue() {
|
|
14
|
+
static dispatch_once_t onceToken;
|
|
15
|
+
dispatch_once(&onceToken, ^{
|
|
16
|
+
g_electronSafeQueue = dispatch_queue_create("com.macrecorder.electron.safe", DISPATCH_QUEUE_SERIAL);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
@interface ElectronSafeStreamDelegate : NSObject <SCStreamDelegate>
|
|
21
|
+
@end
|
|
22
|
+
|
|
23
|
+
@implementation ElectronSafeStreamDelegate
|
|
24
|
+
|
|
25
|
+
- (void)stream:(SCStream * API_AVAILABLE(macos(12.3)))stream didStopWithError:(NSError *)error API_AVAILABLE(macos(12.3)) {
|
|
26
|
+
NSLog(@"🛑 Electron-safe stream stopped");
|
|
27
|
+
|
|
28
|
+
// Use the safe queue to prevent race conditions
|
|
29
|
+
dispatch_async(g_electronSafeQueue, ^{
|
|
30
|
+
g_electronSafeIsRecording = NO;
|
|
31
|
+
|
|
32
|
+
if (error) {
|
|
33
|
+
NSLog(@"❌ Stream error: %@", error);
|
|
34
|
+
} else {
|
|
35
|
+
NSLog(@"✅ Stream stopped cleanly");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Clean up safely
|
|
39
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
40
|
+
[ElectronSafeScreenCapture cleanupSafely];
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@end
|
|
46
|
+
|
|
47
|
+
@implementation ElectronSafeScreenCapture
|
|
48
|
+
|
|
49
|
+
+ (void)load {
|
|
50
|
+
initializeSafeQueue();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
+ (BOOL)startRecordingWithPath:(NSString *)outputPath options:(NSDictionary *)options {
|
|
54
|
+
if (@available(macOS 12.3, *)) {
|
|
55
|
+
return [self startRecordingModern:outputPath options:options];
|
|
56
|
+
} else {
|
|
57
|
+
NSLog(@"❌ ScreenCaptureKit not available on this macOS version");
|
|
58
|
+
return NO;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
+ (BOOL)startRecordingModern:(NSString *)outputPath options:(NSDictionary *)options API_AVAILABLE(macos(12.3)) {
|
|
63
|
+
__block BOOL success = NO;
|
|
64
|
+
|
|
65
|
+
dispatch_sync(g_electronSafeQueue, ^{
|
|
66
|
+
if (g_electronSafeIsRecording) {
|
|
67
|
+
NSLog(@"⚠️ Recording already in progress");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
g_electronSafeOutputPath = [outputPath copy];
|
|
72
|
+
|
|
73
|
+
// Get shareable content safely
|
|
74
|
+
[SCShareableContent getShareableContentWithCompletionHandler:^(SCShareableContent *content, NSError *error) {
|
|
75
|
+
if (error) {
|
|
76
|
+
NSLog(@"❌ Failed to get shareable content: %@", error);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Configure recording safely
|
|
81
|
+
[self configureAndStartRecording:content options:options completion:^(BOOL recordingSuccess) {
|
|
82
|
+
success = recordingSuccess;
|
|
83
|
+
}];
|
|
84
|
+
}];
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return success;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
+ (void)configureAndStartRecording:(SCShareableContent *)content
|
|
91
|
+
options:(NSDictionary *)options
|
|
92
|
+
completion:(void(^)(BOOL))completion API_AVAILABLE(macos(12.3)) {
|
|
93
|
+
|
|
94
|
+
@try {
|
|
95
|
+
// Create content filter based on options
|
|
96
|
+
SCContentFilter *filter = nil;
|
|
97
|
+
|
|
98
|
+
NSNumber *windowId = options[@"windowId"];
|
|
99
|
+
NSNumber *displayId = options[@"displayId"];
|
|
100
|
+
|
|
101
|
+
if (windowId && [windowId unsignedIntValue] != 0) {
|
|
102
|
+
// Window recording
|
|
103
|
+
SCWindow *targetWindow = nil;
|
|
104
|
+
for (SCWindow *window in content.windows) {
|
|
105
|
+
if (window.windowID == [windowId unsignedIntValue]) {
|
|
106
|
+
targetWindow = window;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (targetWindow) {
|
|
112
|
+
filter = [[SCContentFilter alloc] initWithDesktopIndependentWindow:targetWindow];
|
|
113
|
+
NSLog(@"✅ Window filter created for window ID: %@", windowId);
|
|
114
|
+
} else {
|
|
115
|
+
NSLog(@"❌ Window not found: %@", windowId);
|
|
116
|
+
completion(NO);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
// Display recording (default)
|
|
121
|
+
SCDisplay *targetDisplay = nil;
|
|
122
|
+
|
|
123
|
+
if (displayId) {
|
|
124
|
+
for (SCDisplay *display in content.displays) {
|
|
125
|
+
if (display.displayID == [displayId unsignedIntValue]) {
|
|
126
|
+
targetDisplay = display;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!targetDisplay && content.displays.count > 0) {
|
|
133
|
+
targetDisplay = content.displays[0]; // Default to first display
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (targetDisplay) {
|
|
137
|
+
filter = [[SCContentFilter alloc] initWithDisplay:targetDisplay excludingWindows:@[]];
|
|
138
|
+
NSLog(@"✅ Display filter created for display ID: %u", targetDisplay.displayID);
|
|
139
|
+
} else {
|
|
140
|
+
NSLog(@"❌ No display available");
|
|
141
|
+
completion(NO);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Configure stream
|
|
147
|
+
SCStreamConfiguration *config = [[SCStreamConfiguration alloc] init];
|
|
148
|
+
|
|
149
|
+
// Audio configuration (only available on macOS 13.0+)
|
|
150
|
+
if (@available(macOS 13.0, *)) {
|
|
151
|
+
config.capturesAudio = [options[@"includeMicrophone"] boolValue] || [options[@"includeSystemAudio"] boolValue];
|
|
152
|
+
config.sampleRate = 44100;
|
|
153
|
+
config.channelCount = 2;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Video configuration
|
|
157
|
+
config.width = 1920;
|
|
158
|
+
config.height = 1080;
|
|
159
|
+
config.minimumFrameInterval = CMTimeMake(1, 30); // 30 FPS
|
|
160
|
+
config.queueDepth = 8;
|
|
161
|
+
|
|
162
|
+
// Capture area if specified
|
|
163
|
+
NSDictionary *captureArea = options[@"captureArea"];
|
|
164
|
+
if (captureArea) {
|
|
165
|
+
CGRect sourceRect = CGRectMake(
|
|
166
|
+
[captureArea[@"x"] doubleValue],
|
|
167
|
+
[captureArea[@"y"] doubleValue],
|
|
168
|
+
[captureArea[@"width"] doubleValue],
|
|
169
|
+
[captureArea[@"height"] doubleValue]
|
|
170
|
+
);
|
|
171
|
+
config.sourceRect = sourceRect;
|
|
172
|
+
config.width = (size_t)sourceRect.size.width;
|
|
173
|
+
config.height = (size_t)sourceRect.size.height;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Cursor capture
|
|
177
|
+
config.showsCursor = [options[@"captureCursor"] boolValue];
|
|
178
|
+
|
|
179
|
+
// Create delegate
|
|
180
|
+
ElectronSafeStreamDelegate *delegate = [[ElectronSafeStreamDelegate alloc] init];
|
|
181
|
+
|
|
182
|
+
// Create stream
|
|
183
|
+
NSError *streamError = nil;
|
|
184
|
+
g_electronSafeStream = [[SCStream alloc] initWithFilter:filter
|
|
185
|
+
configuration:config
|
|
186
|
+
delegate:delegate];
|
|
187
|
+
|
|
188
|
+
if (!g_electronSafeStream) {
|
|
189
|
+
NSLog(@"❌ Failed to create stream: %@", streamError);
|
|
190
|
+
completion(NO);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Start recording to file
|
|
195
|
+
[self startRecordingToFile:g_electronSafeOutputPath completion:completion];
|
|
196
|
+
|
|
197
|
+
} @catch (NSException *e) {
|
|
198
|
+
NSLog(@"❌ Exception in configureAndStartRecording: %@", e.reason);
|
|
199
|
+
completion(NO);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
+ (void)startRecordingToFile:(NSString *)outputPath completion:(void(^)(BOOL))completion API_AVAILABLE(macos(12.3)) {
|
|
204
|
+
if (@available(macOS 15.0, *)) {
|
|
205
|
+
// Use SCRecordingOutput for macOS 15.0+
|
|
206
|
+
[self startRecordingWithSCRecordingOutput:outputPath completion:completion];
|
|
207
|
+
} else {
|
|
208
|
+
// Fallback to sample buffer capture for macOS 12.3-14.x
|
|
209
|
+
[self startRecordingWithSampleBuffers:outputPath completion:completion];
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
+ (void)startRecordingWithSCRecordingOutput:(NSString *)outputPath completion:(void(^)(BOOL))completion API_AVAILABLE(macos(15.0)) {
|
|
214
|
+
@try {
|
|
215
|
+
// Note: SCRecordingOutput is only available on macOS 15.0+
|
|
216
|
+
// For now, we'll use the sample buffer approach for compatibility
|
|
217
|
+
NSLog(@"⚠️ SCRecordingOutput not implemented for compatibility - using fallback");
|
|
218
|
+
|
|
219
|
+
[g_electronSafeStream startCaptureWithCompletionHandler:^(NSError *error) {
|
|
220
|
+
dispatch_async(g_electronSafeQueue, ^{
|
|
221
|
+
if (error) {
|
|
222
|
+
NSLog(@"❌ Failed to start capture: %@", error);
|
|
223
|
+
g_electronSafeIsRecording = NO;
|
|
224
|
+
completion(NO);
|
|
225
|
+
} else {
|
|
226
|
+
NSLog(@"✅ Electron-safe recording started with SCRecordingOutput");
|
|
227
|
+
g_electronSafeIsRecording = YES;
|
|
228
|
+
completion(YES);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
}];
|
|
232
|
+
|
|
233
|
+
} @catch (NSException *e) {
|
|
234
|
+
NSLog(@"❌ Exception in startRecordingWithSCRecordingOutput: %@", e.reason);
|
|
235
|
+
completion(NO);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
+ (void)startRecordingWithSampleBuffers:(NSString *)outputPath completion:(void(^)(BOOL))completion API_AVAILABLE(macos(12.3)) {
|
|
240
|
+
@try {
|
|
241
|
+
// For macOS 12.3-14.x, we'll use a simpler approach
|
|
242
|
+
// This is a fallback implementation
|
|
243
|
+
|
|
244
|
+
[g_electronSafeStream startCaptureWithCompletionHandler:^(NSError *error) {
|
|
245
|
+
dispatch_async(g_electronSafeQueue, ^{
|
|
246
|
+
if (error) {
|
|
247
|
+
NSLog(@"❌ Failed to start capture: %@", error);
|
|
248
|
+
g_electronSafeIsRecording = NO;
|
|
249
|
+
completion(NO);
|
|
250
|
+
} else {
|
|
251
|
+
NSLog(@"✅ Electron-safe recording started with sample buffers");
|
|
252
|
+
g_electronSafeIsRecording = YES;
|
|
253
|
+
completion(YES);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
}];
|
|
257
|
+
|
|
258
|
+
} @catch (NSException *e) {
|
|
259
|
+
NSLog(@"❌ Exception in startRecordingWithSampleBuffers: %@", e.reason);
|
|
260
|
+
completion(NO);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
+ (BOOL)stopRecordingSafely {
|
|
265
|
+
__block BOOL success = NO;
|
|
266
|
+
|
|
267
|
+
dispatch_sync(g_electronSafeQueue, ^{
|
|
268
|
+
if (!g_electronSafeIsRecording) {
|
|
269
|
+
NSLog(@"⚠️ No recording in progress");
|
|
270
|
+
success = YES; // Not an error
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
@try {
|
|
275
|
+
if (@available(macOS 12.3, *)) {
|
|
276
|
+
if (g_electronSafeStream) {
|
|
277
|
+
[g_electronSafeStream stopCaptureWithCompletionHandler:^(NSError *error) {
|
|
278
|
+
dispatch_async(g_electronSafeQueue, ^{
|
|
279
|
+
if (error) {
|
|
280
|
+
NSLog(@"❌ Failed to stop capture: %@", error);
|
|
281
|
+
} else {
|
|
282
|
+
NSLog(@"✅ Capture stopped successfully");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
[ElectronSafeScreenCapture cleanupSafely];
|
|
286
|
+
});
|
|
287
|
+
}];
|
|
288
|
+
success = YES;
|
|
289
|
+
} else {
|
|
290
|
+
NSLog(@"⚠️ No stream to stop");
|
|
291
|
+
success = YES;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
} @catch (NSException *e) {
|
|
295
|
+
NSLog(@"❌ Exception stopping recording: %@", e.reason);
|
|
296
|
+
[ElectronSafeScreenCapture cleanupSafely];
|
|
297
|
+
success = NO;
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
return success;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
+ (void)cleanupSafely {
|
|
305
|
+
dispatch_async(g_electronSafeQueue, ^{
|
|
306
|
+
@try {
|
|
307
|
+
if (@available(macOS 12.3, *)) {
|
|
308
|
+
g_electronSafeStream = nil;
|
|
309
|
+
}
|
|
310
|
+
g_electronSafeIsRecording = NO;
|
|
311
|
+
g_electronSafeOutputPath = nil;
|
|
312
|
+
|
|
313
|
+
NSLog(@"✅ Electron-safe cleanup completed");
|
|
314
|
+
|
|
315
|
+
} @catch (NSException *e) {
|
|
316
|
+
NSLog(@"❌ Exception during cleanup: %@", e.reason);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
+ (BOOL)isRecording {
|
|
322
|
+
__block BOOL recording = NO;
|
|
323
|
+
dispatch_sync(g_electronSafeQueue, ^{
|
|
324
|
+
recording = g_electronSafeIsRecording;
|
|
325
|
+
});
|
|
326
|
+
return recording;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
+ (NSArray *)getAvailableDisplays {
|
|
330
|
+
NSMutableArray *displays = [NSMutableArray array];
|
|
331
|
+
|
|
332
|
+
@try {
|
|
333
|
+
// Get all displays using Core Graphics
|
|
334
|
+
uint32_t displayCount = 0;
|
|
335
|
+
CGGetActiveDisplayList(0, NULL, &displayCount);
|
|
336
|
+
|
|
337
|
+
if (displayCount > 0) {
|
|
338
|
+
CGDirectDisplayID *displayList = (CGDirectDisplayID *)malloc(displayCount * sizeof(CGDirectDisplayID));
|
|
339
|
+
CGGetActiveDisplayList(displayCount, displayList, &displayCount);
|
|
340
|
+
|
|
341
|
+
for (uint32_t i = 0; i < displayCount; i++) {
|
|
342
|
+
CGDirectDisplayID displayID = displayList[i];
|
|
343
|
+
|
|
344
|
+
CGRect bounds = CGDisplayBounds(displayID);
|
|
345
|
+
NSString *name = [NSString stringWithFormat:@"Display %u", displayID];
|
|
346
|
+
|
|
347
|
+
NSDictionary *displayInfo = @{
|
|
348
|
+
@"id": @(displayID),
|
|
349
|
+
@"name": name,
|
|
350
|
+
@"width": @((int)bounds.size.width),
|
|
351
|
+
@"height": @((int)bounds.size.height),
|
|
352
|
+
@"x": @((int)bounds.origin.x),
|
|
353
|
+
@"y": @((int)bounds.origin.y),
|
|
354
|
+
@"isPrimary": @(CGDisplayIsMain(displayID))
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
[displays addObject:displayInfo];
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
free(displayList);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
} @catch (NSException *e) {
|
|
364
|
+
NSLog(@"❌ Exception getting displays: %@", e.reason);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return [displays copy];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
+ (NSArray *)getAvailableWindows {
|
|
371
|
+
NSMutableArray *windows = [NSMutableArray array];
|
|
372
|
+
|
|
373
|
+
@try {
|
|
374
|
+
if (@available(macOS 12.3, *)) {
|
|
375
|
+
[SCShareableContent getShareableContentWithCompletionHandler:^(SCShareableContent *content, NSError *error) {
|
|
376
|
+
if (!error && content) {
|
|
377
|
+
for (SCWindow *window in content.windows) {
|
|
378
|
+
// Skip system windows and our own
|
|
379
|
+
if (window.frame.size.width < 50 || window.frame.size.height < 50) continue;
|
|
380
|
+
if (!window.title || window.title.length == 0) continue;
|
|
381
|
+
|
|
382
|
+
NSString *appName = window.owningApplication.applicationName ?: @"Unknown";
|
|
383
|
+
|
|
384
|
+
// Skip Electron windows (our overlay)
|
|
385
|
+
if ([appName containsString:@"Electron"] || [appName containsString:@"node"]) continue;
|
|
386
|
+
|
|
387
|
+
NSDictionary *windowInfo = @{
|
|
388
|
+
@"id": @(window.windowID),
|
|
389
|
+
@"name": window.title,
|
|
390
|
+
@"appName": appName,
|
|
391
|
+
@"x": @((int)window.frame.origin.x),
|
|
392
|
+
@"y": @((int)window.frame.origin.y),
|
|
393
|
+
@"width": @((int)window.frame.size.width),
|
|
394
|
+
@"height": @((int)window.frame.size.height),
|
|
395
|
+
@"isOnScreen": @(window.isOnScreen)
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
[windows addObject:windowInfo];
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
} @catch (NSException *e) {
|
|
405
|
+
NSLog(@"❌ Exception getting windows: %@", e.reason);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return [windows copy];
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
+ (BOOL)checkPermissions {
|
|
412
|
+
@try {
|
|
413
|
+
// Check screen recording permission
|
|
414
|
+
if (@available(macOS 10.15, *)) {
|
|
415
|
+
CGRequestScreenCaptureAccess();
|
|
416
|
+
|
|
417
|
+
// Create a small test image to verify permission
|
|
418
|
+
CGImageRef testImage = CGDisplayCreateImage(CGMainDisplayID());
|
|
419
|
+
if (testImage) {
|
|
420
|
+
CFRelease(testImage);
|
|
421
|
+
return YES;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return NO;
|
|
426
|
+
|
|
427
|
+
} @catch (NSException *e) {
|
|
428
|
+
NSLog(@"❌ Exception checking permissions: %@", e.reason);
|
|
429
|
+
return NO;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
+ (NSString *)getDisplayThumbnailBase64:(CGDirectDisplayID)displayID
|
|
434
|
+
maxWidth:(NSInteger)maxWidth
|
|
435
|
+
maxHeight:(NSInteger)maxHeight {
|
|
436
|
+
@try {
|
|
437
|
+
CGImageRef screenshot = CGDisplayCreateImage(displayID);
|
|
438
|
+
if (!screenshot) {
|
|
439
|
+
return nil;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Resize image if needed
|
|
443
|
+
CGSize originalSize = CGSizeMake(CGImageGetWidth(screenshot), CGImageGetHeight(screenshot));
|
|
444
|
+
CGSize newSize = [self calculateThumbnailSize:originalSize maxWidth:maxWidth maxHeight:maxHeight];
|
|
445
|
+
|
|
446
|
+
NSData *imageData = [self createPNGDataFromImage:screenshot size:newSize];
|
|
447
|
+
CFRelease(screenshot);
|
|
448
|
+
|
|
449
|
+
if (imageData) {
|
|
450
|
+
return [imageData base64EncodedStringWithOptions:0];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return nil;
|
|
454
|
+
|
|
455
|
+
} @catch (NSException *e) {
|
|
456
|
+
NSLog(@"❌ Exception creating display thumbnail: %@", e.reason);
|
|
457
|
+
return nil;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
+ (NSString *)getWindowThumbnailBase64:(uint32_t)windowID
|
|
462
|
+
maxWidth:(NSInteger)maxWidth
|
|
463
|
+
maxHeight:(NSInteger)maxHeight {
|
|
464
|
+
@try {
|
|
465
|
+
CGImageRef screenshot = CGWindowListCreateImage(CGRectNull,
|
|
466
|
+
kCGWindowListOptionIncludingWindow,
|
|
467
|
+
windowID,
|
|
468
|
+
kCGWindowImageDefault);
|
|
469
|
+
if (!screenshot) {
|
|
470
|
+
return nil;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Resize image if needed
|
|
474
|
+
CGSize originalSize = CGSizeMake(CGImageGetWidth(screenshot), CGImageGetHeight(screenshot));
|
|
475
|
+
CGSize newSize = [self calculateThumbnailSize:originalSize maxWidth:maxWidth maxHeight:maxHeight];
|
|
476
|
+
|
|
477
|
+
NSData *imageData = [self createPNGDataFromImage:screenshot size:newSize];
|
|
478
|
+
CFRelease(screenshot);
|
|
479
|
+
|
|
480
|
+
if (imageData) {
|
|
481
|
+
return [imageData base64EncodedStringWithOptions:0];
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return nil;
|
|
485
|
+
|
|
486
|
+
} @catch (NSException *e) {
|
|
487
|
+
NSLog(@"❌ Exception creating window thumbnail: %@", e.reason);
|
|
488
|
+
return nil;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
+ (CGSize)calculateThumbnailSize:(CGSize)originalSize maxWidth:(NSInteger)maxWidth maxHeight:(NSInteger)maxHeight {
|
|
493
|
+
CGFloat aspectRatio = originalSize.width / originalSize.height;
|
|
494
|
+
CGFloat newWidth = originalSize.width;
|
|
495
|
+
CGFloat newHeight = originalSize.height;
|
|
496
|
+
|
|
497
|
+
if (newWidth > maxWidth) {
|
|
498
|
+
newWidth = maxWidth;
|
|
499
|
+
newHeight = newWidth / aspectRatio;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (newHeight > maxHeight) {
|
|
503
|
+
newHeight = maxHeight;
|
|
504
|
+
newWidth = newHeight * aspectRatio;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return CGSizeMake(newWidth, newHeight);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
+ (NSData *)createPNGDataFromImage:(CGImageRef)image size:(CGSize)size {
|
|
511
|
+
@try {
|
|
512
|
+
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
|
|
513
|
+
CGContextRef context = CGBitmapContextCreate(NULL,
|
|
514
|
+
(size_t)size.width,
|
|
515
|
+
(size_t)size.height,
|
|
516
|
+
8,
|
|
517
|
+
0,
|
|
518
|
+
colorSpace,
|
|
519
|
+
kCGImageAlphaPremultipliedLast);
|
|
520
|
+
|
|
521
|
+
if (!context) {
|
|
522
|
+
CGColorSpaceRelease(colorSpace);
|
|
523
|
+
return nil;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
CGContextDrawImage(context, CGRectMake(0, 0, size.width, size.height), image);
|
|
527
|
+
CGImageRef resizedImage = CGBitmapContextCreateImage(context);
|
|
528
|
+
|
|
529
|
+
CGContextRelease(context);
|
|
530
|
+
CGColorSpaceRelease(colorSpace);
|
|
531
|
+
|
|
532
|
+
if (!resizedImage) {
|
|
533
|
+
return nil;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
NSMutableData *data = [NSMutableData data];
|
|
537
|
+
CGImageDestinationRef destination = CGImageDestinationCreateWithData((__bridge CFMutableDataRef)data,
|
|
538
|
+
kUTTypePNG,
|
|
539
|
+
1,
|
|
540
|
+
NULL);
|
|
541
|
+
|
|
542
|
+
if (destination) {
|
|
543
|
+
CGImageDestinationAddImage(destination, resizedImage, NULL);
|
|
544
|
+
CGImageDestinationFinalize(destination);
|
|
545
|
+
CFRelease(destination);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
CGImageRelease(resizedImage);
|
|
549
|
+
|
|
550
|
+
return [data copy];
|
|
551
|
+
|
|
552
|
+
} @catch (NSException *e) {
|
|
553
|
+
NSLog(@"❌ Exception creating PNG data: %@", e.reason);
|
|
554
|
+
return nil;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
@end
|