From 7b3395b1b89e81be685005f3d6236f70c41e39d9 Mon Sep 17 00:00:00 2001 From: Andrei Khutartsou Date: Mon, 8 Jun 2026 12:49:37 +0200 Subject: [PATCH 1/6] feat: auto-select screen based on cursor position for recording --- Sources/Capture/RegionSelectionOverlay.swift | 6 +- .../Recording/ScreenRecordingManager.swift | 55 ++++++++++++++----- 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/Sources/Capture/RegionSelectionOverlay.swift b/Sources/Capture/RegionSelectionOverlay.swift index fd87f17..039dfd1 100644 --- a/Sources/Capture/RegionSelectionOverlay.swift +++ b/Sources/Capture/RegionSelectionOverlay.swift @@ -4,6 +4,7 @@ import ScreenCaptureKit struct RegionSelection { let pointsRect: CGRect // In global display points (top-left origin, matching SCK coordinates) let scaleFactor: CGFloat + let displayID: CGDirectDisplayID } @MainActor @@ -68,9 +69,12 @@ final class RegionSelectionOverlay { height: rect.height ) + let displayID = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID ?? kCGNullDirectDisplay + let selection = RegionSelection( pointsRect: pointsRect, - scaleFactor: screen.backingScaleFactor + scaleFactor: screen.backingScaleFactor, + displayID: displayID ) closeOverlays() diff --git a/Sources/Recording/ScreenRecordingManager.swift b/Sources/Recording/ScreenRecordingManager.swift index d1bcdaa..7607c3c 100644 --- a/Sources/Recording/ScreenRecordingManager.swift +++ b/Sources/Recording/ScreenRecordingManager.swift @@ -42,8 +42,15 @@ final class ScreenRecordingManager: NSObject { state = .preparing let captureAudio = AppPreferences.recordingCaptureAudio + + let mouseLocation = NSEvent.mouseLocation + let targetScreen = NSScreen.screens.first { NSMouseInRect(mouseLocation, $0.frame, false) } ?? NSScreen.screens.first + let targetDisplayID = targetScreen?.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID + let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) - guard let display = content.displays.first else { + let display = content.displays.first { $0.displayID == targetDisplayID } ?? content.displays.first + + guard let display = display else { state = .idle return false } @@ -77,7 +84,8 @@ final class ScreenRecordingManager: NSObject { state = .preparing let captureAudio = AppPreferences.recordingCaptureAudio let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) - guard let display = content.displays.first else { + let display = content.displays.first { $0.displayID == selection.displayID } ?? content.displays.first + guard let display = display else { state = .idle return false } @@ -94,21 +102,38 @@ final class ScreenRecordingManager: NSObject { let contentRect = try await filter.contentRect let pointPixelScale = try await filter.pointPixelScale - let screenFrame = NSScreen.screens.first?.frame ?? NSRect(x: 0, y: 0, width: CGFloat(display.width), height: CGFloat(display.height)) + let targetScreen = NSScreen.screens.first { screen in + let screenID = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID + return screenID == display.displayID + } ?? NSScreen.screens.first + + let primaryHeight = NSScreen.screens.first?.frame.height ?? 0 + let targetScreenFrame = targetScreen?.frame ?? NSRect(x: 0, y: 0, width: CGFloat(display.width), height: CGFloat(display.height)) + let targetScreenQuartzFrame = CGRect( + x: targetScreenFrame.origin.x, + y: primaryHeight - targetScreenFrame.origin.y - targetScreenFrame.height, + width: targetScreenFrame.width, + height: targetScreenFrame.height + ) let selRect = selection.pointsRect - let clampedX = max(selRect.minX, screenFrame.minX) - let clampedY = max(selRect.minY, 0) - let clampedMaxX = min(selRect.maxX, screenFrame.maxX) - let clampedMaxY = min(selRect.maxY, screenFrame.height) - - let scaleX = contentRect.width / screenFrame.width - let scaleY = contentRect.height / screenFrame.height - - let sourceX = contentRect.minX + (clampedX - screenFrame.minX) * scaleX - let sourceY = contentRect.minY + clampedY * scaleY - let sourceW = (clampedMaxX - clampedX) * scaleX - let sourceH = (clampedMaxY - clampedY) * scaleY + let clampedX = max(selRect.minX, targetScreenQuartzFrame.minX) + let clampedY = max(selRect.minY, targetScreenQuartzFrame.minY) + let clampedMaxX = min(selRect.maxX, targetScreenQuartzFrame.maxX) + let clampedMaxY = min(selRect.maxY, targetScreenQuartzFrame.maxY) + + let localX = clampedX - targetScreenQuartzFrame.minX + let localY = clampedY - targetScreenQuartzFrame.minY + let localW = clampedMaxX - clampedX + let localH = clampedMaxY - clampedY + + let scaleX = contentRect.width / targetScreenFrame.width + let scaleY = contentRect.height / targetScreenFrame.height + + let sourceX = contentRect.minX + localX * scaleX + let sourceY = contentRect.minY + localY * scaleY + let sourceW = localW * scaleX + let sourceH = localH * scaleY let mappedSourceRect = CGRect(x: sourceX, y: sourceY, width: sourceW, height: sourceH) From ef41d84f61109c590e3f0d7320b45688228cb64a Mon Sep 17 00:00:00 2001 From: Andrei Khutartsou Date: Mon, 8 Jun 2026 13:28:58 +0200 Subject: [PATCH 2/6] feat: add microphone recording with mute/unmute control --- Resources/BetterShot.entitlements | 5 +- Resources/Info.plist | 2 + Sources/Models/AppPreferences.swift | 6 ++ Sources/Recording/RecordingSession.swift | 32 +++++- Sources/Recording/RecordingStatusBar.swift | 6 ++ .../Recording/ScreenRecordingManager.swift | 100 +++++++++++++++++- Sources/Settings/PreferencesView.swift | 3 + 7 files changed, 151 insertions(+), 3 deletions(-) diff --git a/Resources/BetterShot.entitlements b/Resources/BetterShot.entitlements index 0c67376..d459cb2 100644 --- a/Resources/BetterShot.entitlements +++ b/Resources/BetterShot.entitlements @@ -1,5 +1,8 @@ - + + com.apple.security.device.audio-input + + diff --git a/Resources/Info.plist b/Resources/Info.plist index 009592e..18204c5 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -24,6 +24,8 @@ NSScreenCaptureUsageDescription BetterShot needs screen recording access to capture screenshots and record your screen. + NSMicrophoneUsageDescription + BetterShot needs microphone access to record audio along with your screen recordings. NSRequiresAquaSystemAppearance diff --git a/Sources/Models/AppPreferences.swift b/Sources/Models/AppPreferences.swift index 2f3a628..08a1fe8 100644 --- a/Sources/Models/AppPreferences.swift +++ b/Sources/Models/AppPreferences.swift @@ -16,6 +16,7 @@ enum AppPreferences { private static let recordingFPSKey = "bs_recordingFPS" private static let recordingShowCursorKey = "bs_recordingShowCursor" private static let recordingCaptureAudioKey = "bs_recordingCaptureAudio" + private static let recordingCaptureMicrophoneKey = "bs_recordingCaptureMicrophone" private static let recordingOpenEditorKey = "bs_recordingOpenEditor" // MARK: - Appearance @@ -113,6 +114,11 @@ enum AppPreferences { set { UserDefaults.standard.set(newValue, forKey: recordingCaptureAudioKey) } } + static var recordingCaptureMicrophone: Bool { + get { UserDefaults.standard.object(forKey: recordingCaptureMicrophoneKey) as? Bool ?? false } + set { UserDefaults.standard.set(newValue, forKey: recordingCaptureMicrophoneKey) } + } + static var recordingOpenEditor: Bool { get { UserDefaults.standard.object(forKey: recordingOpenEditorKey) as? Bool ?? true } set { UserDefaults.standard.set(newValue, forKey: recordingOpenEditorKey) } diff --git a/Sources/Recording/RecordingSession.swift b/Sources/Recording/RecordingSession.swift index fbb679f..c5851f5 100644 --- a/Sources/Recording/RecordingSession.swift +++ b/Sources/Recording/RecordingSession.swift @@ -6,6 +6,7 @@ final class RecordingSession: @unchecked Sendable { private let videoInput: AVAssetWriterInput private let adaptor: AVAssetWriterInputPixelBufferAdaptor private let audioInput: AVAssetWriterInput? + private let micInput: AVAssetWriterInput? private let lock = NSLock() private var _isCapturing = false @@ -17,7 +18,7 @@ final class RecordingSession: @unchecked Sendable { set { lock.withLock { _isCapturing = newValue } } } - init(outputURL: URL, width: Int, height: Int, fps: Int, includeAudio: Bool) throws { + init(outputURL: URL, width: Int, height: Int, fps: Int, includeAudio: Bool, includeMic: Bool) throws { writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4) let videoSettings: [String: Any] = [ @@ -59,6 +60,21 @@ final class RecordingSession: @unchecked Sendable { } else { audioInput = nil } + + if includeMic { + let micSettings: [String: Any] = [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVSampleRateKey: 48000, + AVNumberOfChannelsKey: 1, + AVEncoderBitRateKey: 64_000, + ] + let input = AVAssetWriterInput(mediaType: .audio, outputSettings: micSettings) + input.expectsMediaDataInRealTime = true + writer.add(input) + micInput = input + } else { + micInput = nil + } } func startWriting() -> Bool { @@ -104,9 +120,23 @@ final class RecordingSession: @unchecked Sendable { audioInput.append(sampleBuffer) } + func appendMicSample(_ sampleBuffer: CMSampleBuffer) { + lock.lock() + guard _isCapturing, _sessionStarted else { lock.unlock(); return } + + let timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) + guard let first = _firstTimestamp, timestamp >= first else { lock.unlock(); return } + + lock.unlock() + + guard let micInput, micInput.isReadyForMoreMediaData else { return } + micInput.append(sampleBuffer) + } + func finishInputs() { videoInput.markAsFinished() audioInput?.markAsFinished() + micInput?.markAsFinished() } func finishWriting() async { diff --git a/Sources/Recording/RecordingStatusBar.swift b/Sources/Recording/RecordingStatusBar.swift index a714def..93e6679 100644 --- a/Sources/Recording/RecordingStatusBar.swift +++ b/Sources/Recording/RecordingStatusBar.swift @@ -31,6 +31,12 @@ struct RecordingStatusBarView: View { .frame(width: 1, height: 18) HStack(spacing: 2) { + if AppPreferences.recordingCaptureMicrophone { + iconButton(icon: recorder.isMicMuted ? "mic.slash.fill" : "mic.fill", tint: recorder.isMicMuted ? .red : .white.opacity(0.85)) { + recorder.toggleMicMute() + } + } + iconButton(icon: isPaused ? "play.fill" : "pause.fill") { recorder.togglePause() } diff --git a/Sources/Recording/ScreenRecordingManager.swift b/Sources/Recording/ScreenRecordingManager.swift index 7607c3c..ef55b42 100644 --- a/Sources/Recording/ScreenRecordingManager.swift +++ b/Sources/Recording/ScreenRecordingManager.swift @@ -17,11 +17,13 @@ final class ScreenRecordingManager: NSObject { private(set) var state: State = .idle private(set) var elapsedSeconds: Int = 0 + var isMicMuted: Bool = false private var stream: SCStream? private var session: RecordingSession? private var outputURL: URL? private var timer: Timer? + private var micCapture: MicrophoneCapture? nonisolated(unsafe) private var _streamSession: RecordingSession? private let videoQueue = DispatchQueue(label: "com.bettershot.recording.video", qos: .userInitiated) @@ -176,6 +178,14 @@ final class ScreenRecordingManager: NSObject { config.channelCount = 2 } + let captureMic = AppPreferences.recordingCaptureMicrophone + if captureMic { + let status = AVCaptureDevice.authorizationStatus(for: .audio) + if status == .notDetermined { + _ = await AVCaptureDevice.requestAccess(for: .audio) + } + } + let dir = AppPreferences.saveDirectory let stamp = Int(Date().timeIntervalSince1970 * 1000) let path = "\(dir)/bettershot_\(stamp).mp4" @@ -187,7 +197,8 @@ final class ScreenRecordingManager: NSObject { width: width, height: height, fps: fps, - includeAudio: captureAudio + includeAudio: captureAudio, + includeMic: captureMic ) guard recordingSession.startWriting() else { @@ -209,6 +220,19 @@ final class ScreenRecordingManager: NSObject { try await scStream.startCapture() recordingSession.isCapturing = true + if captureMic { + let mic = MicrophoneCapture() + mic.onAudioSample = { [weak self] sampleBuffer in + guard let self = self else { return } + if self.isMicMuted { + self.silenceAudioBuffer(sampleBuffer) + } + self._streamSession?.appendMicSample(sampleBuffer) + } + mic.start() + self.micCapture = mic + } + state = .recording elapsedSeconds = 0 startTimer() @@ -225,6 +249,10 @@ final class ScreenRecordingManager: NSObject { session?.isCapturing = false + micCapture?.stop() + micCapture = nil + isMicMuted = false + if let stream { try? stream.removeStreamOutput(self, type: .screen) try? stream.removeStreamOutput(self, type: .audio) @@ -273,6 +301,10 @@ final class ScreenRecordingManager: NSObject { stopTimer() session?.isCapturing = false + micCapture?.stop() + micCapture = nil + isMicMuted = false + if let stream { try? stream.removeStreamOutput(self, type: .screen) try? stream.removeStreamOutput(self, type: .audio) @@ -307,6 +339,16 @@ final class ScreenRecordingManager: NSObject { timer?.invalidate() timer = nil } + + private func silenceAudioBuffer(_ sampleBuffer: CMSampleBuffer) { + guard let blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer) else { return } + let length = CMBlockBufferGetDataLength(blockBuffer) + _ = CMBlockBufferFillDataBytes(with: 0, blockBuffer: blockBuffer, offsetIntoDestination: 0, dataLength: length) + } + + func toggleMicMute() { + isMicMuted.toggle() + } } // MARK: - SCStreamDelegate @@ -331,8 +373,64 @@ extension ScreenRecordingManager: SCStreamOutput { _streamSession?.appendVideoSample(sampleBuffer) case .audio: _streamSession?.appendAudioSample(sampleBuffer) + case .microphone: + break @unknown default: break } } } + +// MARK: - MicrophoneCapture Helper + +final class MicrophoneCapture: NSObject, AVCaptureAudioDataOutputSampleBufferDelegate { + private var captureSession: AVCaptureSession? + private var audioOutput: AVCaptureAudioDataOutput? + private let sampleQueue = DispatchQueue(label: "com.bettershot.recording.mic", qos: .userInteractive) + + var onAudioSample: ((CMSampleBuffer) -> Void)? + + func start() { + let session = AVCaptureSession() + + guard let mic = AVCaptureDevice.default(for: .audio), + let input = try? AVCaptureDeviceInput(device: mic) else { return } + + if session.canAddInput(input) { + session.addInput(input) + } + + let output = AVCaptureAudioDataOutput() + + let audioSettings: [String: Any] = [ + AVFormatIDKey: kAudioFormatLinearPCM, + AVNumberOfChannelsKey: 1, + AVSampleRateKey: 48000.0, + AVLinearPCMBitDepthKey: 16, + AVLinearPCMIsBigEndianKey: false, + AVLinearPCMIsFloatKey: false, + AVLinearPCMIsNonInterleaved: false + ] + output.audioSettings = audioSettings + + output.setSampleBufferDelegate(self, queue: sampleQueue) + if session.canAddOutput(output) { + session.addOutput(output) + } + + self.captureSession = session + self.audioOutput = output + + session.startRunning() + } + + func stop() { + captureSession?.stopRunning() + captureSession = nil + audioOutput = nil + } + + nonisolated func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { + onAudioSample?(sampleBuffer) + } +} diff --git a/Sources/Settings/PreferencesView.swift b/Sources/Settings/PreferencesView.swift index 4858379..541fae5 100644 --- a/Sources/Settings/PreferencesView.swift +++ b/Sources/Settings/PreferencesView.swift @@ -671,6 +671,7 @@ struct RecordingSettingsTab: View { @AppStorage("bs_recordingFPS") private var recordingFPS: Int = 30 @AppStorage("bs_recordingShowCursor") private var showCursor: Bool = true @AppStorage("bs_recordingCaptureAudio") private var captureAudio: Bool = false + @AppStorage("bs_recordingCaptureMicrophone") private var captureMicrophone: Bool = false @AppStorage("bs_recordingOpenEditor") private var openEditor: Bool = true var body: some View { @@ -691,6 +692,7 @@ struct RecordingSettingsTab: View { Section("Capture") { Toggle("Show cursor in recording", isOn: $showCursor) Toggle("Capture system audio", isOn: $captureAudio) + Toggle("Capture microphone", isOn: $captureMicrophone) } Section("After Recording") { @@ -706,6 +708,7 @@ struct RecordingSettingsTab: View { recordingFPS = 30 showCursor = true captureAudio = false + captureMicrophone = false openEditor = true } .controlSize(.small) From fafcb33110ca0536cf3ef334d9351dae06e1a41b Mon Sep 17 00:00:00 2001 From: Andrei Khutartsou Date: Tue, 9 Jun 2026 22:20:13 +0200 Subject: [PATCH 3/6] refactor: use human-readable filename format and fix blurry retina canvas scale --- Sources/Capture/CaptureOrchestrator.swift | 5 ++--- Sources/Capture/ScreenCapture.swift | 3 +-- Sources/Editor/EditorCanvasView.swift | 3 ++- Sources/Editor/EditorModel.swift | 6 ++++-- Sources/Editor/EditorWindowView.swift | 8 ++++---- Sources/History/HistoryStore.swift | 4 ++-- Sources/Models/AppPreferences.swift | 11 +++++++++++ Sources/Preview/PreviewOverlay.swift | 3 ++- Sources/Recording/ScreenRecordingManager.swift | 5 ++--- Sources/Recording/VideoEditorModel.swift | 5 ++--- 10 files changed, 32 insertions(+), 21 deletions(-) diff --git a/Sources/Capture/CaptureOrchestrator.swift b/Sources/Capture/CaptureOrchestrator.swift index 43e327c..1471622 100644 --- a/Sources/Capture/CaptureOrchestrator.swift +++ b/Sources/Capture/CaptureOrchestrator.swift @@ -147,10 +147,9 @@ final class CaptureOrchestrator { private func saveImage(_ cgImage: CGImage) -> URL? { let dir = AppPreferences.saveDirectory - let stamp = Int(Date().timeIntervalSince1970 * 1000) let ext = AppPreferences.exportFormat.fileExtension - let path = "\(dir)/bettershot_\(stamp).\(ext)" - let url = URL(fileURLWithPath: path) + let filename = AppPreferences.generateFileName(extension: ext) + let url = URL(fileURLWithPath: "\(dir)/\(filename)") guard let destination = CGImageDestinationCreateWithURL( url as CFURL, diff --git a/Sources/Capture/ScreenCapture.swift b/Sources/Capture/ScreenCapture.swift index 7a25ab6..1272f5e 100644 --- a/Sources/Capture/ScreenCapture.swift +++ b/Sources/Capture/ScreenCapture.swift @@ -125,8 +125,7 @@ final class ScreenCapture { private func makeTempPath() -> String { let dir = NSTemporaryDirectory() - let stamp = Int(Date().timeIntervalSince1970 * 1000) - return "\(dir)bettershot_\(stamp).png" + return "\(dir)\(AppPreferences.generateFileName(extension: "png"))" } private func runScreencapture(_ arguments: [String]) async -> Bool { diff --git a/Sources/Editor/EditorCanvasView.swift b/Sources/Editor/EditorCanvasView.swift index 5d4287e..44c9f03 100644 --- a/Sources/Editor/EditorCanvasView.swift +++ b/Sources/Editor/EditorCanvasView.swift @@ -294,7 +294,8 @@ private struct CanvasScreenshotView: View { } var body: some View { - let nsImage = NSImage(cgImage: image, size: NSSize(width: image.width, height: image.height)) + let scale = NSScreen.main?.backingScaleFactor ?? 2.0 + let nsImage = NSImage(cgImage: image, size: NSSize(width: CGFloat(image.width) / scale, height: CGFloat(image.height) / scale)) Image(nsImage: nsImage) .resizable() diff --git a/Sources/Editor/EditorModel.swift b/Sources/Editor/EditorModel.swift index 0b319a8..985eece 100644 --- a/Sources/Editor/EditorModel.swift +++ b/Sources/Editor/EditorModel.swift @@ -103,7 +103,8 @@ final class EditorModel { let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else { return } sourceImage = image imageSize = CGSize(width: image.width, height: image.height) - previewImage = NSImage(cgImage: image, size: NSSize(width: image.width, height: image.height)) + let scale = NSScreen.main?.backingScaleFactor ?? 2.0 + previewImage = NSImage(cgImage: image, size: NSSize(width: CGFloat(image.width) / scale, height: CGFloat(image.height) / scale)) config = AppPreferences.defaultBeautifierConfig @@ -496,7 +497,8 @@ final class EditorModel { guard let composited = ctx.makeImage() else { return } sourceImage = composited - previewImage = NSImage(cgImage: composited, size: NSSize(width: composited.width, height: composited.height)) + let backingScale = NSScreen.main?.backingScaleFactor ?? 2.0 + previewImage = NSImage(cgImage: composited, size: NSSize(width: CGFloat(composited.width) / backingScale, height: CGFloat(composited.height) / backingScale)) } // MARK: - Render diff --git a/Sources/Editor/EditorWindowView.swift b/Sources/Editor/EditorWindowView.swift index 8f5b77e..948dc5e 100644 --- a/Sources/Editor/EditorWindowView.swift +++ b/Sources/Editor/EditorWindowView.swift @@ -100,10 +100,9 @@ struct EditorWindowView: View { guard let rendered = model.renderFinal() else { return } let dir = AppPreferences.saveDirectory - let stamp = Int(Date().timeIntervalSince1970 * 1000) let ext = AppPreferences.exportFormat.fileExtension - let path = "\(dir)/bettershot_\(stamp).\(ext)" - let url = URL(fileURLWithPath: path) + let filename = AppPreferences.generateFileName(extension: ext) + let url = URL(fileURLWithPath: "\(dir)/\(filename)") guard let dest = CGImageDestinationCreateWithURL( url as CFURL, @@ -161,7 +160,8 @@ struct EditorWindowView: View { private func copyToClipboard() async { guard let rendered = model.renderFinal() else { return } - let nsImage = NSImage(cgImage: rendered, size: NSSize(width: rendered.width, height: rendered.height)) + let scale = NSScreen.main?.backingScaleFactor ?? 2.0 + let nsImage = NSImage(cgImage: rendered, size: NSSize(width: CGFloat(rendered.width) / scale, height: CGFloat(rendered.height) / scale)) let pb = NSPasteboard.general pb.clearContents() pb.writeObjects([nsImage]) diff --git a/Sources/History/HistoryStore.swift b/Sources/History/HistoryStore.swift index cfb4cd2..18ed660 100644 --- a/Sources/History/HistoryStore.swift +++ b/Sources/History/HistoryStore.swift @@ -25,10 +25,10 @@ final class HistoryStore { func importCapture(from tempURL: URL, deleteSource: Bool = true, kind: CaptureKind = .screenshot) -> CaptureRecord? { let ext = tempURL.pathExtension.isEmpty ? "png" : tempURL.pathExtension - var filename = "bettershot_\(Int(Date().timeIntervalSince1970 * 1000)).\(ext)" + var filename = AppPreferences.generateFileName(extension: ext) var destURL = storageDir.appendingPathComponent(filename) if FileManager.default.fileExists(atPath: destURL.path) { - filename = "bettershot_\(Int(Date().timeIntervalSince1970 * 1000))_\(UUID().uuidString.prefix(6)).\(ext)" + filename = AppPreferences.generateFileName(extension: ext) destURL = storageDir.appendingPathComponent(filename) } diff --git a/Sources/Models/AppPreferences.swift b/Sources/Models/AppPreferences.swift index 08a1fe8..b02a6be 100644 --- a/Sources/Models/AppPreferences.swift +++ b/Sources/Models/AppPreferences.swift @@ -138,6 +138,17 @@ enum AppPreferences { } } } + + static func generateFileName(extension ext: String) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd_HHmmss" + let dateString = formatter.string(from: Date()) + + let allowedChars = "abcdefghijklmnopqrstuvwxyz" + let randomSuffix = String((0..<4).compactMap { _ in allowedChars.randomElement() }) + + return "bettershot_\(dateString)_\(randomSuffix).\(ext)" + } } // MARK: - Enums diff --git a/Sources/Preview/PreviewOverlay.swift b/Sources/Preview/PreviewOverlay.swift index 54516ea..73ecae7 100644 --- a/Sources/Preview/PreviewOverlay.swift +++ b/Sources/Preview/PreviewOverlay.swift @@ -200,7 +200,8 @@ struct PreviewCardView: View { generator.maximumSize = CGSize(width: 260, height: 196) if let result = try? await generator.image(at: .zero) { let cgImage = result.image - let nsImage = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) + let scale = NSScreen.main?.backingScaleFactor ?? 2.0 + let nsImage = NSImage(cgImage: cgImage, size: NSSize(width: CGFloat(cgImage.width) / scale, height: CGFloat(cgImage.height) / scale)) await MainActor.run { thumbnail = nsImage } } } diff --git a/Sources/Recording/ScreenRecordingManager.swift b/Sources/Recording/ScreenRecordingManager.swift index ef55b42..8ccb968 100644 --- a/Sources/Recording/ScreenRecordingManager.swift +++ b/Sources/Recording/ScreenRecordingManager.swift @@ -187,9 +187,8 @@ final class ScreenRecordingManager: NSObject { } let dir = AppPreferences.saveDirectory - let stamp = Int(Date().timeIntervalSince1970 * 1000) - let path = "\(dir)/bettershot_\(stamp).mp4" - let url = URL(fileURLWithPath: path) + let filename = AppPreferences.generateFileName(extension: "mp4") + let url = URL(fileURLWithPath: "\(dir)/\(filename)") outputURL = url let recordingSession = try RecordingSession( diff --git a/Sources/Recording/VideoEditorModel.swift b/Sources/Recording/VideoEditorModel.swift index f29febe..431c529 100644 --- a/Sources/Recording/VideoEditorModel.swift +++ b/Sources/Recording/VideoEditorModel.swift @@ -116,9 +116,8 @@ final class VideoEditorModel { let asset = AVURLAsset(url: sourceURL) let dir = AppPreferences.saveDirectory - let stamp = Int(Date().timeIntervalSince1970 * 1000) - let outputPath = "\(dir)/bettershot_\(stamp).mp4" - let outputURL = URL(fileURLWithPath: outputPath) + let filename = AppPreferences.generateFileName(extension: "mp4") + let outputURL = URL(fileURLWithPath: "\(dir)/\(filename)") var exportConfig = config if exportConfig.style != .none && exportConfig.padding <= 0 { From 73244b81aff0370fde2d77e5575c695596eb5a46 Mon Sep 17 00:00:00 2001 From: Andrei Khutartsou Date: Tue, 9 Jun 2026 22:29:15 +0200 Subject: [PATCH 4/6] fix: use raw PNG data for clipboard copy and direct CGImage rendering to fix screenshot quality degradation --- Sources/Capture/CaptureOrchestrator.swift | 7 +++++-- Sources/Editor/EditorCanvasView.swift | 5 +---- Sources/Editor/EditorModel.swift | 6 ++---- Sources/Editor/EditorWindowView.swift | 13 ++++++++----- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/Sources/Capture/CaptureOrchestrator.swift b/Sources/Capture/CaptureOrchestrator.swift index 1471622..4ef1612 100644 --- a/Sources/Capture/CaptureOrchestrator.swift +++ b/Sources/Capture/CaptureOrchestrator.swift @@ -203,9 +203,12 @@ final class CaptureOrchestrator { private func copyToClipboard(_ url: URL) { guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else { return } - let nsImage = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) let pb = NSPasteboard.general pb.clearContents() - pb.writeObjects([nsImage]) + let newRep = NSBitmapImageRep(cgImage: cgImage) + if let pngData = newRep.representation(using: .png, properties: [:]) { + pb.declareTypes([.png], owner: nil) + pb.setData(pngData, forType: .png) + } } } diff --git a/Sources/Editor/EditorCanvasView.swift b/Sources/Editor/EditorCanvasView.swift index 44c9f03..e2a688f 100644 --- a/Sources/Editor/EditorCanvasView.swift +++ b/Sources/Editor/EditorCanvasView.swift @@ -294,10 +294,7 @@ private struct CanvasScreenshotView: View { } var body: some View { - let scale = NSScreen.main?.backingScaleFactor ?? 2.0 - let nsImage = NSImage(cgImage: image, size: NSSize(width: CGFloat(image.width) / scale, height: CGFloat(image.height) / scale)) - - Image(nsImage: nsImage) + Image(decorative: image, scale: 1.0) .resizable() .interpolation(.high) .clipShape(clipShape) diff --git a/Sources/Editor/EditorModel.swift b/Sources/Editor/EditorModel.swift index 985eece..0b319a8 100644 --- a/Sources/Editor/EditorModel.swift +++ b/Sources/Editor/EditorModel.swift @@ -103,8 +103,7 @@ final class EditorModel { let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else { return } sourceImage = image imageSize = CGSize(width: image.width, height: image.height) - let scale = NSScreen.main?.backingScaleFactor ?? 2.0 - previewImage = NSImage(cgImage: image, size: NSSize(width: CGFloat(image.width) / scale, height: CGFloat(image.height) / scale)) + previewImage = NSImage(cgImage: image, size: NSSize(width: image.width, height: image.height)) config = AppPreferences.defaultBeautifierConfig @@ -497,8 +496,7 @@ final class EditorModel { guard let composited = ctx.makeImage() else { return } sourceImage = composited - let backingScale = NSScreen.main?.backingScaleFactor ?? 2.0 - previewImage = NSImage(cgImage: composited, size: NSSize(width: CGFloat(composited.width) / backingScale, height: CGFloat(composited.height) / backingScale)) + previewImage = NSImage(cgImage: composited, size: NSSize(width: composited.width, height: composited.height)) } // MARK: - Render diff --git a/Sources/Editor/EditorWindowView.swift b/Sources/Editor/EditorWindowView.swift index 948dc5e..3a06ecf 100644 --- a/Sources/Editor/EditorWindowView.swift +++ b/Sources/Editor/EditorWindowView.swift @@ -135,8 +135,9 @@ struct EditorWindowView: View { if AppPreferences.copyAfterSave { let pb = NSPasteboard.general pb.clearContents() - if let nsImage = NSImage(contentsOf: url) { - pb.writeObjects([nsImage]) + if let data = try? Data(contentsOf: url) { + pb.declareTypes([.png], owner: nil) + pb.setData(data, forType: .png) } } @@ -160,11 +161,13 @@ struct EditorWindowView: View { private func copyToClipboard() async { guard let rendered = model.renderFinal() else { return } - let scale = NSScreen.main?.backingScaleFactor ?? 2.0 - let nsImage = NSImage(cgImage: rendered, size: NSSize(width: CGFloat(rendered.width) / scale, height: CGFloat(rendered.height) / scale)) let pb = NSPasteboard.general pb.clearContents() - pb.writeObjects([nsImage]) + let newRep = NSBitmapImageRep(cgImage: rendered) + if let pngData = newRep.representation(using: .png, properties: [:]) { + pb.declareTypes([.png], owner: nil) + pb.setData(pngData, forType: .png) + } withAnimation { model.toastMessage = "Copied to clipboard" } } } From f4347c824b1a5f3173d34703f3ee671bbdb64622 Mon Sep 17 00:00:00 2001 From: Andrei Khutartsou Date: Tue, 9 Jun 2026 22:30:33 +0200 Subject: [PATCH 5/6] feat: bind Cmd+C keyboard shortcut to copy image to clipboard in editor --- Sources/Editor/EditorWindowView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Editor/EditorWindowView.swift b/Sources/Editor/EditorWindowView.swift index 3a06ecf..c7e0630 100644 --- a/Sources/Editor/EditorWindowView.swift +++ b/Sources/Editor/EditorWindowView.swift @@ -78,7 +78,7 @@ struct EditorWindowView: View { } label: { Label("Copy", systemImage: "doc.on.doc") } - .keyboardShortcut("c", modifiers: [.command, .shift]) + .keyboardShortcut("c", modifiers: .command) Button { Task { await exportImage() } From 500d42dc44c05df9333b48b82214b25669b3e35c Mon Sep 17 00:00:00 2001 From: Andrei Khutartsou Date: Wed, 10 Jun 2026 10:21:44 +0200 Subject: [PATCH 6/6] feat: support separate save directories for screenshots and screen recordings --- Sources/Capture/CaptureOrchestrator.swift | 2 +- Sources/Editor/EditorWindowView.swift | 2 +- Sources/Models/AppPreferences.swift | 12 +++++ .../Recording/ScreenRecordingManager.swift | 2 +- Sources/Recording/VideoEditorModel.swift | 2 +- Sources/Settings/PreferencesView.swift | 47 ++++++++++++++++--- 6 files changed, 56 insertions(+), 11 deletions(-) diff --git a/Sources/Capture/CaptureOrchestrator.swift b/Sources/Capture/CaptureOrchestrator.swift index 4ef1612..aae7549 100644 --- a/Sources/Capture/CaptureOrchestrator.swift +++ b/Sources/Capture/CaptureOrchestrator.swift @@ -146,7 +146,7 @@ final class CaptureOrchestrator { } private func saveImage(_ cgImage: CGImage) -> URL? { - let dir = AppPreferences.saveDirectory + let dir = AppPreferences.saveDirectoryScreenshots let ext = AppPreferences.exportFormat.fileExtension let filename = AppPreferences.generateFileName(extension: ext) let url = URL(fileURLWithPath: "\(dir)/\(filename)") diff --git a/Sources/Editor/EditorWindowView.swift b/Sources/Editor/EditorWindowView.swift index c7e0630..3e1b83a 100644 --- a/Sources/Editor/EditorWindowView.swift +++ b/Sources/Editor/EditorWindowView.swift @@ -99,7 +99,7 @@ struct EditorWindowView: View { private func exportImage() async { guard let rendered = model.renderFinal() else { return } - let dir = AppPreferences.saveDirectory + let dir = AppPreferences.saveDirectoryScreenshots let ext = AppPreferences.exportFormat.fileExtension let filename = AppPreferences.generateFileName(extension: ext) let url = URL(fileURLWithPath: "\(dir)/\(filename)") diff --git a/Sources/Models/AppPreferences.swift b/Sources/Models/AppPreferences.swift index b02a6be..5019230 100644 --- a/Sources/Models/AppPreferences.swift +++ b/Sources/Models/AppPreferences.swift @@ -6,6 +6,8 @@ enum AppPreferences { // MARK: - Keys private static let appearanceKey = "bs_appAppearance" private static let saveDirKey = "bs_saveDirectory" + private static let saveDirScreenshotsKey = "bs_saveDirectoryScreenshots" + private static let saveDirRecordingsKey = "bs_saveDirectoryRecordings" private static let copyAfterSaveKey = "bs_copyAfterSave" private static let playSoundKey = "bs_playSound" private static let overlayPositionKey = "bs_overlayPosition" @@ -40,6 +42,16 @@ enum AppPreferences { set { UserDefaults.standard.set(newValue, forKey: saveDirKey) } } + static var saveDirectoryScreenshots: String { + get { UserDefaults.standard.string(forKey: saveDirScreenshotsKey) ?? UserDefaults.standard.string(forKey: saveDirKey) ?? NSHomeDirectory() + "/Desktop" } + set { UserDefaults.standard.set(newValue, forKey: saveDirScreenshotsKey) } + } + + static var saveDirectoryRecordings: String { + get { UserDefaults.standard.string(forKey: saveDirRecordingsKey) ?? UserDefaults.standard.string(forKey: saveDirKey) ?? NSHomeDirectory() + "/Desktop" } + set { UserDefaults.standard.set(newValue, forKey: saveDirRecordingsKey) } + } + static var copyAfterSave: Bool { get { UserDefaults.standard.object(forKey: copyAfterSaveKey) as? Bool ?? true } set { UserDefaults.standard.set(newValue, forKey: copyAfterSaveKey) } diff --git a/Sources/Recording/ScreenRecordingManager.swift b/Sources/Recording/ScreenRecordingManager.swift index 8ccb968..dd86178 100644 --- a/Sources/Recording/ScreenRecordingManager.swift +++ b/Sources/Recording/ScreenRecordingManager.swift @@ -186,7 +186,7 @@ final class ScreenRecordingManager: NSObject { } } - let dir = AppPreferences.saveDirectory + let dir = AppPreferences.saveDirectoryRecordings let filename = AppPreferences.generateFileName(extension: "mp4") let url = URL(fileURLWithPath: "\(dir)/\(filename)") outputURL = url diff --git a/Sources/Recording/VideoEditorModel.swift b/Sources/Recording/VideoEditorModel.swift index 431c529..fc083b0 100644 --- a/Sources/Recording/VideoEditorModel.swift +++ b/Sources/Recording/VideoEditorModel.swift @@ -115,7 +115,7 @@ final class VideoEditorModel { guard let sourceURL else { return nil } let asset = AVURLAsset(url: sourceURL) - let dir = AppPreferences.saveDirectory + let dir = AppPreferences.saveDirectoryRecordings let filename = AppPreferences.generateFileName(extension: "mp4") let outputURL = URL(fileURLWithPath: "\(dir)/\(filename)") diff --git a/Sources/Settings/PreferencesView.swift b/Sources/Settings/PreferencesView.swift index 541fae5..f8199fa 100644 --- a/Sources/Settings/PreferencesView.swift +++ b/Sources/Settings/PreferencesView.swift @@ -69,6 +69,8 @@ struct PreferencesView: View { struct GeneralSettingsTab: View { @AppStorage("bs_appAppearance") private var appAppearanceRaw: String = AppAppearance.system.rawValue @AppStorage("bs_saveDirectory") private var saveDir = NSHomeDirectory() + "/Desktop" + @AppStorage("bs_saveDirectoryScreenshots") private var saveDirScreenshots = NSHomeDirectory() + "/Desktop" + @AppStorage("bs_saveDirectoryRecordings") private var saveDirRecordings = NSHomeDirectory() + "/Desktop" @AppStorage("bs_copyAfterSave") private var copyAfterSave = true @AppStorage("bs_playSound") private var playSound = true @AppStorage("bs_exportFormat") private var exportFormatRaw: String = ExportFormat.png.rawValue @@ -93,9 +95,18 @@ struct GeneralSettingsTab: View { ) } - private var saveDirDisplayName: String { - let url = URL(fileURLWithPath: saveDir) - return url.lastPathComponent + private var saveDirScreenshotsBinding: Binding { + Binding( + get: { AppPreferences.saveDirectoryScreenshots }, + set: { saveDirScreenshots = $0 } + ) + } + + private var saveDirRecordingsBinding: Binding { + Binding( + get: { AppPreferences.saveDirectoryRecordings }, + set: { saveDirRecordings = $0 } + ) } var body: some View { @@ -111,9 +122,29 @@ struct GeneralSettingsTab: View { Section("Save") { HStack { - Text("Save to") + Text("Save screenshots to") + Spacer() + Text(URL(fileURLWithPath: saveDirScreenshotsBinding.wrappedValue).lastPathComponent) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.head) + Button("Choose...") { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.directoryURL = URL(fileURLWithPath: saveDirScreenshotsBinding.wrappedValue) + if panel.runModal() == .OK, let url = panel.url { + saveDirScreenshotsBinding.wrappedValue = url.path + } + } + .controlSize(.small) + } + + HStack { + Text("Save recordings to") Spacer() - Text(saveDirDisplayName) + Text(URL(fileURLWithPath: saveDirRecordingsBinding.wrappedValue).lastPathComponent) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.head) @@ -122,9 +153,9 @@ struct GeneralSettingsTab: View { panel.canChooseFiles = false panel.canChooseDirectories = true panel.allowsMultipleSelection = false - panel.directoryURL = URL(fileURLWithPath: saveDir) + panel.directoryURL = URL(fileURLWithPath: saveDirRecordingsBinding.wrappedValue) if panel.runModal() == .OK, let url = panel.url { - saveDir = url.path + saveDirRecordingsBinding.wrappedValue = url.path } } .controlSize(.small) @@ -196,6 +227,8 @@ struct GeneralSettingsTab: View { appAppearanceRaw = AppAppearance.system.rawValue AppPreferences.applyAppearance() saveDir = NSHomeDirectory() + "/Desktop" + saveDirScreenshots = NSHomeDirectory() + "/Desktop" + saveDirRecordings = NSHomeDirectory() + "/Desktop" copyAfterSave = true playSound = true exportFormatRaw = ExportFormat.png.rawValue