diff --git a/BetterCapture.xcodeproj/project.pbxproj b/BetterCapture.xcodeproj/project.pbxproj index 191bae1..30f3b3d 100644 --- a/BetterCapture.xcodeproj/project.pbxproj +++ b/BetterCapture.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 6C5C123D2F3893FE0082CE23 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 6C0990E12F2BE0C200D48100 /* Sparkle */; }; + 6CA1B0012F8700000082CE23 /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = 6CA1B0002F8700000082CE23 /* KeyboardShortcuts */; }; 6C7AABCC2F783CC3006496CE /* BetterCapture.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = 6C7AABCB2F783CC3006496CE /* BetterCapture.xctestplan */; }; /* End PBXBuildFile section */ @@ -59,6 +60,7 @@ buildActionMask = 2147483647; files = ( 6C5C123D2F3893FE0082CE23 /* Sparkle in Frameworks */, + 6CA1B0012F8700000082CE23 /* KeyboardShortcuts in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -112,6 +114,7 @@ name = BetterCapture; packageProductDependencies = ( 6C0990E12F2BE0C200D48100 /* Sparkle */, + 6CA1B0002F8700000082CE23 /* KeyboardShortcuts */, ); productName = BetterCapture; productReference = 6C0990AF2F2BE0C100D48100 /* BetterCapture.app */; @@ -170,6 +173,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 6C0990E02F2BE0C200D48100 /* XCRemoteSwiftPackageReference "Sparkle" */, + 6CA1AFFF2F8700000082CE23 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */, ); preferredProjectObjectVersion = 77; productRefGroup = 6C0990B02F2BE0C100D48100 /* Products */; @@ -505,6 +509,14 @@ minimumVersion = 2.7.0; }; }; + 6CA1AFFF2F8700000082CE23 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sindresorhus/KeyboardShortcuts"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.4.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -513,6 +525,11 @@ package = 6C0990E02F2BE0C200D48100 /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; + 6CA1B0002F8700000082CE23 /* KeyboardShortcuts */ = { + isa = XCSwiftPackageProductDependency; + package = 6CA1AFFF2F8700000082CE23 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */; + productName = KeyboardShortcuts; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 6C0990A72F2BE0C100D48100 /* Project object */; diff --git a/BetterCapture.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/BetterCapture.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6e450d9..0bb0192 100644 --- a/BetterCapture.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/BetterCapture.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "e721da7f9826abdffcb6185e886155efa2514bd6234475f1afa893e29eb258d6", + "originHash" : "bd98f0be2af71cea5645be75ecf05037da9d09ea8295306d01254963541210cb", "pins" : [ + { + "identity" : "keyboardshortcuts", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sindresorhus/KeyboardShortcuts", + "state" : { + "revision" : "1aef85578fdd4f9eaeeb8d53b7b4fc31bf08fe27", + "version" : "2.4.0" + } + }, { "identity" : "sparkle", "kind" : "remoteSourceControl", diff --git a/BetterCapture/BetterCaptureApp.swift b/BetterCapture/BetterCaptureApp.swift index 345ca7d..c2826a3 100644 --- a/BetterCapture/BetterCaptureApp.swift +++ b/BetterCapture/BetterCaptureApp.swift @@ -5,21 +5,21 @@ // Created by Joshua Sattler on 29.01.26. // +import KeyboardShortcuts import SwiftUI @main struct BetterCaptureApp: App { @State private var viewModel = RecorderViewModel() @State private var updaterService = UpdaterService() - var body: some Scene { // Menu bar extra - the primary interface // Using .window style to support custom toggle switches MenuBarExtra { MenuBarView(viewModel: viewModel) .task { - // Request permissions on first app launch await viewModel.requestPermissionsOnLaunch() + registerKeyboardShortcuts() } } label: { MenuBarLabel(viewModel: viewModel) @@ -31,6 +31,28 @@ struct BetterCaptureApp: App { SettingsView(settings: viewModel.settings, updaterService: updaterService) } } + + // MARK: - Keyboard Shortcuts + + private func registerKeyboardShortcuts() { + KeyboardShortcuts.onKeyUp(for: .toggleRecording) { [viewModel] in + Task { @MainActor in + await viewModel.toggleRecording() + } + } + + KeyboardShortcuts.onKeyUp(for: .selectContent) { [viewModel] in + Task { @MainActor in + viewModel.presentPicker() + } + } + + KeyboardShortcuts.onKeyUp(for: .selectArea) { [viewModel] in + Task { @MainActor in + await viewModel.presentAreaSelection() + } + } + } } /// The label shown in the menu bar (icon or duration timer) diff --git a/BetterCapture/Model/ContentSelectionMode.swift b/BetterCapture/Model/ContentSelectionMode.swift new file mode 100644 index 0000000..34c4d4a --- /dev/null +++ b/BetterCapture/Model/ContentSelectionMode.swift @@ -0,0 +1,37 @@ +// +// ContentSelectionMode.swift +// BetterCapture +// +// Created by Joshua Sattler on 28.03.26. +// + +import Foundation + +/// The mode for content selection: picking content via the system picker, or drawing a screen area +enum ContentSelectionMode: String { + /// The `UserDefaults` / `@AppStorage` key used to persist the selected mode. + static let storageKey = "contentSelectionMode" + + /// The mode currently stored in `UserDefaults`, falling back to `.pickContent`. + static var current: ContentSelectionMode { + guard let raw = UserDefaults.standard.string(forKey: storageKey) else { return .pickContent } + return ContentSelectionMode(rawValue: raw) ?? .pickContent + } + + case pickContent + case selectArea + + var label: String { + switch self { + case .pickContent: "Pick Content" + case .selectArea: "Select Area" + } + } + + var icon: String { + switch self { + case .pickContent: "macwindow" + case .selectArea: "rectangle.dashed" + } + } +} diff --git a/BetterCapture/Model/KeyboardShortcutNames.swift b/BetterCapture/Model/KeyboardShortcutNames.swift new file mode 100644 index 0000000..07bd5a5 --- /dev/null +++ b/BetterCapture/Model/KeyboardShortcutNames.swift @@ -0,0 +1,14 @@ +// +// KeyboardShortcutNames.swift +// BetterCapture +// +// Created by Joshua Sattler on 28.03.26. +// + +import KeyboardShortcuts + +extension KeyboardShortcuts.Name { + static let toggleRecording = Self("toggleRecording") + static let selectContent = Self("selectContent") + static let selectArea = Self("selectArea") +} diff --git a/BetterCapture/View/MenuBarView.swift b/BetterCapture/View/MenuBarView.swift index 56e1cb3..98599d7 100644 --- a/BetterCapture/View/MenuBarView.swift +++ b/BetterCapture/View/MenuBarView.swift @@ -241,28 +241,6 @@ struct RecordingButton: View { } } -// MARK: - Content Selection Mode - -/// The mode for content selection: picking content via the system picker, or drawing a screen area -enum ContentSelectionMode: String { - case pickContent - case selectArea - - var label: String { - switch self { - case .pickContent: "Pick Content" - case .selectArea: "Select Area" - } - } - - var icon: String { - switch self { - case .pickContent: "macwindow" - case .selectArea: "rectangle.dashed" - } - } -} - // MARK: - Content Selection Button /// A split button that triggers the active content selection mode, with a dropdown chevron to switch modes. @@ -271,7 +249,7 @@ enum ContentSelectionMode: String { struct ContentSelectionButton: View { let viewModel: RecorderViewModel var onDismissPanel: (() -> Void)? - @AppStorage("contentSelectionMode") private var mode: ContentSelectionMode = .pickContent + @AppStorage(ContentSelectionMode.storageKey) private var mode: ContentSelectionMode = .pickContent @State private var isDropdownExpanded = false @State private var isMainHovered = false @State private var isChevronHovered = false diff --git a/BetterCapture/View/RecordingOverlayView.swift b/BetterCapture/View/RecordingOverlayView.swift index 7b3ddb7..3b89aa9 100644 --- a/BetterCapture/View/RecordingOverlayView.swift +++ b/BetterCapture/View/RecordingOverlayView.swift @@ -8,10 +8,10 @@ import SwiftUI /// The SwiftUI content view hosted inside the recording overlay panel. -/// Shows a live preview and two action buttons: Start Recording and Cancel. +/// Shows a live preview and two action buttons: Start Recording and Dismiss. struct RecordingOverlayView: View { let viewModel: RecorderViewModel - let onCancel: () -> Void + let onDismiss: () -> Void @State private var currentPreview: NSImage? @@ -75,15 +75,15 @@ struct RecordingOverlayView: View { VStack(spacing: 6) { Button("Start Recording", systemImage: "record.circle") { Task { - await viewModel.startRecordingFromOverlay() + await viewModel.startRecording() } } .buttonStyle(OverlayButtonStyle(labelColor: .green, weight: .semibold)) - Button("Cancel") { - onCancel() + Button("Dismiss") { + onDismiss() } - .buttonStyle(OverlayButtonStyle(labelColor: .red, weight: .medium)) + .buttonStyle(OverlayButtonStyle(labelColor: .secondary, weight: .medium)) } .padding(.top, 10) } diff --git a/BetterCapture/View/SettingsView.swift b/BetterCapture/View/SettingsView.swift index b922c38..1f73096 100644 --- a/BetterCapture/View/SettingsView.swift +++ b/BetterCapture/View/SettingsView.swift @@ -6,6 +6,7 @@ // import AppKit +import KeyboardShortcuts import SwiftUI /// The settings window for BetterCapture @@ -26,11 +27,40 @@ struct SettingsView: View { Tab("Audio", systemImage: "waveform") { AudioSettingsView(settings: settings) } + + Tab("Shortcuts", systemImage: "keyboard") { + ShortcutsSettingsView() + } } .frame(width: 500, height: 420) } } +// MARK: - Shortcuts Settings + +struct ShortcutsSettingsView: View { + var body: some View { + Form { + Section("Recording") { + KeyboardShortcuts.Recorder("Toggle Recording", name: .toggleRecording) + } + + Section("Content Selection") { + KeyboardShortcuts.Recorder("Select Content", name: .selectContent) + KeyboardShortcuts.Recorder("Select Area", name: .selectArea) + } + + Section { + Text("Shortcuts work globally, even when BetterCapture is not focused.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + .padding() + } +} + // MARK: - Video Settings struct VideoSettingsView: View { diff --git a/BetterCapture/ViewModel/RecorderViewModel.swift b/BetterCapture/ViewModel/RecorderViewModel.swift index 9badf24..519553d 100644 --- a/BetterCapture/ViewModel/RecorderViewModel.swift +++ b/BetterCapture/ViewModel/RecorderViewModel.swift @@ -126,6 +126,24 @@ final class RecorderViewModel { // MARK: - Public Methods + /// Toggles the recording state. If no content is selected, triggers the appropriate + /// selection flow based on the user's content selection mode preference. + func toggleRecording() async { + if isRecording { + await stopRecording() + } else if hasContentSelected { + await startRecording() + } else { + // No content selected — trigger selection based on the user's preferred mode + switch ContentSelectionMode.current { + case .pickContent: + presentPicker() + case .selectArea: + await presentAreaSelection() + } + } + } + /// Presents the system content sharing picker func presentPicker() { captureEngine.presentPicker() @@ -206,18 +224,6 @@ final class RecorderViewModel { } } - /// Dismisses the recording overlay without starting a recording. - func dismissOverlay() { - recordingOverlay.dismiss() - } - - /// Called by the recording overlay's "Start Recording" button. - /// Dismisses the overlay and starts recording. - func startRecordingFromOverlay() async { - recordingOverlay.dismiss() - await startRecording() - } - /// Starts a new recording session func startRecording() async { guard canStartRecording else { @@ -225,6 +231,9 @@ final class RecorderViewModel { return } + // Dismiss the recording overlay if it's still visible + recordingOverlay.dismiss() + do { state = .recording lastError = nil @@ -260,6 +269,11 @@ final class RecorderViewModel { logger.info("Starting capture engine...") try await captureEngine.startCapture(with: settings, videoSize: videoSize, sourceRect: selectedSourceRect) + // Re-show the area selection border now that capture has started + if isAreaSelection, let screenRect = selectedScreenRect { + selectionBorderFrame.show(screenRect: screenRect) + } + // Start timer startTimer() @@ -315,11 +329,6 @@ final class RecorderViewModel { } } - /// Clears the current content selection - func clearSelection() { - captureEngine.clearSelection() - } - /// Resets the area selection, removing the border frame and clearing state func resetAreaSelection() async { selectedSourceRect = nil diff --git a/docs/decisions/0001-keyboard-shortcuts-implementation.md b/docs/decisions/0001-keyboard-shortcuts-implementation.md new file mode 100644 index 0000000..79cf953 --- /dev/null +++ b/docs/decisions/0001-keyboard-shortcuts-implementation.md @@ -0,0 +1,99 @@ +--- +status: decided +date: 2026-03-28 +decision-makers: jsattler +--- + +# Use sindresorhus/KeyboardShortcuts for global keyboard shortcuts + +## Context and Problem Statement + +BetterCapture has no keyboard shortcuts. +All actions (toggle recording, select content, select area) require clicking through the menu bar popover. +Users have requested global hotkeys that work even when the app is not focused (see [Discussion #76](https://github.com/jsattler/BetterCapture/discussions/76), [Issue #119](https://github.com/jsattler/BetterCapture/issues/119)). + +## Decision Drivers + +- BetterCapture is fully sandboxed (`com.apple.security.app-sandbox`) and must remain so for Mac App Store compatibility. +- Shortcuts must work globally (when the app is not focused), since the app is a menu bar agent (`LSUIElement = true`) with no persistent window. +- The solution should provide a native-feeling UI for users to customize their shortcuts. +- Conflict detection with system and app shortcuts is important. +- No Accessibility permission should be required (poor UX barrier). +- Minimal implementation effort — the project already has one third-party dependency (Sparkle). + +## Considered Options + +- sindresorhus/KeyboardShortcuts (third-party Swift package) +- NSEvent.addGlobalMonitorForEvents (native AppKit API) +- CGEvent.tapCreate / Quartz Event Services (native Core Graphics API) + +## Decision Outcome + +Chosen option: "sindresorhus/KeyboardShortcuts", because it is the only approach that supports global hotkeys in a sandboxed app without requiring Accessibility permission, while also providing a complete solution (recorder UI, conflict detection, UserDefaults storage). + +### Consequences + +- Good, because it works within the App Sandbox with no additional entitlements. +- Good, because it provides a SwiftUI `Recorder` view that handles shortcut recording, display, and conflict warnings. +- Good, because it stores shortcuts in `UserDefaults`, consistent with the existing `SettingsStore` pattern. +- Good, because it is well-maintained (2.6k stars, MIT license, latest release Sep 2025), used in production Mac App Store apps. +- Bad, because it introduces a second third-party dependency (alongside Sparkle). +- Bad, because it internally uses Carbon `RegisterEventHotKey`, which is legacy but has no modern replacement from Apple. + +### Confirmation + +- Verify that all three shortcuts (Toggle Recording, Select Content, Select Area) function globally when the app is not focused. +- Verify that the `KeyboardShortcuts.Recorder` views appear correctly in the Settings window. +- Verify that no Accessibility permission prompt is triggered. +- Verify that the app remains sandboxed and builds without additional entitlements. + +## Pros and Cons of the Options + +### sindresorhus/KeyboardShortcuts + +A mature Swift package (macOS 10.15+) that wraps Carbon `RegisterEventHotKey` with a SwiftUI-native API. Provides a `Recorder` view for user-customizable shortcuts, automatic conflict detection, and `UserDefaults` persistence. + +- Good, because fully sandboxed and Mac App Store compatible. +- Good, because no Accessibility permission required. +- Good, because provides SwiftUI `Recorder` view with built-in conflict detection. +- Good, because supports `@Observable` pattern via `onKeyUp(for:)` callbacks. +- Good, because handles storage, display, and localization out of the box. +- Neutral, because adds a third-party dependency (MIT license, well-maintained). +- Bad, because relies on Carbon APIs internally (no modern Apple replacement exists). + +### NSEvent.addGlobalMonitorForEvents + +Native AppKit API that installs a monitor for events posted to other applications. + +- Good, because it is a first-party Apple API with no third-party dependency. +- Bad, because key events require Accessibility permission ("Key-related events may only be monitored if accessibility is enabled or if your application is trusted for accessibility access"). +- Bad, because events can only be observed, not consumed (the shortcut passes through to the focused app). +- Bad, because no built-in recorder UI, conflict detection, or storage — all must be built from scratch. +- Bad, because the Accessibility permission requirement is a significant UX barrier. + +### CGEvent.tapCreate / Quartz Event Services + +Low-level Core Graphics API for creating event taps that intercept input events at the system level. + +- Good, because it provides the most control over event handling (can modify or consume events). +- Bad, because it is not compatible with App Sandbox — requires the process to be trusted for accessibility. +- Bad, because it is designed for assistive technology (Section 508), not general app hotkeys. +- Bad, because it is significantly more complex to implement than the alternatives. +- Bad, because it would require removing the sandbox entitlement, breaking Mac App Store eligibility. + +## More Information + +### Shortcuts to implement + +| Shortcut Name | Action | ViewModel Method | +| ---------------- | ------------------------------- | ------------------------ | +| Toggle Recording | Start or stop recording | `toggleRecording()` | +| Select Content | Open the system content picker | `presentPicker()` | +| Select Area | Open the area selection overlay | `presentAreaSelection()` | + +### UX decisions + +- **No default key combinations.** All shortcuts start unconfigured. Users set their own in a new "Shortcuts" tab in Settings. This follows the library author's recommendation: "please do not set this for a publicly distributed app. Users find it annoying when random apps steal their existing keyboard shortcuts." +- **Toggle Recording** instead of separate Start/Stop. A single shortcut toggles between starting and stopping a recording, reducing cognitive load. +- **Smart Toggle Recording behavior:** When the user triggers Toggle Recording and no content is selected, it automatically triggers the appropriate selection flow based on the user's current `ContentSelectionMode` preference (stored in `@AppStorage("contentSelectionMode")`). If mode is `.pickContent`, it calls `presentPicker()`. If mode is `.selectArea`, it calls `presentAreaSelection()`. If content is already selected and recording is idle, it starts recording. If recording is active, it stops. +- The `KeyboardShortcuts.Recorder` view automatically warns users when a chosen shortcut conflicts with system shortcuts (e.g., Shift+Cmd+4 for screenshots) or the app's own menu shortcuts.