返回 CodeWhale
darwin-recording.h
根目录 / crates / tui / plugins / computer-use / src / backends / darwin-recording.h
1 // ScreenCaptureKit capture has no selection UI or desktop dimming overlay.
2 // Runs inside the signed accessibility helper until SIGINT or duration expiry.
3 #import <ScreenCaptureKit/ScreenCaptureKit.h>
4 #import <AVFoundation/AVFoundation.h>
5 #include <signal.h>
6 #include <poll.h>
7 #include <unistd.h>
8
9 static volatile sig_atomic_t cuStopRecording = 0;
10 static BOOL cuRecordingOwnerPipe = NO;
11 static void cuRecordingSignal(int sig) { cuStopRecording = 1; }
12 static BOOL cuRecordingOwnerClosed(void) {
13 if(!cuRecordingOwnerPipe) return NO;
14 struct pollfd fd={STDIN_FILENO,POLLHUP,0};
15 return poll(&fd,1,0)>0 && (fd.revents&POLLHUP);
16 }
17
18 @interface CURecorder : NSObject <SCStreamOutput, SCStreamDelegate>
19 @property AVAssetWriter *writer;
20 @property AVAssetWriterInput *input;
21 @property NSError *failure;
22 @property BOOL started;
23 @end
24 @implementation CURecorder
25 - (void)stream:(SCStream *)stream didStopWithError:(NSError *)error {
26 self.failure=error; cuStopRecording=1;
27 }
28 - (void)stream:(SCStream *)stream didOutputSampleBuffer:(CMSampleBufferRef)sample ofType:(SCStreamOutputType)type {
29 if(type!=SCStreamOutputTypeScreen || !CMSampleBufferIsValid(sample)) return;
30 NSArray *attachments=(__bridge NSArray *)CMSampleBufferGetSampleAttachmentsArray(sample,NO);
31 if(!attachments.count || [attachments[0][SCStreamFrameInfoStatus] integerValue]!=SCFrameStatusComplete) return;
32 if(!self.started) {
33 if(![self.writer startWriting]) { self.failure=self.writer.error; cuStopRecording=1; return; }
34 [self.writer startSessionAtSourceTime:CMSampleBufferGetPresentationTimeStamp(sample)];
35 self.started=YES;
36 }
37 if(self.input.readyForMoreMediaData && ![self.input appendSampleBuffer:sample]) {
38 self.failure=self.writer.error; cuStopRecording=1;
39 }
40 }
41 @end
42
43 static void cuWait(dispatch_semaphore_t sem, NSTimeInterval seconds, BOOL starting) {
44 NSDate *deadline=[NSDate dateWithTimeIntervalSinceNow:seconds];
45 while(dispatch_semaphore_wait(sem,DISPATCH_TIME_NOW)!=0) {
46 if(starting && (cuStopRecording || cuRecordingOwnerClosed()))
47 @throw [NSException exceptionWithName:@"cancelled" reason:@"recording owner closed during startup; partial file retained" userInfo:nil];
48 if(deadline.timeIntervalSinceNow<=0) @throw [NSException exceptionWithName:@"recording" reason:@"screen recorder timed out" userInfo:nil];
49 [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.01]];
50 }
51 }
52 static id cuRecord(NSDictionary *args) {
53 cuRecordingOwnerPipe=[args[@"owner_pipe"] boolValue];
54 signal(SIGINT,cuRecordingSignal);signal(SIGTERM,cuRecordingSignal);
55 if(!CGPreflightScreenCaptureAccess()) @throw [NSException exceptionWithName:@"permission" reason:@"Screen Recording permission is missing" userInfo:nil];
56 NSString *file=args[@"file"];
57 if(![file isKindOfClass:NSString.class] || ![file isAbsolutePath] || [[NSFileManager defaultManager] fileExistsAtPath:file])
58 @throw [NSException exceptionWithName:@"recording" reason:@"recording needs a new absolute output path" userInfo:nil];
59 __block SCShareableContent *content; __block NSError *error;
60 dispatch_semaphore_t sem=dispatch_semaphore_create(0);
61 [SCShareableContent getShareableContentExcludingDesktopWindows:NO onScreenWindowsOnly:YES completionHandler:^(SCShareableContent *c,NSError *e){ content=c;error=e;dispatch_semaphore_signal(sem); }];
62 cuWait(sem,15,YES);
63 if(error) @throw [NSException exceptionWithName:@"recording" reason:error.localizedDescription userInfo:nil];
64 SCDisplay *display=nil;
65 for(SCDisplay *d in content.displays) if(d.displayID==[args[@"displayID"] unsignedIntValue]) display=d;
66 if(!display) @throw [NSException exceptionWithName:@"recording" reason:@"recording display is unavailable" userInfo:nil];
67 CGRect bounds=CGDisplayBounds(display.displayID), crop=CGRectMake(0,0,bounds.size.width,bounds.size.height);
68 NSArray *region=args[@"region"];
69 if(region) {
70 if(region.count!=4) @throw [NSException exceptionWithName:@"recording" reason:@"region must be [x,y,w,h]" userInfo:nil];
71 crop=CGRectMake([region[0] doubleValue]-bounds.origin.x,[region[1] doubleValue]-bounds.origin.y,[region[2] doubleValue],[region[3] doubleValue]);
72 if(!isfinite(crop.origin.x)||!isfinite(crop.origin.y)||!isfinite(crop.size.width)||!isfinite(crop.size.height)||crop.size.width<=0||crop.size.height<=0||!CGRectContainsRect(CGRectMake(0,0,bounds.size.width,bounds.size.height),crop))
73 @throw [NSException exceptionWithName:@"recording" reason:@"recording region must fit the selected display" userInfo:nil];
74 }
75 SCStreamConfiguration *config=[SCStreamConfiguration new];
76 config.sourceRect=crop;
77 // Even dimensions for H.264. Keep point-sized output to bound encoder cost.
78 config.width=MAX(2,((size_t)crop.size.width/2)*2);config.height=MAX(2,((size_t)crop.size.height/2)*2);
79 config.minimumFrameInterval=CMTimeMake(1,30);config.showsCursor=NO;config.capturesAudio=NO;
80 CURecorder *rec=[CURecorder new];
81 rec.writer=[[AVAssetWriter alloc] initWithURL:[NSURL fileURLWithPath:file] fileType:AVFileTypeQuickTimeMovie error:&error];
82 if(!rec.writer) @throw [NSException exceptionWithName:@"recording" reason:error.localizedDescription userInfo:nil];
83 rec.input=[AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeVideo outputSettings:@{AVVideoCodecKey:AVVideoCodecTypeH264,AVVideoWidthKey:@(config.width),AVVideoHeightKey:@(config.height)}];
84 rec.input.expectsMediaDataInRealTime=YES;
85 if(![rec.writer canAddInput:rec.input]) @throw [NSException exceptionWithName:@"recording" reason:@"video encoder unavailable" userInfo:nil];
86 [rec.writer addInput:rec.input];
87 dispatch_queue_t queue=dispatch_queue_create("net.codewhale.recording",DISPATCH_QUEUE_SERIAL);
88 SCContentFilter *filter=[[SCContentFilter alloc] initWithDisplay:display excludingWindows:@[]];
89 SCStream *stream=[[SCStream alloc] initWithFilter:filter configuration:config delegate:rec];
90 if(![stream addStreamOutput:rec type:SCStreamOutputTypeScreen sampleHandlerQueue:queue error:&error])
91 @throw [NSException exceptionWithName:@"recording" reason:error.localizedDescription userInfo:nil];
92 if(cuStopRecording || cuRecordingOwnerClosed())
93 @throw [NSException exceptionWithName:@"cancelled" reason:@"recording owner closed before capture started" userInfo:nil];
94 [stream startCaptureWithCompletionHandler:^(NSError *e){error=e;dispatch_semaphore_signal(sem);}];cuWait(sem,15,YES);
95 if(error) @throw [NSException exceptionWithName:@"recording" reason:error.localizedDescription userInfo:nil];
96 puts("{\"ready\":true}");fflush(stdout);
97 double duration=[args[@"durationSec"] doubleValue];
98 NSDate *deadline=duration>0?[NSDate dateWithTimeIntervalSinceNow:duration]:nil;
99 while(!cuStopRecording && !cuRecordingOwnerClosed() && (!deadline || deadline.timeIntervalSinceNow>0))
100 [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.05]];
101 [stream stopCaptureWithCompletionHandler:^(NSError *e){error=e;dispatch_semaphore_signal(sem);}];cuWait(sem,15,NO);
102 dispatch_sync(queue,^{}); // Drain frames before finalizing the file.
103 if(error || rec.failure || !rec.started) {
104 [rec.writer cancelWriting];
105 @throw [NSException exceptionWithName:@"recording" reason:(error?:rec.failure).localizedDescription?:@"no complete video frames received" userInfo:nil];
106 }
107 [rec.input markAsFinished];
108 [rec.writer finishWritingWithCompletionHandler:^{dispatch_semaphore_signal(sem);}];cuWait(sem,15,NO);
109 if(rec.writer.status!=AVAssetWriterStatusCompleted) @throw [NSException exceptionWithName:@"recording" reason:rec.writer.error.localizedDescription?:@"video finalization failed" userInfo:nil];
110 return @{@"finished":@YES,@"width":@(config.width),@"height":@(config.height)};
111 }
112
112 lines C