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
224 changes: 224 additions & 0 deletions BetterCapture/Service/NotificationService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
//
// NotificationService.swift
// BetterCapture
//
// Created by Joshua Sattler on 06.02.26.
//

import Foundation
import UserNotifications
import AppKit
import OSLog

/// Service responsible for managing user notifications
@MainActor
@Observable
final class NotificationService: NSObject {

// MARK: - Constants

private enum NotificationIdentifier {
static let categoryRecordingSaved = "RECORDING_SAVED"
static let categoryRecordingFailed = "RECORDING_FAILED"
static let actionShowInFinder = "SHOW_IN_FINDER"
}

private enum UserInfoKey {
static let folderURL = "folderURL"
}

// MARK: - Properties

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

// MARK: - Initialization

override init() {
super.init()
setupNotificationDelegate()
registerNotificationCategories()
requestNotificationPermission()
}

// MARK: - Setup

private func setupNotificationDelegate() {
UNUserNotificationCenter.current().delegate = self
}

private func registerNotificationCategories() {
// Action to show recording in Finder
let showInFinderAction = UNNotificationAction(
identifier: NotificationIdentifier.actionShowInFinder,
title: "Show in Finder",
options: [.foreground]
)

// Category for successful recording with action
let recordingSavedCategory = UNNotificationCategory(
identifier: NotificationIdentifier.categoryRecordingSaved,
actions: [showInFinderAction],
intentIdentifiers: []
)

// Category for failed recording (no actions needed)
let recordingFailedCategory = UNNotificationCategory(
identifier: NotificationIdentifier.categoryRecordingFailed,
actions: [],
intentIdentifiers: []
)

UNUserNotificationCenter.current().setNotificationCategories([
recordingSavedCategory,
recordingFailedCategory
])
}

private func requestNotificationPermission() {
Task {
do {
let granted = try await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .sound])
if granted {
logger.info("Notification permission granted")
} else {
logger.warning("Notification permission denied")
}
} catch {
logger.error("Notification permission error: \(error.localizedDescription)")
}
}
}

// MARK: - Public Methods

/// Sends a notification for a successfully saved recording
/// - Parameter fileURL: The URL of the saved recording file
func sendRecordingSavedNotification(fileURL: URL) {
let content = UNMutableNotificationContent()
content.title = "Recording Saved"
content.body = "Your recording has been saved to \(fileURL.lastPathComponent)"
content.sound = .default
content.categoryIdentifier = NotificationIdentifier.categoryRecordingSaved

// Store the folder URL for opening when notification is clicked
let folderURL = fileURL.deletingLastPathComponent()
content.userInfo = [UserInfoKey.folderURL: folderURL.path()]

let request = UNNotificationRequest(
identifier: UUID().uuidString,
content: content,
trigger: nil
)

Task {
do {
try await UNUserNotificationCenter.current().add(request)
logger.info("Recording saved notification sent")
} catch {
logger.error("Failed to send notification: \(error.localizedDescription)")
}
}
}

/// Sends a notification for a failed recording
/// - Parameter error: The error that caused the recording to fail
func sendRecordingFailedNotification(error: Error) {
let content = UNMutableNotificationContent()
content.title = "Recording Failed"
content.body = "Your recording could not be saved: \(error.localizedDescription)"
content.sound = .default
content.categoryIdentifier = NotificationIdentifier.categoryRecordingFailed

let request = UNNotificationRequest(
identifier: UUID().uuidString,
content: content,
trigger: nil
)

Task {
do {
try await UNUserNotificationCenter.current().add(request)
logger.info("Recording failed notification sent")
} catch {
logger.error("Failed to send failure notification: \(error.localizedDescription)")
}
}
}

/// Sends a notification when recording stopped unexpectedly
/// - Parameter error: Optional error that caused the stop
func sendRecordingStoppedNotification(error: Error?) {
let content = UNMutableNotificationContent()
content.title = "Recording Stopped"

if let error {
content.body = "Recording stopped unexpectedly: \(error.localizedDescription)"
} else {
content.body = "Recording stopped unexpectedly"
}

content.sound = .default
content.categoryIdentifier = NotificationIdentifier.categoryRecordingFailed

let request = UNNotificationRequest(
identifier: UUID().uuidString,
content: content,
trigger: nil
)

Task {
do {
try await UNUserNotificationCenter.current().add(request)
logger.info("Recording stopped notification sent")
} catch {
logger.error("Failed to send stopped notification: \(error.localizedDescription)")
}
}
}

