node-mac-recorder 2.24.6 → 2.24.8

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 CHANGED
@@ -7,6 +7,7 @@
7
7
  "src/screen_capture_kit.mm",
8
8
  "src/avfoundation_recorder.mm",
9
9
  "src/camera_recorder.mm",
10
+ "src/ios_device_recorder.mm",
10
11
  "src/sync_timeline.mm",
11
12
  "src/audio_recorder.mm",
12
13
  "src/audio_mixer.mm",
@@ -42,7 +43,8 @@
42
43
  "-framework ApplicationServices",
43
44
  "-framework Carbon",
44
45
  "-framework Accessibility",
45
- "-framework CoreAudio"
46
+ "-framework CoreAudio",
47
+ "-framework CoreMediaIO"
46
48
  ]
47
49
  },
48
50
  "defines": [ "NAPI_DISABLE_CPP_EXCEPTIONS" ]
package/index.js CHANGED
@@ -153,6 +153,170 @@ class MacRecorder extends EventEmitter {
153
153
  });
154
154
  }
155
155
 
156
+ /**
157
+ * USB uzerinden AVFoundation muxed capture kaynagi olarak gorunen
158
+ * iPhone/iPad cihazlarini listeler. Continuity Camera cihazlari bu listeye
159
+ * dahil edilmez; bu kaynak telefonun kamera sensoru degil ekran akışıdır.
160
+ */
161
+ async getIOSCaptureDevices() {
162
+ if (typeof nativeBinding.getIOSCaptureDevices !== "function") return [];
163
+ const devices = nativeBinding.getIOSCaptureDevices();
164
+ if (!Array.isArray(devices)) return [];
165
+ return devices.map((device) => ({
166
+ id: device?.id || "",
167
+ name: device?.name || "iPhone",
168
+ manufacturer: device?.manufacturer || "Apple",
169
+ model: device?.model || null,
170
+ connected: device?.connected !== false,
171
+ suspended: device?.suspended === true,
172
+ width: Number(device?.width) || 0,
173
+ height: Number(device?.height) || 0,
174
+ hasAudio: device?.hasAudio !== false,
175
+ transport: device?.transport || "usb",
176
+ }));
177
+ }
178
+
179
+ /**
180
+ * QuickTime benzeri dogrudan USB iPhone ekran kaydi. Muxed aygitin kendi
181
+ * video ve sesini tek, uzun-kayitlara dayanikli MOV dosyasina yazar.
182
+ */
183
+ async startIOSRecording(outputPath, options = {}) {
184
+ if (this.isRecording) throw new Error("Recording is already in progress");
185
+ if (!outputPath) throw new Error("Output path is required");
186
+ if (typeof nativeBinding.startIOSDeviceRecording !== "function") {
187
+ throw new Error("This recorder build does not support USB iPhone capture");
188
+ }
189
+
190
+ const outputDir = path.dirname(outputPath);
191
+ if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
192
+ this.outputPath = outputPath;
193
+ const sessionTimestamp = options.sessionTimestamp || Date.now();
194
+ const cameraOutputPath = options.captureCamera === true
195
+ ? path.join(outputDir, `temp_camera_${sessionTimestamp}.mov`)
196
+ : null;
197
+ const audioOutputPath = options.includeMicrophone === true
198
+ ? path.join(outputDir, `temp_audio_${sessionTimestamp}.mov`)
199
+ : null;
200
+ this.options = {
201
+ ...this.options,
202
+ ...options,
203
+ sourceType: "iphone",
204
+ captureCursor: false,
205
+ // The muxed USB source already contains the iPhone's own system audio.
206
+ // includeSystemAudio refers to the Mac and must never be added here.
207
+ includeSystemAudio: false,
208
+ };
209
+ this.cameraCaptureFile = cameraOutputPath;
210
+ this.audioCaptureFile = audioOutputPath;
211
+ this.cameraCaptureActive = options.captureCamera === true;
212
+ this.audioCaptureActive = options.includeMicrophone === true;
213
+ this.sessionTimestamp = sessionTimestamp;
214
+ this.recordingMode = "iphone";
215
+
216
+ try {
217
+ const success = nativeBinding.startIOSDeviceRecording(
218
+ outputPath,
219
+ options.deviceId || options.iosDeviceId || "",
220
+ {
221
+ captureCamera: options.captureCamera === true,
222
+ cameraOutputPath,
223
+ cameraDeviceId: options.cameraDeviceId || "",
224
+ includeMicrophone: options.includeMicrophone === true,
225
+ audioOutputPath,
226
+ audioDeviceId: options.audioDeviceId || "",
227
+ },
228
+ );
229
+ if (!success) throw new Error("The iPhone capture session could not be started");
230
+
231
+ this.isRecording = true;
232
+ this.recordingStartTime = Date.now();
233
+ this.timelineStartTimestamp = this.recordingStartTime;
234
+ this.syncTimestamp = this.recordingStartTime;
235
+ this.recordingTimer = setInterval(() => {
236
+ this.emit(
237
+ "timeUpdate",
238
+ Math.floor((Date.now() - this.recordingStartTime) / 1000),
239
+ );
240
+ }, 1000);
241
+
242
+ const event = {
243
+ outputPath,
244
+ timestamp: this.recordingStartTime,
245
+ options: this.options,
246
+ nativeConfirmed: true,
247
+ cursorOutputPath: null,
248
+ keyboardOutputPath: null,
249
+ audioOutputPath,
250
+ cameraOutputPath,
251
+ sessionTimestamp: this.sessionTimestamp,
252
+ syncTimestamp: this.syncTimestamp,
253
+ fileTimestamp: this.sessionTimestamp,
254
+ sourceType: "iphone",
255
+ };
256
+ this.emit("recordingStarted", event);
257
+ this.emit("started", outputPath);
258
+ return outputPath;
259
+ } catch (error) {
260
+ this.recordingMode = null;
261
+ this.isRecording = false;
262
+ this.cameraCaptureActive = false;
263
+ this.audioCaptureActive = false;
264
+ this.cameraCaptureFile = null;
265
+ this.audioCaptureFile = null;
266
+ throw error;
267
+ }
268
+ }
269
+
270
+ async stopIOSRecording() {
271
+ if (this.recordingMode !== "iphone" || !this.isRecording) {
272
+ throw new Error("No iPhone recording in progress");
273
+ }
274
+ let success = false;
275
+ try {
276
+ success = nativeBinding.stopIOSDeviceRecording();
277
+ } finally {
278
+ if (this.recordingTimer) clearInterval(this.recordingTimer);
279
+ this.recordingTimer = null;
280
+ this.isRecording = false;
281
+ this.recordingMode = null;
282
+ }
283
+
284
+ const result = {
285
+ code: success ? 0 : 1,
286
+ outputPath: this.outputPath,
287
+ cameraOutputPath: this.cameraCaptureFile || null,
288
+ audioOutputPath: this.audioCaptureFile || null,
289
+ sessionTimestamp: this.sessionTimestamp,
290
+ syncTimestamp: this.syncTimestamp,
291
+ sourceType: "iphone",
292
+ };
293
+ if (this.cameraCaptureActive) {
294
+ this.emit("cameraCaptureStopped", {
295
+ outputPath: this.cameraCaptureFile,
296
+ success,
297
+ sessionTimestamp: this.sessionTimestamp,
298
+ syncTimestamp: this.syncTimestamp,
299
+ });
300
+ }
301
+ if (this.audioCaptureActive) {
302
+ this.emit("audioCaptureStopped", {
303
+ outputPath: this.audioCaptureFile,
304
+ success,
305
+ sessionTimestamp: this.sessionTimestamp,
306
+ syncTimestamp: this.syncTimestamp,
307
+ });
308
+ }
309
+ this.cameraCaptureActive = false;
310
+ this.audioCaptureActive = false;
311
+ this.emit("stopped", result);
312
+ if (success && fs.existsSync(this.outputPath)) {
313
+ this.emit("completed", this.outputPath);
314
+ }
315
+ this.sessionTimestamp = null;
316
+ this.syncTimestamp = null;
317
+ return result;
318
+ }
319
+
156
320
  /**
157
321
  * macOS ekranlarını listeler
158
322
  */
