Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions BetterCapture/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//
// AppDelegate.swift
// BetterCapture
//
// Created by Joshua Sattler on 30.08.26.
//

import AppKit
import OSLog

/// Owns the recorder and handles `bettercapture://` URLs.
///
/// URL handling cannot live on the `MenuBarExtra` scene. SwiftUI only routes external
/// events such as URLs to window-presenting scenes, and the menu bar content is not built
/// until the user first opens the popover, so `onOpenURL` there is never registered.
/// `NSApplicationDelegate` receives the Apple Event from launch onwards, which is why the
/// recorder is owned here: it has to be reachable without the popover ever being opened.
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {

let viewModel = RecorderViewModel()

private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "BetterCapture", category: "AppDelegate")

func application(_ application: NSApplication, open urls: [URL]) {
for url in urls where url.scheme == "bettercapture" {
handle(url)
}
}

// MARK: - URL Scheme

private func handle(_ url: URL) {
logger.info("Handling URL: \(url.absoluteString)")

switch url.host {
case "toggle", "toggle-copy":
let copyToClipboard = url.host == "toggle-copy"
Task {
if viewModel.isRecording {
await viewModel.stopRecording(copyToClipboard: copyToClipboard)
} else {
switch ContentSelectionMode.current {
case .pickContent:
viewModel.presentPicker()
case .selectArea:
await viewModel.presentAreaSelection()
}
}
}
case "open-recordings":
let settings = viewModel.settings
let didStart = settings.startAccessingOutputDirectory()
defer {
if didStart {
settings.stopAccessingOutputDirectory()
}
}
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: settings.outputDirectory.path)
default:
logger.warning("Unhandled URL host: \(url.host ?? "nil")")
}
}
}
46 changes: 6 additions & 40 deletions BetterCapture/BetterCaptureApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@ import SwiftUI

@main
struct BetterCaptureApp: App {
@State private var viewModel = RecorderViewModel()
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@State private var updaterService = UpdaterService()

/// The recorder lives on the app delegate so URLs can be handled before the
/// menu bar popover has ever been opened. See ``AppDelegate``.
private var viewModel: RecorderViewModel { appDelegate.viewModel }

var body: some Scene {
// Menu bar extra - the primary interface
// Using .window style to support custom toggle switches
Expand All @@ -22,9 +27,6 @@ struct BetterCaptureApp: App {
await viewModel.requestPermissionsOnLaunch()
registerKeyboardShortcuts()
}
.onOpenURL { url in
handleURL(url)
}
} label: {
MenuBarLabel(viewModel: viewModel)
}
Expand All @@ -36,42 +38,6 @@ struct BetterCaptureApp: App {
}
}

// MARK: - URL Scheme

private func handleURL(_ url: URL) {
guard url.scheme == "bettercapture" else { return }

switch url.host {
case "toggle", "toggle-copy":
let copyToClipboard = url.host == "toggle-copy"
Task { @MainActor in
if viewModel.isRecording {
await viewModel.stopRecording(copyToClipboard: copyToClipboard)
} else {
switch ContentSelectionMode.current {
case .pickContent:
viewModel.presentPicker()
case .selectArea:
await viewModel.presentAreaSelection()
}
}
}
case "open-recordings":
Task { @MainActor in
let settings = viewModel.settings
let didStart = settings.startAccessingOutputDirectory()
defer {
if didStart {
settings.stopAccessingOutputDirectory()
}
}
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: settings.outputDirectory.path)
}
default:
break
}
}

// MARK: - Keyboard Shortcuts

