From d94c1733be2253e07ad32158b96cb7b04c6a35cd Mon Sep 17 00:00:00 2001 From: Phuoc NTB Date: Wed, 1 Jul 2026 13:11:59 +0700 Subject: [PATCH] fix: resolve memory leaks and crash in macOS camera and screen capture --- client/app.go | 17 +++- client/internal/camera/camera_darwin.m | 105 ++++++++++++++++++------ client/internal/screen/screen_darwin.go | 2 + 3 files changed, 96 insertions(+), 28 deletions(-) diff --git a/client/app.go b/client/app.go index 33623e7..fdbb4e4 100644 --- a/client/app.go +++ b/client/app.go @@ -1143,8 +1143,10 @@ func (a *App) connectWS() { } err := conn.ReadJSON(&msg) if err != nil { + log.Printf("[WS] ReadJSON error: %v", err) break } + log.Printf("[WS] Received event: %s", msg.Event) switch msg.Event { case "start_screenshot_stream": @@ -1276,26 +1278,39 @@ func (a *App) startWebcamStream() { go func() { ticker := time.NewTicker(250 * time.Millisecond) defer ticker.Stop() + emptyCount := 0 + sentCount := 0 for { select { case <-ticker.C: frame := camera.GetFrame() if frame == "" { + emptyCount++ + if emptyCount <= 20 || emptyCount%100 == 0 { + log.Printf("[WS] Webcam: no frame available (empty count: %d)", emptyCount) + } continue } + emptyCount = 0 + sentCount++ a.mu.Lock() conn := a.wsConn a.mu.Unlock() if conn != nil { - _ = conn.WriteJSON(map[string]any{ + err := conn.WriteJSON(map[string]any{ "event": "webcam_stream_frame", "data": map[string]any{ "imageBuffer": frame, }, }) + if sentCount <= 5 { + log.Printf("[WS] Webcam frame #%d sent (len=%d, err=%v)", sentCount, len(frame), err) + } + } else if sentCount <= 5 { + log.Printf("[WS] Webcam frame #%d: no WS connection", sentCount) } case <-a.streamCamStop: return diff --git a/client/internal/camera/camera_darwin.m b/client/internal/camera/camera_darwin.m index dc9c0de..914ca02 100644 --- a/client/internal/camera/camera_darwin.m +++ b/client/internal/camera/camera_darwin.m @@ -9,10 +9,12 @@ static AVCaptureSession *captureSession = nil; static AVCaptureVideoDataOutput *videoOutput = nil; static dispatch_queue_t captureQueue = nil; +static CIContext *sharedCIContext = nil; -// Latest frame stored as JPEG base64 +// Latest frame stored as JPEG base64 (owning reference) static NSString *latestFrameBase64 = nil; static NSLock *frameLock = nil; +static int frameCount = 0; // ── Delegate that receives sample buffers ───────────────────── @interface CameraFrameDelegate : NSObject @@ -23,29 +25,57 @@ static NSLock *frameLock = nil; - (void)captureOutput:(AVCaptureOutput *)output didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection { + @autoreleasepool { + CVImageBufferRef imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer); + if (!imageBuffer) { + NSLog(@"[CAMERA] didOutputSampleBuffer: imageBuffer is NULL"); + return; + } - CVImageBufferRef imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer); - if (!imageBuffer) return; + CIImage *ciImage = [CIImage imageWithCVPixelBuffer:imageBuffer]; + if (!sharedCIContext) { + return; + } + CGImageRef cgImage = [sharedCIContext createCGImage:ciImage fromRect:ciImage.extent]; + if (!cgImage) { + NSLog(@"[CAMERA] didOutputSampleBuffer: cgImage is NULL"); + return; + } - CIImage *ciImage = [CIImage imageWithCVPixelBuffer:imageBuffer]; - CIContext *context = [CIContext contextWithOptions:nil]; - CGImageRef cgImage = [context createCGImage:ciImage fromRect:ciImage.extent]; - if (!cgImage) return; + // Convert to JPEG NSData (quality ≈ 0.4) + NSBitmapImageRep *rep = [[NSBitmapImageRep alloc] initWithCGImage:cgImage]; + CGImageRelease(cgImage); + if (!rep) { + return; + } - // Convert to JPEG NSData (quality ≈ 0.4) - NSBitmapImageRep *rep = [[NSBitmapImageRep alloc] initWithCGImage:cgImage]; - CGImageRelease(cgImage); + NSDictionary *props = @{NSImageCompressionFactor: @(0.4)}; + NSData *jpegData = [rep representationUsingType:NSBitmapImageFileTypeJPEG properties:props]; + if (!jpegData) { + NSLog(@"[CAMERA] didOutputSampleBuffer: jpegData is NULL"); + [rep release]; + return; + } - NSDictionary *props = @{NSImageCompressionFactor: @(0.4)}; - NSData *jpegData = [rep representationUsingType:NSBitmapImageFileTypeJPEG properties:props]; - if (!jpegData) return; + NSString *b64 = [jpegData base64EncodedStringWithOptions:0]; + // Create a retained string (ownership transfer) + NSString *dataUrl = [[NSString alloc] initWithFormat:@"data:image/jpeg;base64,%@", b64]; + [rep release]; - NSString *b64 = [jpegData base64EncodedStringWithOptions:0]; - NSString *dataUrl = [NSString stringWithFormat:@"data:image/jpeg;base64,%@", b64]; + [frameLock lock]; + if (latestFrameBase64) { + [latestFrameBase64 release]; + } + latestFrameBase64 = dataUrl; // retained copy + frameCount++; + int fc = frameCount; + [frameLock unlock]; - [frameLock lock]; - latestFrameBase64 = dataUrl; - [frameLock unlock]; + // Log first few frames to confirm camera is working + if (fc <= 3) { + NSLog(@"[CAMERA] Frame #%d captured, size=%lu bytes", fc, (unsigned long)jpegData.length); + } + } } @end @@ -56,39 +86,51 @@ static CameraFrameDelegate *frameDelegate = nil; // StartNativeCamera: returns 0 on success, -1 on failure int StartNativeCamera(void) { + NSLog(@"[CAMERA] StartNativeCamera called"); + if (captureSession && captureSession.isRunning) { + NSLog(@"[CAMERA] Session already running"); return 0; // already running } if (!frameLock) { frameLock = [[NSLock alloc] init]; } + frameCount = 0; // Check camera permission if (@available(macOS 10.14, *)) { AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo]; + NSLog(@"[CAMERA] Camera permission status: %ld (0=NotDetermined, 1=Restricted, 2=Denied, 3=Authorized)", (long)status); + if (status == AVAuthorizationStatusDenied || status == AVAuthorizationStatusRestricted) { NSLog(@"[CAMERA] Camera permission denied (status=%ld)", (long)status); return -1; } if (status == AVAuthorizationStatusNotDetermined) { - // Request permission synchronously via semaphore + NSLog(@"[CAMERA] Requesting camera permission..."); dispatch_semaphore_t sem = dispatch_semaphore_create(0); __block BOOL granted = NO; [AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^(BOOL g) { granted = g; + NSLog(@"[CAMERA] Permission request result: %@", g ? @"GRANTED" : @"DENIED"); dispatch_semaphore_signal(sem); }]; - dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)); + dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC)); if (!granted) { - NSLog(@"[CAMERA] Camera permission not granted"); + NSLog(@"[CAMERA] Camera permission not granted after request"); return -1; } } } + if (!sharedCIContext) { + sharedCIContext = [[CIContext contextWithOptions:nil] retain]; + } + captureSession = [[AVCaptureSession alloc] init]; captureSession.sessionPreset = AVCaptureSessionPresetLow; // 320×240-ish + NSLog(@"[CAMERA] Session created with preset Low"); // Find default video device AVCaptureDevice *camera = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo]; @@ -97,6 +139,7 @@ int StartNativeCamera(void) { captureSession = nil; return -1; } + NSLog(@"[CAMERA] Found camera: %@", camera.localizedName); NSError *error = nil; AVCaptureDeviceInput *input = [AVCaptureDeviceInput deviceInputWithDevice:camera error:&error]; @@ -108,6 +151,7 @@ int StartNativeCamera(void) { if ([captureSession canAddInput:input]) { [captureSession addInput:input]; + NSLog(@"[CAMERA] Input added to session"); } else { NSLog(@"[CAMERA] Cannot add camera input to session"); captureSession = nil; @@ -127,6 +171,7 @@ int StartNativeCamera(void) { if ([captureSession canAddOutput:videoOutput]) { [captureSession addOutput:videoOutput]; + NSLog(@"[CAMERA] Output added to session"); } else { NSLog(@"[CAMERA] Cannot add video output to session"); captureSession = nil; @@ -134,21 +179,25 @@ int StartNativeCamera(void) { } [captureSession startRunning]; - NSLog(@"[CAMERA] Native camera capture started"); + NSLog(@"[CAMERA] Session startRunning called, isRunning=%d", captureSession.isRunning); return 0; } void StopNativeCamera(void) { + NSLog(@"[CAMERA] StopNativeCamera called"); if (captureSession && captureSession.isRunning) { [captureSession stopRunning]; - NSLog(@"[CAMERA] Native camera capture stopped"); + NSLog(@"[CAMERA] Session stopped"); } captureSession = nil; videoOutput = nil; frameDelegate = nil; [frameLock lock]; - latestFrameBase64 = nil; + if (latestFrameBase64) { + [latestFrameBase64 release]; + latestFrameBase64 = nil; + } [frameLock unlock]; } @@ -156,11 +205,13 @@ void StopNativeCamera(void) { char* GetLatestCameraFrame(void) { [frameLock lock]; NSString *frame = latestFrameBase64; - latestFrameBase64 = nil; // consume + latestFrameBase64 = nil; // transfers ownership to caller [frameLock unlock]; - if (!frame || frame.length == 0) { + if (!frame) { return NULL; } - return strdup([frame UTF8String]); + char *res = strdup([frame UTF8String]); + [frame release]; // Release since we had ownership + return res; } diff --git a/client/internal/screen/screen_darwin.go b/client/internal/screen/screen_darwin.go index fb064d4..6f15e24 100644 --- a/client/internal/screen/screen_darwin.go +++ b/client/internal/screen/screen_darwin.go @@ -63,6 +63,7 @@ static unsigned char* CaptureFullDesktop(int* outLen, int quality) { NSData *jpegData = [rep representationUsingType:NSBitmapImageFileTypeJPEG properties:props]; if (!jpegData || jpegData.length == 0) { + [rep release]; *outLen = 0; return NULL; } @@ -70,6 +71,7 @@ static unsigned char* CaptureFullDesktop(int* outLen, int quality) { *outLen = (int)jpegData.length; unsigned char *buf = (unsigned char*)malloc(jpegData.length); memcpy(buf, jpegData.bytes, jpegData.length); + [rep release]; return buf; } }