@@ -1227,6 +1391,9 @@ class MacRecorder extends EventEmitter {
1227
1391
  if (!this.isRecording) {
1228
1392
  throw new Error("No recording in progress");
1229
1393
  }
1394
+ if (this.recordingMode === "iphone") {
1395
+ return this.stopIOSRecording();
1396
+ }
1230
1397
 
1231
1398
  return new Promise(async (resolve, reject) => {
1232
1399
  const stopRequestedAt = Date.now();
@@ -1399,7 +1566,11 @@ class MacRecorder extends EventEmitter {
1399
1566
  * Kayıt durumunu döndürür
1400
1567
  */
1401
1568
  getStatus() {
1402
- const nativeStatus = nativeBinding.getRecordingStatus();
1569
+ const nativeStatus =
1570
+ this.recordingMode === "iphone" &&
1571
+ typeof nativeBinding.getIOSDeviceRecordingStatus === "function"
1572
+ ? nativeBinding.getIOSDeviceRecordingStatus().isRecording === true
1573
+ : nativeBinding.getRecordingStatus();
1403
1574
  return {
1404
1575
  isRecording: this.isRecording && nativeStatus,
1405
1576
  outputPath: this.outputPath,
@@ -1416,6 +1587,31 @@ class MacRecorder extends EventEmitter {
1416
1587
  };
1417
1588
  }
1418
1589
 
1590
+ /**
1591
+ * Pencere kaydında hedef pencerenin uygulamasını aktive eder.
1592
+ *
1593
+ * Kayıt başlarken kaydedilen uygulama pasif kalırsa, odağını kaybedince
1594
+ * gizlenen pencereler (iTerm2 hotkey window vb.) kendini gizler ve kayıtta
1595
+ * görünmez. Kayıt komutu verildiğinde odak bizde olduğu için bu adım şart.
1596
+ *
1597
+ * @param {number} windowId CGWindowID
1598
+ * @returns {boolean}
1599
+ */
1600
+ activateWindowOwnerApp(windowId) {
1601
+ const id = Number(windowId);
1602
+ if (!Number.isFinite(id) || id <= 0) return false;
1603
+ if (typeof nativeBinding.activateWindowOwnerApp !== "function") return false;
1604
+ try {
1605
+ return !!nativeBinding.activateWindowOwnerApp(id);
1606
+ } catch (error) {
1607
+ console.warn(
1608
+ "[MacRecorder] activateWindowOwnerApp başarısız:",
1609
+ error?.message || error,
1610
+ );
1611
+ return false;
1612
+ }
1613
+ }
1614
+
1419
1615
  /**
1420
1616
  * Encoder'ın gerçek zamanlı yetişip yetişmediğini gösteren kare sayaçları.
1421
1617
  * Kayıt sırasında canlı, bittikten sonra son oturumun değerlerini döndürür.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-mac-recorder",
3
- "version": "2.24.6",
3
+ "version": "2.24.8",
4
4
  "description": "Native macOS screen recording package for Node.js applications",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -337,6 +337,12 @@ static NSString *g_lastStandaloneAudioOutputPath = nil;
337
337
 
338
338
  CMTime timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer);
339
339
 
340
+ // Keep microphone warm-up outside the recording until the USB iPhone movie
341
+ // output has actually started writing its first frame.
342
+ if (MRSyncShouldHoldForPrimary(timestamp)) {
343
+ return;
344
+ }
345
+
340
346
  // A/V SYNC: Hold audio samples until camera produces first frame
341
347
  // This ensures both audio and camera files start from the same wall-clock moment
342
348
  if (MRSyncShouldHoldAudioSample(timestamp)) {
@@ -526,6 +526,12 @@ didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
526
526
 
527
527
  CMTime timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer);
528
528
 
529
+ // Drop camera warm-up frames until the primary source (USB iPhone screen)
530
+ // has committed its first frame. This keeps all files on one t=0 boundary.
531
+ if (MRSyncShouldHoldForPrimary(timestamp)) {
532
+ return;
533
+ }
534
+
529
535
  // A/V SYNC: Signal camera's first frame to release audio hold
530
536
  MRSyncMarkCameraFirstFrame(timestamp);
531
537
 
@@ -0,0 +1,534 @@
1
+ #import <napi.h>
2
+ #import <AVFoundation/AVFoundation.h>
3
+ #import <CoreMediaIO/CMIOHardware.h>
4
+ #import <Foundation/Foundation.h>
5
+ #import "logging.h"
6
+ #import "sync_timeline.h"
7
+
8
+ extern "C" bool startCameraRecording(NSString *outputPath, NSString *deviceId, NSError **error);
9
+ extern "C" bool waitForCameraRecordingStart(double timeoutSeconds);
10
+ extern "C" bool stopCameraRecording(void);
11
+ extern "C" bool isCameraRecording(void);
12
+ extern "C" bool startStandaloneAudioRecording(NSString *outputPath, NSString *preferredDeviceId, NSError **error);
13
+ extern "C" bool stopStandaloneAudioRecording(void);
14
+ extern "C" bool isStandaloneAudioRecording(void);
15
+
16
+ @interface MRIOSDeviceRecorder : NSObject <AVCaptureFileOutputRecordingDelegate>
17
+ @property(nonatomic, strong) AVCaptureSession *session;
18
+ @property(nonatomic, strong) AVCaptureDeviceInput *deviceInput;
19
+ @property(nonatomic, strong) AVCaptureMovieFileOutput *movieOutput;
20
+ @property(nonatomic, copy) NSString *outputPath;
21
+ @property(atomic) BOOL recording;
22
+ @property(atomic) BOOL startCompleted;
23
+ @property(atomic) BOOL finishCompleted;
24
+ @property(atomic, strong) NSError *finishError;
25
+ @property(nonatomic) BOOL captureCamera;
26
+ @property(nonatomic) BOOL captureMicrophone;
27
+ @property(nonatomic, copy) NSString *cameraOutputPath;
28
+ @property(nonatomic, copy) NSString *audioOutputPath;
29
+ @property(nonatomic, strong) NSDate *primaryStartedAt;
30
+ @end
31
+
32
+ @implementation MRIOSDeviceRecorder
33
+
34
+ - (void)captureOutput:(AVCaptureFileOutput *)captureOutput
35
+ didStartRecordingToOutputFileAtURL:(NSURL *)fileURL
36
+ fromConnections:(NSArray<AVCaptureConnection *> *)connections {
37
+ self.recording = YES;
38
+ self.startCompleted = YES;
39
+ self.primaryStartedAt = [NSDate date];
40
+ MRSyncMarkPrimaryStarted(CMClockGetTime(CMClockGetHostTimeClock()));
41
+ MRLog(@"📱 iPhone capture started: %@", fileURL.path);
42
+ }
43
+
44
+ - (void)captureOutput:(AVCaptureFileOutput *)captureOutput
45
+ didFinishRecordingToOutputFileAtURL:(NSURL *)outputFileURL
46
+ fromConnections:(NSArray<AVCaptureConnection *> *)connections
47
+ error:(NSError *)error {
48
+ self.recording = NO;
49
+ self.finishError = error;
50
+ self.finishCompleted = YES;
51
+ if (error) {
52
+ NSNumber *successfullyFinished = error.userInfo[AVErrorRecordingSuccessfullyFinishedKey];
53
+ if (![successfullyFinished boolValue]) {
54
+ MRLog(@"❌ iPhone capture finalize failed: %@", error.localizedDescription);
55
+ return;
56
+ }
57
+ }
58
+ MRLog(@"✅ iPhone capture finalized: %@", outputFileURL.path);
59
+ }
60
+
61
+ @end
62
+
63
+ static MRIOSDeviceRecorder *g_iosRecorder = nil;
64
+
65
+ static void MREnableIOSScreenCaptureDevices(void) {
66
+ static dispatch_once_t onceToken;
67
+ dispatch_once(&onceToken, ^{
68
+ CMIOObjectPropertyAddress address = {
69
+ kCMIOHardwarePropertyAllowScreenCaptureDevices,
70
+ kCMIOObjectPropertyScopeGlobal,
71
+ kCMIOObjectPropertyElementMain
72
+ };
73
+ UInt32 allow = 1;
74
+ OSStatus status = CMIOObjectSetPropertyData(
75
+ kCMIOObjectSystemObject,
76
+ &address,
77
+ 0,
78
+ NULL,
79
+ sizeof(allow),
80
+ &allow
81
+ );
82
+ if (status == noErr) {
83
+ MRLog(@"✅ CoreMediaIO iPhone screen capture devices enabled");
84
+ } else {
85
+ MRLog(@"❌ CoreMediaIO could not enable iPhone screen capture devices (OSStatus=%d)", (int)status);
86
+ }
87
+ });
88
+ }
89
+
90
+ static NSArray<AVCaptureDevice *> *MRDiscoverIOSCaptureDevices(void) {
91
+ NSMutableArray<AVCaptureDevice *> *result = [NSMutableArray array];
92
+ NSMutableSet<NSString *> *seenIds = [NSMutableSet set];
93
+
94
+ #pragma clang diagnostic push
95
+ #pragma clang diagnostic ignored "-Wdeprecated-declarations"
96
+ for (AVCaptureDevice *device in [AVCaptureDevice devicesWithMediaType:AVMediaTypeMuxed]) {
97
+ if (device.uniqueID.length == 0 || [seenIds containsObject:device.uniqueID]) continue;
98
+ [seenIds addObject:device.uniqueID];
99
+ [result addObject:device];
100
+ }
101
+ #pragma clang diagnostic pop
102
+
103
+ NSMutableArray<AVCaptureDeviceType> *deviceTypes = [NSMutableArray array];
104
+ if (@available(macOS 10.15, *)) {
105
+ [deviceTypes addObject:AVCaptureDeviceTypeExternalUnknown];
106
+ }
107
+ if (@available(macOS 14.0, *)) {
108
+ [deviceTypes addObject:AVCaptureDeviceTypeExternal];
109
+ }
110
+ if (deviceTypes.count > 0) {
111
+ AVCaptureDeviceDiscoverySession *discovery =
112
+ [AVCaptureDeviceDiscoverySession discoverySessionWithDeviceTypes:deviceTypes
113
+ mediaType:AVMediaTypeMuxed
114
+ position:AVCaptureDevicePositionUnspecified];
115
+ for (AVCaptureDevice *device in discovery.devices) {
116
+ if (device.uniqueID.length == 0 || [seenIds containsObject:device.uniqueID]) continue;
117
+ [seenIds addObject:device.uniqueID];
118
+ [result addObject:device];
119
+ }
120
+ }
121
+ return result;
122
+ }
123
+
124
+ static NSArray<AVCaptureDevice *> *MRIOSCaptureDevices(void) {
125
+ MREnableIOSScreenCaptureDevices();
126
+
127
+ // CoreMediaIO publishes the USB screen device asynchronously after the
128
+ // allow flag changes. Poll briefly so the first click works without asking
129
+ // the user to close and reopen the recorder.
130
+ NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:3.0];
131
+ NSArray<AVCaptureDevice *> *devices = nil;
132
+ do {
133
+ devices = MRDiscoverIOSCaptureDevices();
134
+ if (devices.count > 0) break;
135
+ [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
136
+ beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.05]];
137
+ } while ([deadline timeIntervalSinceNow] > 0);
138
+ return devices ?: @[];
139
+ }
140
+
141
+ static AVCaptureDevice *MRIOSDeviceForId(NSString *deviceId) {
142
+ NSArray<AVCaptureDevice *> *devices = MRIOSCaptureDevices();
143
+ if (deviceId.length == 0) return devices.firstObject;
144
+ for (AVCaptureDevice *device in devices) {
145
+ if ([device.uniqueID isEqualToString:deviceId]) return device;
146
+ }
147
+ return nil;
148
+ }
149
+
150
+ static bool MRWaitForFlag(bool (^readFlag)(void), NSTimeInterval timeoutSeconds) {
151
+ NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:timeoutSeconds];
152
+ while (!readFlag() && [deadline timeIntervalSinceNow] > 0) {
153
+ [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
154
+ beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.01]];
155
+ }
156
+ return readFlag();
157
+ }
158
+
159
+ extern "C" NSArray<NSDictionary *> *listIOSCaptureDevices(void) {
160
+ NSMutableArray<NSDictionary *> *devices = [NSMutableArray array];
161
+ for (AVCaptureDevice *device in MRIOSCaptureDevices()) {
162
+ CMVideoDimensions largest = {0, 0};
163
+ for (AVCaptureDeviceFormat *format in device.formats) {
164
+ CMVideoDimensions dimensions = CMVideoFormatDescriptionGetDimensions(format.formatDescription);
165
+ if ((int64_t)dimensions.width * dimensions.height >
166
+ (int64_t)largest.width * largest.height) {
167
+ largest = dimensions;
168
+ }
169
+ }
170
+ [devices addObject:@{
171
+ @"id": device.uniqueID ?: @"",
172
+ @"name": device.localizedName ?: @"iPhone",
173
+ @"manufacturer": device.manufacturer ?: @"Apple",
174
+ @"model": device.modelID ?: @"",
175
+ @"connected": @(device.isConnected),
176
+ @"suspended": @(device.isSuspended),
177
+ @"width": @(largest.width),
178
+ @"height": @(largest.height),
179
+ @"hasAudio": @YES,
180
+ @"transport": @"usb"
181
+ }];
182
+ }
183
+ return devices;
184
+ }
185
+
186
+ extern "C" bool startIOSDeviceRecording(NSString *outputPath,
187
+ NSString *deviceId,
188
+ BOOL captureCamera,
189
+ NSString *cameraOutputPath,
190
+ NSString *cameraDeviceId,
191
+ BOOL captureMicrophone,
192
+ NSString *audioOutputPath,
193
+ NSString *audioDeviceId,
194
+ NSError **errorOut) {
195
+ @autoreleasepool {
196
+ if (g_iosRecorder && (g_iosRecorder.recording || g_iosRecorder.startCompleted)) {
197
+ if (errorOut) {
198
+ *errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
199
+ code:1
200
+ userInfo:@{NSLocalizedDescriptionKey: @"An iPhone recording is already active"}];
201
+ }
202
+ return false;
203
+ }
204
+
205
+ AVCaptureDevice *device = MRIOSDeviceForId(deviceId);
206
+ if (!device) {
207
+ if (errorOut) {
208
+ *errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
209
+ code:2
210
+ userInfo:@{NSLocalizedDescriptionKey: @"No trusted USB iPhone capture device was found"}];
211
+ }
212
+ return false;
213
+ }
214
+
215
+ NSError *directoryError = nil;
216
+ NSString *directory = [outputPath stringByDeletingLastPathComponent];
217
+ [[NSFileManager defaultManager] createDirectoryAtPath:directory
218
+ withIntermediateDirectories:YES
219
+ attributes:nil
220
+ error:&directoryError];
221
+ if (directoryError) {
222
+ if (errorOut) *errorOut = directoryError;
223
+ return false;
224
+ }
225
+ [[NSFileManager defaultManager] removeItemAtPath:outputPath error:nil];
226
+ if (captureCamera && cameraOutputPath.length > 0) {
227
+ [[NSFileManager defaultManager] removeItemAtPath:cameraOutputPath error:nil];
228
+ }
229
+ if (captureMicrophone && audioOutputPath.length > 0) {
230
+ [[NSFileManager defaultManager] removeItemAtPath:audioOutputPath error:nil];
231
+ }
232
+
233
+ NSError *inputError = nil;
234
+ AVCaptureDeviceInput *input = [AVCaptureDeviceInput deviceInputWithDevice:device
235
+ error:&inputError];
236
+ if (!input) {
237
+ if (errorOut) *errorOut = inputError;
238
+ return false;
239
+ }
240
+
241
+ MRIOSDeviceRecorder *recorder = [[MRIOSDeviceRecorder alloc] init];
242
+ recorder.session = [[AVCaptureSession alloc] init];
243
+ recorder.deviceInput = input;
244
+ recorder.movieOutput = [[AVCaptureMovieFileOutput alloc] init];
245
+ recorder.outputPath = outputPath;
246
+ recorder.recording = NO;
247
+ recorder.startCompleted = NO;
248
+ recorder.finishCompleted = NO;
249
+ recorder.finishError = nil;
250
+ recorder.captureCamera = captureCamera;
251
+ recorder.captureMicrophone = captureMicrophone;
252
+ recorder.cameraOutputPath = cameraOutputPath;
253
+ recorder.audioOutputPath = audioOutputPath;
254
+ recorder.primaryStartedAt = nil;
255
+
256
+ [recorder.session beginConfiguration];
257
+ if ([recorder.session canSetSessionPreset:AVCaptureSessionPresetHigh]) {
258
+ recorder.session.sessionPreset = AVCaptureSessionPresetHigh;
259
+ }
260
+ if (![recorder.session canAddInput:input]) {
261
+ [recorder.session commitConfiguration];
262
+ if (errorOut) {
263
+ *errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
264
+ code:3
265
+ userInfo:@{NSLocalizedDescriptionKey: @"The iPhone capture input could not be attached"}];
266
+ }
267
+ return false;
268
+ }
269
+ [recorder.session addInput:input];
270
+ if (![recorder.session canAddOutput:recorder.movieOutput]) {
271
+ [recorder.session commitConfiguration];
272
+ if (errorOut) {
273
+ *errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
274
+ code:4
275
+ userInfo:@{NSLocalizedDescriptionKey: @"The iPhone movie output could not be attached"}];
276
+ }
277
+ return false;
278
+ }
279
+ [recorder.session addOutput:recorder.movieOutput];
280
+ [recorder.session commitConfiguration];
281
+
282
+ // Frequent movie fragments keep long recordings recoverable if the cable
283
+ // disconnects or the app exits before the final movie atom is written.
284
+ recorder.movieOutput.movieFragmentInterval = CMTimeMakeWithSeconds(2.0, 600);
285
+ g_iosRecorder = recorder;
286
+
287
+ // Camera and microphone sessions are prepared first but discard their
288
+ // warm-up samples until AVCaptureMovieFileOutput confirms the iPhone's
289
+ // first frame. Every produced file therefore starts at the same t=0.
290
+ MRSyncConfigure(captureMicrophone);
291
+ MRSyncConfigureCamera(captureCamera);
292
+ MRSyncConfigurePrimaryStart(captureCamera || captureMicrophone);
293
+
294
+ if (captureCamera) {
295
+ NSError *cameraError = nil;
296
+ if (cameraOutputPath.length == 0 ||
297
+ !startCameraRecording(cameraOutputPath, cameraDeviceId, &cameraError)) {
298
+ MRSyncConfigurePrimaryStart(NO);
299
+ MRSyncConfigure(NO);
300
+ g_iosRecorder = nil;
301
+ if (errorOut) {
302
+ *errorOut = cameraError ?: [NSError errorWithDomain:@"MacRecorderIOS"
303
+ code:7
304
+ userInfo:@{NSLocalizedDescriptionKey: @"The selected camera could not be started"}];
305
+ }
306
+ return false;
307
+ }
308
+ }
309
+
310
+ if (captureMicrophone) {
311
+ NSError *audioError = nil;
312
+ if (audioOutputPath.length == 0 ||
313
+ !startStandaloneAudioRecording(audioOutputPath, audioDeviceId, &audioError)) {
314
+ if (isCameraRecording()) stopCameraRecording();
315
+ MRSyncConfigurePrimaryStart(NO);
316
+ MRSyncConfigure(NO);
317
+ g_iosRecorder = nil;
318
+ if (errorOut) {
319
+ *errorOut = audioError ?: [NSError errorWithDomain:@"MacRecorderIOS"
320
+ code:8
321
+ userInfo:@{NSLocalizedDescriptionKey: @"The selected microphone could not be started"}];
322
+ }
323
+ return false;
324
+ }
325
+ }
326
+
327
+ [recorder.session startRunning];
328
+ if (!recorder.session.isRunning) {
329
+ if (isCameraRecording()) stopCameraRecording();
330
+ if (isStandaloneAudioRecording()) stopStandaloneAudioRecording();
331
+ MRSyncConfigurePrimaryStart(NO);
332
+ MRSyncConfigure(NO);
333
+ if (errorOut) {
334
+ *errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
335
+ code:5
336
+ userInfo:@{NSLocalizedDescriptionKey: @"The iPhone capture session did not start"}];
337
+ }
338
+ g_iosRecorder = nil;
339
+ return false;
340
+ }
341
+
342
+ [recorder.movieOutput startRecordingToOutputFileURL:[NSURL fileURLWithPath:outputPath]
343
+ recordingDelegate:recorder];
344
+ bool started = MRWaitForFlag(^bool{
345
+ return recorder.startCompleted;
346
+ }, 10.0);
347
+ if (!started) {
348
+ if (recorder.movieOutput.isRecording) [recorder.movieOutput stopRecording];
349
+ [recorder.session stopRunning];
350
+ if (isCameraRecording()) stopCameraRecording();
351
+ if (isStandaloneAudioRecording()) stopStandaloneAudioRecording();
352
+ MRSyncConfigurePrimaryStart(NO);
353
+ MRSyncConfigure(NO);
354
+ g_iosRecorder = nil;
355
+ if (errorOut) {
356
+ *errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
357
+ code:6
358
+ userInfo:@{NSLocalizedDescriptionKey: @"Timed out waiting for the first iPhone frame"}];
359
+ }
360
+ return false;
361
+ }
362
+ if (captureCamera && !waitForCameraRecordingStart(8.0)) {
363
+ MRLog(@"❌ Camera did not produce a synchronized frame for iPhone recording");
364
+ if (recorder.movieOutput.isRecording) [recorder.movieOutput stopRecording];
365
+ MRWaitForFlag(^bool{ return recorder.finishCompleted; }, 10.0);
366
+ [recorder.session stopRunning];
367
+ if (isCameraRecording()) stopCameraRecording();
368
+ if (isStandaloneAudioRecording()) stopStandaloneAudioRecording();
369
+ MRSyncConfigurePrimaryStart(NO);
370
+ MRSyncConfigure(NO);
371
+ g_iosRecorder = nil;
372
+ if (errorOut) {
373
+ *errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
374
+ code:9
375
+ userInfo:@{NSLocalizedDescriptionKey: @"Timed out waiting for the selected camera"}];
376
+ }
377
+ return false;
378
+ }
379
+ return true;
380
+ }
381
+ }
382
+
383
+ extern "C" bool stopIOSDeviceRecording(void) {
384
+ @autoreleasepool {
385
+ MRIOSDeviceRecorder *recorder = g_iosRecorder;
386
+ if (!recorder) return true;
387
+
388
+ if (recorder.primaryStartedAt) {
389
+ NSTimeInterval duration = MAX(0.0, -[recorder.primaryStartedAt timeIntervalSinceNow]);
390
+ MRSyncSetStopLimitSeconds(duration);
391
+ }
392
+
393
+ BOOL cameraStopped = YES;
394
+ BOOL microphoneStopped = YES;
395
+ if (recorder.captureCamera && isCameraRecording()) {
396
+ cameraStopped = stopCameraRecording();
397
+ }
398
+ if (recorder.captureMicrophone && isStandaloneAudioRecording()) {
399
+ microphoneStopped = stopStandaloneAudioRecording();
400
+ }
401
+
402
+ if (recorder.movieOutput.isRecording) {
403
+ [recorder.movieOutput stopRecording];
404
+ MRWaitForFlag(^bool{
405
+ return recorder.finishCompleted;
406
+ }, 20.0);
407
+ }
408
+ if (recorder.session.isRunning) [recorder.session stopRunning];
409
+
410
+ NSError *finishError = recorder.finishError;
411
+ BOOL finished = recorder.finishCompleted || !recorder.startCompleted;
412
+ BOOL fileExists = [[NSFileManager defaultManager] fileExistsAtPath:recorder.outputPath];
413
+ MRSyncConfigurePrimaryStart(NO);
414
+ MRSyncConfigure(NO);
415
+ g_iosRecorder = nil;
416
+
417
+ if (finishError) {
418
+ NSNumber *successfullyFinished = finishError.userInfo[AVErrorRecordingSuccessfullyFinishedKey];
419
+ if (![successfullyFinished boolValue]) return false;
420
+ }
421
+ if (!cameraStopped || !microphoneStopped) {
422
+ MRLog(@"⚠️ iPhone recording finalized, but an auxiliary camera/microphone writer reported a stop error");
423
+ }
424
+ // Never discard a valid phone screen recording because an optional
425
+ // auxiliary source failed to finalize. The JS layer validates each
426
+ // returned path independently before packaging it.
427
+ return finished && fileExists;
428
+ }
429
+ }
430
+
431
+ extern "C" bool isIOSDeviceRecording(void) {
432
+ return g_iosRecorder && (g_iosRecorder.recording || g_iosRecorder.movieOutput.isRecording);
433
+ }
434
+
435
+ extern "C" NSString *currentIOSDeviceRecordingPath(void) {
436
+ return g_iosRecorder.outputPath;
437
+ }
438
+
439
+ Napi::Value GetIOSCaptureDevices(const Napi::CallbackInfo& info) {
440
+ Napi::Env env = info.Env();
441
+ NSArray<NSDictionary *> *devices = listIOSCaptureDevices();
442
+ Napi::Array result = Napi::Array::New(env, devices.count);
443
+ for (NSUInteger index = 0; index < devices.count; index++) {
444
+ NSDictionary *device = devices[index];
445
+ Napi::Object item = Napi::Object::New(env);
446
+ item.Set("id", Napi::String::New(env, [device[@"id"] UTF8String]));
447
+ item.Set("name", Napi::String::New(env, [device[@"name"] UTF8String]));
448
+ item.Set("manufacturer", Napi::String::New(env, [device[@"manufacturer"] UTF8String]));
449
+ item.Set("model", Napi::String::New(env, [device[@"model"] UTF8String]));
450
+ item.Set("connected", Napi::Boolean::New(env, [device[@"connected"] boolValue]));
451
+ item.Set("suspended", Napi::Boolean::New(env, [device[@"suspended"] boolValue]));
452
+ item.Set("width", Napi::Number::New(env, [device[@"width"] intValue]));
453
+ item.Set("height", Napi::Number::New(env, [device[@"height"] intValue]));
454
+ item.Set("hasAudio", Napi::Boolean::New(env, true));
455
+ item.Set("transport", Napi::String::New(env, "usb"));
456
+ result.Set(index, item);
457
+ }
458
+ return result;
459
+ }
460
+
461
+ Napi::Value StartIOSDeviceRecording(const Napi::CallbackInfo& info) {
462
+ Napi::Env env = info.Env();
463
+ if (info.Length() < 1 || !info[0].IsString()) {
464
+ Napi::TypeError::New(env, "Output path is required").ThrowAsJavaScriptException();
465
+ return env.Null();
466
+ }
467
+ NSString *outputPath = [NSString stringWithUTF8String:info[0].As<Napi::String>().Utf8Value().c_str()];
468
+ NSString *deviceId = nil;
469
+ if (info.Length() > 1 && info[1].IsString()) {
470
+ deviceId = [NSString stringWithUTF8String:info[1].As<Napi::String>().Utf8Value().c_str()];
471
+ }
472
+ BOOL captureCamera = NO;
473
+ BOOL captureMicrophone = NO;
474
+ NSString *cameraOutputPath = nil;
475
+ NSString *cameraDeviceId = nil;
476
+ NSString *audioOutputPath = nil;
477
+ NSString *audioDeviceId = nil;
478
+ if (info.Length() > 2 && info[2].IsObject()) {
479
+ Napi::Object options = info[2].As<Napi::Object>();
480
+ auto readBool = [&](const char *key) -> BOOL {
481
+ Napi::Value value = options.Get(key);
482
+ return value.IsBoolean() && value.As<Napi::Boolean>().Value();
483
+ };
484
+ auto readString = [&](const char *key) -> NSString * {
485
+ Napi::Value value = options.Get(key);
486
+ if (!value.IsString()) return nil;
487
+ std::string text = value.As<Napi::String>().Utf8Value();
488
+ return text.empty() ? nil : [NSString stringWithUTF8String:text.c_str()];
489
+ };
490
+ captureCamera = readBool("captureCamera");
491
+ captureMicrophone = readBool("includeMicrophone");
492
+ cameraOutputPath = readString("cameraOutputPath");
493
+ cameraDeviceId = readString("cameraDeviceId");
494
+ audioOutputPath = readString("audioOutputPath");
495
+ audioDeviceId = readString("audioDeviceId");
496
+ }
497
+ NSError *error = nil;
498
+ bool success = startIOSDeviceRecording(outputPath,
499
+ deviceId,
500
+ captureCamera,
501
+ cameraOutputPath,
502
+ cameraDeviceId,
503
+ captureMicrophone,
504
+ audioOutputPath,
505
+ audioDeviceId,
506
+ &error);
507
+ if (!success && error) {
508
+ // startIOSDeviceRecording owns an inner autorelease pool. Do not bridge
509
+ // the NSError past that pool into V8; the JS wrapper turns false into a
510
+ // stable user-facing error and avoids a dangling Objective-C object.
511
+ MRLog(@"❌ iPhone capture could not start");
512
+ }
513
+ return Napi::Boolean::New(env, success);
514
+ }
515
+
516
+ Napi::Value StopIOSDeviceRecording(const Napi::CallbackInfo& info) {
517
+ return Napi::Boolean::New(info.Env(), stopIOSDeviceRecording());
518
+ }
519
+
520
+ Napi::Value GetIOSDeviceRecordingStatus(const Napi::CallbackInfo& info) {
521
+ Napi::Object status = Napi::Object::New(info.Env());
522
+ status.Set("isRecording", Napi::Boolean::New(info.Env(), isIOSDeviceRecording()));
523
+ NSString *path = currentIOSDeviceRecordingPath();
524
+ if (path.length > 0) status.Set("outputPath", Napi::String::New(info.Env(), [path UTF8String]));
525
+ return status;
526
+ }
527
+
528
+ Napi::Object InitIOSDeviceRecorder(Napi::Env env, Napi::Object exports) {
529
+ exports.Set("getIOSCaptureDevices", Napi::Function::New(env, GetIOSCaptureDevices));
530
+ exports.Set("startIOSDeviceRecording", Napi::Function::New(env, StartIOSDeviceRecording));
531
+ exports.Set("stopIOSDeviceRecording", Napi::Function::New(env, StopIOSDeviceRecording));
532
+ exports.Set("getIOSDeviceRecordingStatus", Napi::Function::New(env, GetIOSDeviceRecordingStatus));
533
+ return exports;
534
+ }
@@ -55,6 +55,9 @@ Napi::Object InitKeyboardTracker(Napi::Env env, Napi::Object exports);
55
55
  // Window selector function declarations
56
56
  Napi::Object InitWindowSelector(Napi::Env env, Napi::Object exports);
57
57
 
58
+ // USB iPhone/iPad screen capture functions
59
+ Napi::Object InitIOSDeviceRecorder(Napi::Env env, Napi::Object exports);
60
+
58
61
  // Window selector overlay functions (external)
59
62
  extern "C" void hideOverlays();
60
63
  extern "C" void showOverlays();
@@ -1779,6 +1782,9 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
1779
1782
 
1780
1783
  // Initialize window selector
1781
1784
  InitWindowSelector(env, exports);
1785
+
1786
+ // Initialize direct USB iPhone/iPad capture
1787
+ InitIOSDeviceRecorder(env, exports);
1782
1788
 
1783
1789
  return exports;
1784
1790
  }
@@ -1600,6 +1600,7 @@ static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *c
1600
1600
 
1601
1601
  // Find display containing this window to get scale factor
1602
1602
  CGFloat scaleFactor = 1.0;
1603
+ SCDisplay *windowDisplay = nil;
1603
1604
  CGPoint windowCenter = CGPointMake(
1604
1605
  targetWindow.frame.origin.x + windowLogicalWidth / 2.0,
1605
1606
  targetWindow.frame.origin.y + windowLogicalHeight / 2.0
@@ -1608,6 +1609,7 @@ static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *c
1608
1609
  CGRect dispBounds = CGRectMake(disp.frame.origin.x, disp.frame.origin.y,
1609
1610
  disp.frame.size.width, disp.frame.size.height);
1610
1611
  if (CGRectContainsPoint(dispBounds, windowCenter)) {
1612
+ windowDisplay = disp;
1611
1613
  scaleFactor = SCKBackingScaleForDisplay(disp);
1612
1614
  break;
1613
1615
  }
@@ -1615,24 +1617,32 @@ static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *c
1615
1617
  // Fallback: use main display scale factor
1616
1618
  if (scaleFactor == 1.0 && content.displays.count > 0) {
1617
1619
  SCDisplay *mainDisp = content.displays.firstObject;
1620
+ if (!windowDisplay) windowDisplay = mainDisp;
1618
1621
  scaleFactor = SCKBackingScaleForDisplay(mainDisp);
1619
1622
  }
1620
1623
 
1624
+ // DOGRUDAN pencere yakalama. Ekrandan kirpma/kompozit YOK: bu
1625
+ // filtre pencereyi kendi basina yakalar, pencere tasinsa da takip
1626
+ // eder. (Display kompozit alternatifi denendi ve reddedildi.)
1621
1627
  filter = [[SCContentFilter alloc] initWithDesktopIndependentWindow:targetWindow];
1628
+
1629
+ CGFloat displayScale = scaleFactor;
1630
+ CGFloat filterScaleLog = -1.0;
1622
1631
  if (@available(macOS 14.0, *)) {
1623
- // ScreenCaptureKit exposes the authoritative point→pixel
1624
- // conversion for this exact filter. Prefer it over display
1625
- // heuristics (scaled modes and windows spanning displays).
1632
+ // filter.pointPixelScale, SCK'nin bu filtre icin GERCEKTEN
1633
+ // uretecegi point->pixel oranidir; tek yetkili kaynak odur.
1634
+ //
1635
+ // Eskiden MAX(displayScale, filterScale) aliniyordu. Bu yanlis:
1636
+ // olculdu ki harici 3440x1440 (1x) ekranda display heuristigi
1637
+ // 2.00 donerken filtre dogru sekilde 1.00 donuyor. MAX ile 2x
1638
+ // istenince SCK yine 1x uretiyor ve icerik karenin sol ust
1639
+ // ceyregine sikisip geri kalani siyah kaliyordu.
1640
+ // Retina'da filtre zaten 2.00 donuyor (olculdu), yani Retina
1641
+ // keskinligi kaybi YOK.
1626
1642
  CGFloat filterScale = filter.pointPixelScale;
1643
+ filterScaleLog = filterScale;
1627
1644
  if (isfinite(filterScale) && filterScale >= 1.0 && filterScale <= 4.0) {
1628
- // desktopIndependentWindow filtresi bazı macOS/sürüm
1629
- // kombinasyonlarında Retina pencerede bile 1.0 dönebiliyor.
1630
- // Display geometrisinden bulunan 2x değeri 1x'e indirirsek
1631
- // 1500x960 pencere gerçek 3000x1920 yerine logical boyutta
1632
- // kaydediliyor ve zoom'da metin detayı geri dönülemez
1633
- // biçimde kayboluyor. Filter yalnızca daha yüksek/güvenli
1634
- // bir backing scale bildiriyorsa heuristiği yükseltsin.
1635
- scaleFactor = MAX(scaleFactor, filterScale);
1645
+ scaleFactor = filterScale;
1636
1646
  }
1637
1647
  }
1638
1648
  scaleFactor = MIN(4.0, MAX(1.0, scaleFactor));
@@ -1640,9 +1650,11 @@ static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *c
1640
1650
  NSInteger physicalWindowWidth = (NSInteger)llround(windowLogicalWidth * scaleFactor);
1641
1651
  NSInteger physicalWindowHeight = (NSInteger)llround(windowLogicalHeight * scaleFactor);
1642
1652
 
1643
- MRLog(@"🪟 Recording window: %@ logical=%ux%u, physical=%ldx%ld, scale=%.2fx",
1653
+ MRLog(@"🪟 Recording window: %@ logical=%ux%u, physical=%ldx%ld, scale=%.2fx "
1654
+ @"(displayScale=%.2f filterScale=%.2f)",
1644
1655
  targetWindow.title, (unsigned)windowLogicalWidth, (unsigned)windowLogicalHeight,
1645
- (long)physicalWindowWidth, (long)physicalWindowHeight, scaleFactor);
1656
+ (long)physicalWindowWidth, (long)physicalWindowHeight, scaleFactor,
1657
+ displayScale, filterScaleLog);
1646
1658
  recordingWidth = physicalWindowWidth;
1647
1659
  recordingHeight = physicalWindowHeight;
1648
1660
  } else {
@@ -1696,8 +1708,12 @@ static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *c
1696
1708
  logicalWidth = filterRect.size.width;
1697
1709
  logicalHeight = filterRect.size.height;
1698
1710
  }
1711
+ // Pencere dalindaki ile ayni kural: SCK'nin uretecegi olcek
1712
+ // filtreden okunur. MAX(display, filter) yanlisti — harici
1713
+ // 3440x1440 (1x) ekranda display heuristigi 2.00 donuyor ve
1714
+ // istenen boyut SCK'nin urettiginin iki kati oluyordu.
1699
1715
  if (isfinite(filterScale) && filterScale >= 1.0 && filterScale <= 4.0) {
1700
- scaleFactor = MAX(scaleFactor, filterScale);
1716
+ scaleFactor = filterScale;
1701
1717
  }
1702
1718
  }
1703
1719
  scaleFactor = MIN(4.0, MAX(1.0, scaleFactor));
@@ -1732,9 +1748,10 @@ static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *c
1732
1748
  if (targetDisplay) {
1733
1749
  cropScaleFactor = SCKBackingScaleForDisplay(targetDisplay);
1734
1750
  if (@available(macOS 14.0, *)) {
1751
+ // Ekran/pencere dallariyla ayni kural: yetkili kaynak filtre.
1735
1752
  CGFloat filterScale = filter.pointPixelScale;
1736
1753
  if (isfinite(filterScale) && filterScale >= 1.0 && filterScale <= 4.0) {
1737
- cropScaleFactor = MAX(cropScaleFactor, filterScale);
1754
+ cropScaleFactor = filterScale;
1738
1755
  }
1739
1756
  }
1740
1757
  }
@@ -1779,11 +1796,19 @@ static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *c
1779
1796
  streamConfig.queueDepth = 8;
1780
1797
  }
1781
1798
  if (@available(macOS 14.0, *)) {
1782
- // Use best capture resolution for maximum quality on Retina displays
1783
- streamConfig.captureResolution = SCCaptureResolutionBest;
1784
- // Make stream opaque to avoid alpha channel overhead
1799
+ // captureResolution = SCCaptureResolutionBest BILEREK KULLANILMIYOR.
1800
+ //
1801
+ // Retina kalitesi icin eklenmisti ama GPU (Metal) ile cizen
1802
+ // uygulamalarda pencere kaydinda ICERIGI TAMAMEN DUSURUYOR: iTerm2'de
1803
+ // sekme cubugu/bolme basliklari (AppKit) geliyor, oturum icerigi
1804
+ // (Metal katmani) simsiyah cikiyordu. Olculdu: ayni pencere, ayni
1805
+ // kosullar, sadece bu satir kapaliyken icerik geri geldi.
1806
+ // Cozunurluk zaten streamConfig.width/height ile isteniyor; bu ayara
1807
+ // ihtiyac yok.
1808
+ //
1809
+ // shouldBeOpaque ve colorSpaceName ayni deneyde TEST EDILDI ve
1810
+ // masum cikti (ikisi de acikken icerik geliyor) - dokunulmadi.
1785
1811
  streamConfig.shouldBeOpaque = YES;
1786
- MRLog(@"🎯 Using SCCaptureResolutionBest + shouldBeOpaque for maximum quality (macOS 14+)");
1787
1812
  }
1788
1813
  if (@available(macOS 13.0, *)) {
1789
1814
  // Frame'leri bilinen bir renk uzayında (sRGB) iste; encoder tarafında
@@ -32,6 +32,14 @@ void MRSyncConfigureCamera(BOOL expectCamera);
32
32
  void MRSyncMarkCameraFirstFrame(CMTime timestamp);
33
33
  BOOL MRSyncShouldHoldAudioSample(CMTime timestamp);
34
34
 
35
+ // Some primary sources (for example a USB iPhone muxed device) need a short
36
+ // asynchronous warm-up before their first frame is committed. Camera and
37
+ // microphone writers can use this barrier to discard their warm-up samples and
38
+ // begin at the same host-clock instant as that primary source.
39
+ void MRSyncConfigurePrimaryStart(BOOL expectPrimary);
40
+ void MRSyncMarkPrimaryStarted(CMTime timestamp);
41
+ BOOL MRSyncShouldHoldForPrimary(CMTime timestamp);
42
+
35
43
  // Optional hard stop limit (seconds) shared across capture components.
36
44
  void MRSyncSetStopLimitSeconds(double seconds);
37
45
  double MRSyncGetStopLimitSeconds(void);
@@ -25,6 +25,13 @@ static CMTime g_cameraFirstTimestamp = kCMTimeInvalid;
25
25
  static CMTime g_audioHoldFirstTimestamp = kCMTimeInvalid;
26
26
  static BOOL g_audioHoldLogged = NO;
27
27
 
28
+ // Primary-source start barrier (USB iPhone screen capture, etc.).
29
+ static BOOL g_expectPrimary = NO;
30
+ static BOOL g_primaryReady = YES;
31
+ static CMTime g_primaryStartTimestamp = kCMTimeInvalid;
32
+ static CMTime g_primaryHoldFirstTimestamp = kCMTimeInvalid;
33
+ static BOOL g_primaryHoldLogged = NO;
34
+
28
35
  void MRSyncConfigure(BOOL expectAudio) {
29
36
  dispatch_sync(MRSyncQueue(), ^{
30
37
  g_expectAudio = expectAudio;
@@ -40,6 +47,11 @@ void MRSyncConfigure(BOOL expectAudio) {
40
47
  g_cameraFirstTimestamp = kCMTimeInvalid;
41
48
  g_audioHoldFirstTimestamp = kCMTimeInvalid;
42
49
  g_audioHoldLogged = NO;
50
+ g_expectPrimary = NO;
51
+ g_primaryReady = YES;
52
+ g_primaryStartTimestamp = kCMTimeInvalid;
53
+ g_primaryHoldFirstTimestamp = kCMTimeInvalid;
54
+ g_primaryHoldLogged = NO;
43
55
  });
44
56
  }
45
57
 
@@ -245,6 +257,82 @@ BOOL MRSyncShouldHoldAudioSample(CMTime timestamp) {
245
257
  return shouldHold;
246
258
  }
247
259
 
260
+ void MRSyncConfigurePrimaryStart(BOOL expectPrimary) {
261
+ dispatch_sync(MRSyncQueue(), ^{
262
+ g_expectPrimary = expectPrimary;
263
+ g_primaryReady = expectPrimary ? NO : YES;
264
+ g_primaryStartTimestamp = kCMTimeInvalid;
265
+ g_primaryHoldFirstTimestamp = kCMTimeInvalid;
266
+ g_primaryHoldLogged = NO;
267
+ });
268
+ if (expectPrimary) {
269
+ MRLog(@"🔄 A/V SYNC: Primary-source start barrier enabled");
270
+ }
271
+ }
272
+
273
+ void MRSyncMarkPrimaryStarted(CMTime timestamp) {
274
+ if (!CMTIME_IS_VALID(timestamp)) return;
275
+
276
+ __block BOOL logRelease = NO;
277
+ dispatch_sync(MRSyncQueue(), ^{
278
+ if (g_primaryReady) return;
279
+ g_primaryStartTimestamp = timestamp;
280
+ g_primaryReady = YES;
281
+ g_primaryHoldFirstTimestamp = kCMTimeInvalid;
282
+ g_primaryHoldLogged = NO;
283
+ logRelease = YES;
284
+ });
285
+ if (logRelease) {
286
+ MRLog(@"🎯 A/V SYNC: Primary source started - releasing camera and microphone");
287
+ }
288
+ }
289
+
290
+ BOOL MRSyncShouldHoldForPrimary(CMTime timestamp) {
291
+ if (!CMTIME_IS_VALID(timestamp)) return NO;
292
+
293
+ __block BOOL shouldHold = NO;
294
+ __block BOOL logHold = NO;
295
+ __block BOOL logRelease = NO;
296
+ dispatch_sync(MRSyncQueue(), ^{
297
+ if (!g_expectPrimary || g_primaryReady) {
298
+ if (CMTIME_IS_VALID(g_primaryStartTimestamp) &&
299
+ CMTIME_COMPARE_INLINE(timestamp, <, g_primaryStartTimestamp)) {
300
+ shouldHold = YES;
301
+ }
302
+ return;
303
+ }
304
+
305
+ if (!CMTIME_IS_VALID(g_primaryHoldFirstTimestamp)) {
306
+ g_primaryHoldFirstTimestamp = timestamp;
307
+ shouldHold = YES;
308
+ if (!g_primaryHoldLogged) {
309
+ g_primaryHoldLogged = YES;
310
+ logHold = YES;
311
+ }
312
+ return;
313
+ }
314
+
315
+ // Fail open if a future primary source forgets to signal start.
316
+ CMTime elapsed = CMTimeSubtract(timestamp, g_primaryHoldFirstTimestamp);
317
+ if (CMTIME_COMPARE_INLINE(elapsed, >, CMTimeMakeWithSeconds(12.0, 600))) {
318
+ g_primaryReady = YES;
319
+ g_primaryHoldFirstTimestamp = kCMTimeInvalid;
320
+ g_primaryHoldLogged = NO;
321
+ shouldHold = NO;
322
+ logRelease = YES;
323
+ return;
324
+ }
325
+ shouldHold = YES;
326
+ });
327
+
328
+ if (logHold) {
329
+ MRLog(@"⏸️ A/V SYNC: Camera/microphone waiting for primary source");
330
+ } else if (logRelease) {
331
+ MRLog(@"▶️ A/V SYNC: Primary-source hold released by safety timeout");
332
+ }
333
+ return shouldHold;
334
+ }
335
+
248
336
  void MRSyncSetStopLimitSeconds(double seconds) {
249
337
  dispatch_sync(MRSyncQueue(), ^{
250
338
  g_stopLimitSeconds = seconds;
@@ -239,6 +239,7 @@ void updateOverlay();
239
239
  NSDictionary* getWindowUnderCursor(CGPoint point);
240
240
  NSArray* getAllSelectableWindows();
241
241
  bool bringWindowToFront(int windowId);
242
+ bool activateWindowOwnerApp(int windowId);
242
243
  void cleanupRecordingPreview();
243
244
  bool showRecordingPreview(NSDictionary *windowInfo);
244
245
  bool hideRecordingPreview();
@@ -842,6 +843,61 @@ static void ApplyBrandButtonStyle(NSButton *button) {
842
843
 
843
844
  static WindowSelectorDelegate *g_delegate = nil;
844
845
 
846
+ // Pencerenin SAHIBI UYGULAMAYI aktive eder.
847
+ //
848
+ // bringWindowToFront() yalnizca AXRaise + AXFocused yapar; bu pencereyi one
849
+ // getirir ama uygulamayi AKTIF hale getirmez. Kayit basladiginda kaydedilen
850
+ // uygulama pasif kalirsa "odagi kaybedince gizlen" davranisindaki pencereler
851
+ // (iTerm2 hotkey window vb.) kendini gizler ve kayitta gorunmez. Pencere kaydi
852
+ // baslarken hedef uygulama gercekten aktif olmalidir ki kullanici icine
853
+ // yazabilsin.
854
+ bool activateWindowOwnerApp(int windowId) {
855
+ @autoreleasepool {
856
+ @try {
857
+ CFArrayRef cgWindowList = CGWindowListCopyWindowInfo(kCGWindowListOptionAll, kCGNullWindowID);
858
+ if (!cgWindowList) return false;
859
+
860
+ NSArray *windowArray = (__bridge NSArray *)cgWindowList;
861
+ pid_t ownerPid = 0;
862
+ for (NSDictionary *windowInfo in windowArray) {
863
+ NSNumber *cgWindowId = [windowInfo objectForKey:(NSString *)kCGWindowNumber];
864
+ if ([cgWindowId intValue] == windowId) {
865
+ NSNumber *processId = [windowInfo objectForKey:(NSString *)kCGWindowOwnerPID];
866
+ ownerPid = (pid_t)[processId intValue];
867
+ break;
868
+ }
869
+ }
870
+ CFRelease(cgWindowList);
871
+
872
+ if (ownerPid <= 0) {
873
+ NSLog(@"⚠️ activateWindowOwnerApp: window %d icin PID bulunamadi", windowId);
874
+ return false;
875
+ }
876
+
877
+ NSRunningApplication *app =
878
+ [NSRunningApplication runningApplicationWithProcessIdentifier:ownerPid];
879
+ if (!app) {
880
+ NSLog(@"⚠️ activateWindowOwnerApp: PID %d icin uygulama yok", ownerPid);
881
+ return false;
882
+ }
883
+
884
+ // Once uygulamayi aktive et, sonra hedef pencereyi one kaldir.
885
+ // Ters sirada yapilirsa aktivasyon uygulamanin kendi ana penceresini
886
+ // one getirip secilen pencereyi geri plana itebiliyor.
887
+ BOOL activated = [app activateWithOptions:NSApplicationActivateIgnoringOtherApps];
888
+ NSLog(@"🔝 activateWindowOwnerApp: PID %d aktive=%d (window %d)",
889
+ ownerPid, activated, windowId);
890
+
891
+ bringWindowToFront(windowId);
892
+ return activated ? true : false;
893
+
894
+ } @catch (NSException *exception) {
895
+ NSLog(@"❌ activateWindowOwnerApp exception: %@", exception.reason);
896
+ return false;
897
+ }
898
+ }
899
+ }
900
+
845
901
  // Bring window to front using Accessibility API
846
902
  bool bringWindowToFront(int windowId) {
847
903
  @autoreleasepool {
@@ -2696,6 +2752,24 @@ Napi::Value BringWindowToFront(const Napi::CallbackInfo& info) {
2696
2752
  }
2697
2753
  }
2698
2754
 
2755
+ // NAPI Function: Pencerenin sahibi uygulamayi aktive et (pencere kaydi icin)
2756
+ Napi::Value ActivateWindowOwnerApp(const Napi::CallbackInfo& info) {
2757
+ Napi::Env env = info.Env();
2758
+
2759
+ if (info.Length() < 1 || !info[0].IsNumber()) {
2760
+ Napi::TypeError::New(env, "Window ID required").ThrowAsJavaScriptException();
2761
+ return env.Null();
2762
+ }
2763
+
2764
+ int windowId = info[0].As<Napi::Number>().Int32Value();
2765
+
2766
+ @try {
2767
+ return Napi::Boolean::New(env, activateWindowOwnerApp(windowId));
2768
+ } @catch (NSException *exception) {
2769
+ return Napi::Boolean::New(env, false);
2770
+ }
2771
+ }
2772
+
2699
2773
  // NAPI Function: Enable/Disable Auto Bring To Front
2700
2774
  Napi::Value SetBringToFrontEnabled(const Napi::CallbackInfo& info) {
2701
2775
  Napi::Env env = info.Env();
@@ -2937,6 +3011,7 @@ Napi::Object InitWindowSelector(Napi::Env env, Napi::Object exports) {
2937
3011
  exports.Set("getSelectedWindowInfo", Napi::Function::New(env, GetSelectedWindowInfo));
2938
3012
  exports.Set("getWindowSelectionStatus", Napi::Function::New(env, GetWindowSelectionStatus));
2939
3013
  exports.Set("bringWindowToFront", Napi::Function::New(env, BringWindowToFront));
3014
+ exports.Set("activateWindowOwnerApp", Napi::Function::New(env, ActivateWindowOwnerApp));
2940
3015
  exports.Set("setBringToFrontEnabled", Napi::Function::New(env, SetBringToFrontEnabled));
2941
3016
  exports.Set("showRecordingPreview", Napi::Function::New(env, ShowRecordingPreview));
2942
3017
  exports.Set("hideRecordingPreview", Napi::Function::New(env, HideRecordingPreview));