private func registerKeyboardShortcuts() {
Expand Down
45 changes: 28 additions & 17 deletions BetterCapture/Service/CameraSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,23 +54,7 @@ final class CameraSession {
let input = try AVCaptureDeviceInput(device: device)
let newSession = AVCaptureSession()

newSession.beginConfiguration()

guard newSession.canAddInput(input) else {
logger.error("Cannot add camera input to session")
return
}
newSession.addInput(input)

// An output is required for the system to consider the camera active.
let output = AVCaptureVideoDataOutput()
guard newSession.canAddOutput(output) else {
logger.error("Cannot add video output to session")
return
}
newSession.addOutput(output)

newSession.commitConfiguration()
guard configure(newSession, with: input) else { return }

session = newSession

Expand All @@ -91,6 +75,33 @@ final class CameraSession {
}
}

/// Adds the camera input and a discarding video output to the session.
///
/// The configuration is always committed, including on the failure paths, so an
/// unusable session is never left half-configured.
///
/// - Returns: `true` if both the input and the output were added.
private func configure(_ session: AVCaptureSession, with input: AVCaptureDeviceInput) -> Bool {
session.beginConfiguration()
defer { session.commitConfiguration() }

guard session.canAddInput(input) else {
logger.error("Cannot add camera input to session")
return false
}
session.addInput(input)

// An output is required for the system to consider the camera active.
let output = AVCaptureVideoDataOutput()
guard session.canAddOutput(output) else {
logger.error("Cannot add video output to session")
return false
}
session.addOutput(output)

return true
}

/// Stops the capture session and releases resources.
func stop() {
guard let current = session else { return }
Expand Down
8 changes: 3 additions & 5 deletions BetterCapture/Service/CaptureEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -322,11 +322,6 @@ final class CaptureEngine: NSObject {
config.microphoneCaptureDeviceID = microphoneID
}

// Presenter Overlay: always show the alert so the user knows overlay is available
if settings.presenterOverlayEnabled {
config.presenterOverlayPrivacyAlertSetting = .always
}

return config
}

Expand Down Expand Up @@ -453,6 +448,7 @@ enum CaptureError: LocalizedError, Equatable {
case captureAlreadyRunning
case screenRecordingPermissionDenied
case microphonePermissionDenied
case cameraPermissionDenied
case selectedDisplayDisconnected

var errorDescription: String? {
Expand All @@ -467,6 +463,8 @@ enum CaptureError: LocalizedError, Equatable {
return "Screen recording permission is required. Please grant permission in System Settings → Privacy & Security → Screen Recording."
case .microphonePermissionDenied:
return "Microphone permission is required. Please grant permission in System Settings → Privacy & Security → Microphone."
case .cameraPermissionDenied:
return "Camera permission is required for Presenter Overlay. Please grant permission in System Settings → Privacy & Security → Camera, or turn off Presenter Overlay."
case .selectedDisplayDisconnected:
return "The selected display is no longer connected. Please select the content to capture again."
}
Expand Down
57 changes: 52 additions & 5 deletions BetterCapture/Service/PermissionService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ final class PermissionService {

private(set) var screenRecordingState: PermissionState = .unknown
private(set) var microphoneState: PermissionState = .unknown
private(set) var cameraState: PermissionState = .unknown

var allPermissionsGranted: Bool {
screenRecordingState == .granted && microphoneState == .granted
Expand All @@ -53,16 +54,28 @@ final class PermissionService {
func updatePermissionStates() {
screenRecordingState = checkScreenRecordingPermission()
microphoneState = checkMicrophonePermission()
cameraState = checkCameraPermission()

logger.info("Permission states - Screen: \(String(describing: self.screenRecordingState)), Microphone: \(String(describing: self.microphoneState))")
let screen = String(describing: screenRecordingState)
let microphone = String(describing: microphoneState)
let camera = String(describing: cameraState)
logger.info("Permission states - Screen: \(screen), Microphone: \(microphone), Camera: \(camera)")
}

private func checkScreenRecordingPermission() -> PermissionState {
CGPreflightScreenCaptureAccess() ? .granted : .denied
}

private func checkMicrophonePermission() -> PermissionState {
switch AVCaptureDevice.authorizationStatus(for: .audio) {
state(for: .audio)
}

private func checkCameraPermission() -> PermissionState {
state(for: .video)
}

private func state(for mediaType: AVMediaType) -> PermissionState {
switch AVCaptureDevice.authorizationStatus(for: mediaType) {
case .authorized:
return .granted
case .notDetermined:
Expand All @@ -77,9 +90,11 @@ final class PermissionService {
// MARK: - Permission Requests

/// Requests required permissions on app launch
/// - Parameter includeMicrophone: Whether to also request microphone permission
func requestPermissions(includeMicrophone: Bool) async {
logger.info("Requesting permissions (includeMicrophone: \(includeMicrophone))...")
/// - Parameters:
/// - includeMicrophone: Whether to also request microphone permission
/// - includeCamera: Whether to also request camera permission
func requestPermissions(includeMicrophone: Bool, includeCamera: Bool) async {
logger.info("Requesting permissions (includeMicrophone: \(includeMicrophone), includeCamera: \(includeCamera))...")

// Request screen recording permission first (synchronous)
requestScreenRecordingPermission()
Expand All @@ -89,6 +104,11 @@ final class PermissionService {
await requestMicrophonePermission()
}

// Camera is only used by Presenter Overlay, so it is only requested when that is on
if includeCamera {
await requestCameraPermission()
}

// Update states after requests
updatePermissionStates()
}
Expand Down Expand Up @@ -119,6 +139,26 @@ final class PermissionService {
}
}

/// Requests camera permission, used by Presenter Overlay
/// - Returns: true if permission is granted
@discardableResult
func requestCameraPermission() async -> Bool {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
cameraState = .granted
case .notDetermined:
let granted = await AVCaptureDevice.requestAccess(for: .video)
cameraState = granted ? .granted : .denied
logger.info("Camera permission request result: \(granted)")
case .denied, .restricted:
cameraState = .denied
@unknown default:
cameraState = .unknown
}

return cameraState == .granted
}

/// Opens System Settings to the Screen Recording preferences pane
func openScreenRecordingSettings() {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") {
Expand All @@ -132,4 +172,11 @@ final class PermissionService {
NSWorkspace.shared.open(url)
}
}

/// Opens System Settings to the Camera preferences pane
func openCameraSettings() {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera") {
NSWorkspace.shared.open(url)
}
}
}
36 changes: 23 additions & 13 deletions BetterCapture/View/AreaSelectionOverlay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@

/// The NSView that handles drawing the overlay, selection rectangle, and user interaction
@MainActor
final class AreaSelectionView: NSView {

Check warning on line 174 in BetterCapture/View/AreaSelectionOverlay.swift

View workflow job for this annotation

GitHub Actions / Lint

Class body should span 300 lines or less excluding comments and whitespace: currently spans 439 lines (type_body_length)

// MARK: - Properties

Expand Down Expand Up @@ -202,6 +202,10 @@
private var cancelButton: NSButton?
private var buttonContainer: NSView?

/// Position of the button container, updated as the selection moves
private var buttonContainerCenterX: NSLayoutConstraint?
private var buttonContainerCenterY: NSLayoutConstraint?

// MARK: - Initialization

init(frame: NSRect, screen: NSScreen) {
Expand Down Expand Up @@ -503,43 +507,49 @@
cancel.translatesAutoresizingMaskIntoConstraints = false
container.translatesAutoresizingMaskIntoConstraints = false

// The container is centred on the selection through constraints rather than by
// assigning its frame. Auto layout owns the frame of a view with
// `translatesAutoresizingMaskIntoConstraints` off, and writing to it directly
// invalidates the layout it is in the middle of computing.
// `leftAnchor` rather than `leadingAnchor`: the selection is a geometric rect and
// must not flip with the interface layout direction.
let centerX = container.centerXAnchor.constraint(equalTo: leftAnchor)
let centerY = container.centerYAnchor.constraint(equalTo: bottomAnchor)

NSLayoutConstraint.activate([
confirm.topAnchor.constraint(equalTo: container.topAnchor),
confirm.leadingAnchor.constraint(equalTo: container.leadingAnchor),
confirm.trailingAnchor.constraint(equalTo: container.trailingAnchor),
cancel.topAnchor.constraint(equalTo: confirm.bottomAnchor, constant: 8),
cancel.leadingAnchor.constraint(equalTo: container.leadingAnchor),
cancel.trailingAnchor.constraint(equalTo: container.trailingAnchor),
cancel.bottomAnchor.constraint(equalTo: container.bottomAnchor)
cancel.bottomAnchor.constraint(equalTo: container.bottomAnchor),
centerX,
centerY
])

self.confirmButton = confirm
self.cancelButton = cancel
self.buttonContainer = container
self.buttonContainerCenterX = centerX
self.buttonContainerCenterY = centerY

updateButtonPositions()
}

private func hideActionButtons() {
buttonContainer?.removeFromSuperview()
buttonContainer = nil
buttonContainerCenterX = nil
buttonContainerCenterY = nil
confirmButton = nil
cancelButton = nil
}

private func updateButtonPositions() {
guard let container = buttonContainer else { return }

// Let auto layout calculate the intrinsic size, then position manually
container.layoutSubtreeIfNeeded()
let fittingSize = container.fittingSize

container.frame = CGRect(
x: selectionRect.midX - fittingSize.width / 2,
y: selectionRect.midY - fittingSize.height / 2,
width: fittingSize.width,
height: fittingSize.height
)
// The view is unflipped, so both centres are offsets from the bottom-left corner
buttonContainerCenterX?.constant = selectionRect.midX
buttonContainerCenterY?.constant = selectionRect.midY
}

private func makeActionButton(title: String, textColor: NSColor, action: Selector) -> NSButton {
Expand Down Expand Up @@ -761,4 +771,4 @@

delegate?.areaSelectionView(self, didConfirmSelection: screenRect, on: screen)
}
}

Check warning on line 774 in BetterCapture/View/AreaSelectionOverlay.swift

View workflow job for this annotation

GitHub Actions / Lint

File should contain 500 lines or less: currently contains 774 (file_length)
Loading
Loading