// MARK: - Private Methods

private func openFolderInFinder(path: String) {
let url = URL(filePath: path)
NSWorkspace.shared.open(url)
}
}

// MARK: - UNUserNotificationCenterDelegate

extension NotificationService: UNUserNotificationCenterDelegate {

nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
// Show notifications even when app is in foreground
[.banner, .sound]
}

nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
let userInfo = response.notification.request.content.userInfo
let categoryIdentifier = response.notification.request.content.categoryIdentifier

switch response.actionIdentifier {
case NotificationIdentifier.actionShowInFinder,
UNNotificationDefaultActionIdentifier where categoryIdentifier == NotificationIdentifier.categoryRecordingSaved:
// User tapped the notification or the "Show in Finder" action
if let folderPath = userInfo[UserInfoKey.folderURL] as? String {
await MainActor.run {
openFolderInFinder(path: folderPath)
}
}

default:
break
}
}
}
73 changes: 65 additions & 8 deletions BetterCapture/Service/PreviewService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,62 @@ final class PreviewService: NSObject {

// MARK: - Public Methods

/// Updates the content filter and captures a static thumbnail
/// - Parameter filter: The content filter to use
func setContentFilter(_ filter: SCContentFilter) async {
currentFilter = filter
await captureStaticThumbnail(for: filter)
}

/// Captures a single static frame as a thumbnail (no continuous streaming)
private func captureStaticThumbnail(for filter: SCContentFilter) async {
let config = SCStreamConfiguration()
config.width = previewWidth
config.height = previewHeight
config.pixelFormat = kCVPixelFormatType_32BGRA
config.showsCursor = true

do {
let image = try await SCScreenshotManager.captureImage(
contentFilter: filter,
configuration: config
)
previewImage = NSImage(cgImage: image, size: NSSize(width: image.width, height: image.height))
logger.info("Static thumbnail captured")
} catch {
logger.error("Failed to capture static thumbnail: \(error.localizedDescription)")
}
}

/// Starts the preview stream if a content filter is set
func startPreview() async {
guard let filter = currentFilter else {
logger.info("No content filter set, skipping preview start")
return
}

// If already streaming, just update the filter
if let stream, isCapturing {
do {
try await stream.updateContentFilter(filter)
logger.info("Updated preview stream filter")
} catch {
logger.error("Failed to update preview filter: \(error.localizedDescription)")
await stopStream()
await startStream(with: filter)
}
return
}

// Otherwise start a new stream
await startStream(with: filter)
}

/// Stops the preview stream
func stopPreview() async {
await stopStream()
}

/// Starts or updates the preview stream for the given content filter
/// - Parameter filter: The content filter to capture
func captureSnapshot(for filter: SCContentFilter) async {
Expand Down Expand Up @@ -109,16 +165,17 @@ final class PreviewService: NSObject {
// MARK: - Private Methods

private func stopStream() async {
guard let stream else { return }

do {
try await stream.stopCapture()
logger.info("Preview stream stopped")
} catch {
logger.error("Failed to stop preview stream: \(error.localizedDescription)")
if let stream {
do {
try await stream.stopCapture()
logger.info("Preview stream stopped")
} catch {
logger.error("Failed to stop preview stream: \(error.localizedDescription)")
}
self.stream = nil
}

self.stream = nil
// Always ensure isCapturing is false
isCapturing = false
}

Expand Down
25 changes: 19 additions & 6 deletions BetterCapture/View/MenuBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,26 @@ struct MenuBarView: View {

// Preview thumbnail below the content selection button
if viewModel.hasContentSelected {
PreviewThumbnailView(previewImage: currentPreview)
.onChange(of: viewModel.previewService.previewImage) { _, newImage in
currentPreview = newImage
}
.onAppear {
currentPreview = viewModel.previewService.previewImage
PreviewThumbnailView(
previewImage: currentPreview,
isLivePreviewActive: viewModel.previewService.isCapturing,
onStartLivePreview: {
Task {
await viewModel.startPreview()
}
},
onStopLivePreview: {
Task {
await viewModel.stopPreview()
}
}
)
.onChange(of: viewModel.previewService.previewImage) { _, newImage in
currentPreview = newImage
}
.onAppear {
currentPreview = viewModel.previewService.previewImage
}
}

MenuBarDivider()
Expand Down
Loading