Skip to content
Open
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
5 changes: 4 additions & 1 deletion Resources/BetterShot.entitlements
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
<dict>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
2 changes: 2 additions & 0 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
<true/>
<key>NSScreenCaptureUsageDescription</key>
<string>BetterShot needs screen recording access to capture screenshots and record your screen.</string>
<key>NSMicrophoneUsageDescription</key>
<string>BetterShot needs microphone access to record audio along with your screen recordings.</string>
<key>NSRequiresAquaSystemAppearance</key>
<true/>
</dict>
Expand Down
14 changes: 8 additions & 6 deletions Sources/Capture/CaptureOrchestrator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,10 @@ final class CaptureOrchestrator {
}

private func saveImage(_ cgImage: CGImage) -> URL? {
let dir = AppPreferences.saveDirectory
let stamp = Int(Date().timeIntervalSince1970 * 1000)
let dir = AppPreferences.saveDirectoryScreenshots
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,
Expand Down Expand Up @@ -204,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)
}
}
}
6 changes: 5 additions & 1 deletion Sources/Capture/RegionSelectionOverlay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
3 changes: 1 addition & 2 deletions Sources/Capture/ScreenCapture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 1 addition & 3 deletions Sources/Editor/EditorCanvasView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -294,9 +294,7 @@ private struct CanvasScreenshotView: View {
}

var body: some View {
let nsImage = NSImage(cgImage: image, size: NSSize(width: image.width, height: image.height))

Image(nsImage: nsImage)
Image(decorative: image, scale: 1.0)
.resizable()
.interpolation(.high)
.clipShape(clipShape)
Expand Down
21 changes: 12 additions & 9 deletions Sources/Editor/EditorWindowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand All @@ -99,11 +99,10 @@ struct EditorWindowView: View {
private func exportImage() async {
guard let rendered = model.renderFinal() else { return }

let dir = AppPreferences.saveDirectory
let stamp = Int(Date().timeIntervalSince1970 * 1000)
let dir = AppPreferences.saveDirectoryScreenshots
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,
Expand Down Expand Up @@ -136,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)
}
}

Expand All @@ -161,10 +161,13 @@ 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 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" }
}
}
4 changes: 2 additions & 2 deletions Sources/History/HistoryStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
29 changes: 29 additions & 0 deletions Sources/Models/AppPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -16,6 +18,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
Expand All @@ -39,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) }
Expand Down Expand Up @@ -113,6 +126,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) }
Expand All @@ -132,6 +150,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
Expand Down
3 changes: 2 additions & 1 deletion Sources/Preview/PreviewOverlay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
}
Expand Down
32 changes: 31 additions & 1 deletion Sources/Recording/RecordingSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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] = [
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions Sources/Recording/RecordingStatusBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
Loading