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
103 changes: 73 additions & 30 deletions Snapper/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var hotkeyManager: HotkeyManager?
private var captureCoordinator: CaptureCoordinator?
private var quickAccessManager: QuickAccessManager?
private var pinnedScreenshotManager: PinnedScreenshotManager?
private var historyManager: HistoryManager?
private var historyBrowserWindow: HistoryBrowserWindow?
private var ocrCaptureController: OCRCaptureController?
private var timerCaptureController: TimerCaptureController?
private var allInOneHUDPanel: AllInOneHUDPanel?
private var updateManager: UpdateManager?
private var onboardingWindowController: OnboardingWindowController?
private var settingsWindow: NSWindow?
private var observerTokens: [NSObjectProtocol] = []

deinit {
for token in observerTokens {
NotificationCenter.default.removeObserver(token)
}
}

func applicationDidFinishLaunching(_ notification: Notification) {
ensureSingleInstance()
Expand All @@ -36,14 +45,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate {

// Overlay & Pinned
quickAccessManager = QuickAccessManager(appState: appState)
pinnedScreenshotManager = PinnedScreenshotManager(appState: appState)

// History
historyManager = HistoryManager()
historyBrowserWindow = HistoryBrowserWindow(historyManager: historyManager!)
if let historyManager {
historyBrowserWindow = HistoryBrowserWindow(historyManager: historyManager)
}

// Advanced capture modes
ocrCaptureController = OCRCaptureController()
timerCaptureController = TimerCaptureController()
allInOneHUDPanel = AllInOneHUDPanel()

// Updates
updateManager = UpdateManager()
Expand All @@ -68,6 +81,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
enforceReachability()
}

func applicationWillTerminate(_ notification: Notification) {
appState.flushDefaults()
}

func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
if !appState.menuBarVisible {
showSettingsWindow()
Expand Down Expand Up @@ -99,29 +116,38 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}

private func observeNotifications() {
NotificationCenter.default.addObserver(forName: .showHistory, object: nil, queue: .main) { [weak self] _ in
let showHistoryToken = NotificationCenter.default.addObserver(forName: .showHistory, object: nil, queue: .main) { [weak self] _ in
self?.historyBrowserWindow?.show()
}
observerTokens.append(showHistoryToken)

NotificationCenter.default.addObserver(forName: .showSettings, object: nil, queue: .main) { [weak self] _ in
let showSettingsToken = NotificationCenter.default.addObserver(forName: .showSettings, object: nil, queue: .main) { [weak self] _ in
self?.showSettingsWindow()
}
observerTokens.append(showSettingsToken)

NotificationCenter.default.addObserver(forName: .showOnboarding, object: nil, queue: .main) { [weak self] _ in
let showOnboardingToken = NotificationCenter.default.addObserver(forName: .showOnboarding, object: nil, queue: .main) { [weak self] _ in
self?.showOnboarding()
}
observerTokens.append(showOnboardingToken)

NotificationCenter.default.addObserver(forName: .requestPermissions, object: nil, queue: .main) { [weak self] _ in
let requestPermissionsToken = NotificationCenter.default.addObserver(forName: .requestPermissions, object: nil, queue: .main) { [weak self] _ in
self?.requestPermissions()
}
observerTokens.append(requestPermissionsToken)

NotificationCenter.default.addObserver(forName: .openEditor, object: nil, queue: .main) { notification in
let openEditorToken = NotificationCenter.default.addObserver(forName: .openEditor, object: nil, queue: .main) { notification in
if let wrapper = notification.object as? ImageWrapper {
AnnotationEditorWindow.open(with: wrapper.image)
}
}
observerTokens.append(openEditorToken)

NotificationCenter.default.addObserver(forName: .menuBarVisibilityChanged, object: nil, queue: .main) { [weak self] notification in
let menuVisibilityToken = NotificationCenter.default.addObserver(
forName: .menuBarVisibilityChanged,
object: nil,
queue: .main
) { [weak self] notification in
guard let isVisible = notification.object as? Bool else { return }
guard let self else { return }

Expand All @@ -138,42 +164,59 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
self.menuBarController?.hide()
}
}
observerTokens.append(menuVisibilityToken)

NotificationCenter.default.addObserver(forName: .historyRetentionChanged, object: nil, queue: .main) { [weak self] notification in
let historyRetentionToken = NotificationCenter.default.addObserver(
forName: .historyRetentionChanged,
object: nil,
queue: .main
) { [weak self] notification in
guard let days = notification.object as? Int else { return }
guard days > 0 else { return }
Task { @MainActor in
self?.historyManager?.deleteOlderThan(days: days)
}
self?.historyManager?.deleteOlderThan(days: days)
}

NotificationCenter.default.addObserver(forName: .clearHistoryRequested, object: nil, queue: .main) { [weak self] _ in
Task { @MainActor in
self?.historyManager?.clearAll()
}
observerTokens.append(historyRetentionToken)

let clearHistoryToken = NotificationCenter.default.addObserver(
forName: .clearHistoryRequested,
object: nil,
queue: .main
) { [weak self] _ in
self?.historyManager?.clearAll()
}
observerTokens.append(clearHistoryToken)

// Save captures to history
NotificationCenter.default.addObserver(forName: .captureCompleted, object: nil, queue: .main) { [weak self] notification in
let captureCompletedToken = NotificationCenter.default.addObserver(
forName: .captureCompleted,
object: nil,
queue: .main
) { [weak self] notification in
guard let info = notification.object as? CaptureCompletedInfo,
let historyManager = self?.historyManager else { return }

let recordID = UUID()
let image = info.result.image

DispatchQueue.global(qos: .utility).async {
let thumbnailImage = ImageUtils.generateThumbnail(image) ?? image
let thumbnailURL = historyManager.saveThumbnail(thumbnailImage, for: recordID)
Task { @MainActor in
historyManager.saveCapture(
result: info.result,
savedURL: info.savedURL,
thumbnailURL: thumbnailURL,
recordID: recordID
)
}
let thumbnailURL = info.thumbnail.flatMap { historyManager.saveThumbnail($0, for: info.recordID) }
historyManager.saveCapture(
result: info.result,
savedURL: info.savedURL,
thumbnailURL: thumbnailURL,
recordID: info.recordID,
fileSize: info.fileSize
)
}
}
observerTokens.append(captureCompletedToken)

let deleteHistoryRecordToken = NotificationCenter.default.addObserver(
forName: .deleteHistoryRecord,
object: nil,
queue: .main
) { [weak self] notification in
guard let recordID = notification.object as? UUID else { return }
self?.historyManager?.delete(recordID: recordID)
}
observerTokens.append(deleteHistoryRecordToken)
}

private func showSettingsWindow() {
Expand Down
71 changes: 51 additions & 20 deletions Snapper/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,82 +5,91 @@ import SwiftUI
@Observable
final class AppState {
private let defaults: UserDefaults
private var pendingDefaults: [String: Any] = [:]
private var flushWorkItem: DispatchWorkItem?

var isFirstRun: Bool {
didSet { defaults.set(!isFirstRun, forKey: Constants.Keys.hasLaunchedBefore) }
didSet { enqueueDefault(!isFirstRun, forKey: Constants.Keys.hasLaunchedBefore) }
}

var isCapturing = false

var menuBarVisible: Bool {
didSet { defaults.set(menuBarVisible, forKey: Constants.Keys.menuBarVisible) }
didSet { enqueueDefault(menuBarVisible, forKey: Constants.Keys.menuBarVisible) }
}

var launchAtLogin: Bool {
didSet { defaults.set(launchAtLogin, forKey: Constants.Keys.launchAtLogin) }
didSet { enqueueDefault(launchAtLogin, forKey: Constants.Keys.launchAtLogin) }
}

var captureSound: Bool {
didSet { defaults.set(captureSound, forKey: Constants.Keys.captureSound) }
didSet { enqueueDefault(captureSound, forKey: Constants.Keys.captureSound) }
}

var captureSoundName: CaptureSound {
didSet { defaults.set(captureSoundName.rawValue, forKey: Constants.Keys.captureSoundName) }
didSet { enqueueDefault(captureSoundName.rawValue, forKey: Constants.Keys.captureSoundName) }
}

var copyToClipboard: Bool {
didSet { defaults.set(copyToClipboard, forKey: Constants.Keys.copyToClipboard) }
didSet { enqueueDefault(copyToClipboard, forKey: Constants.Keys.copyToClipboard) }
}

var saveToFile: Bool {
didSet { defaults.set(saveToFile, forKey: Constants.Keys.saveToFile) }
didSet { enqueueDefault(saveToFile, forKey: Constants.Keys.saveToFile) }
}

var saveDirectory: URL {
didSet { defaults.set(saveDirectory.path, forKey: Constants.Keys.saveDirectory) }
didSet { enqueueDefault(saveDirectory.path, forKey: Constants.Keys.saveDirectory) }
}

var imageFormat: ImageFormat {
didSet { defaults.set(imageFormat.rawValue, forKey: Constants.Keys.imageFormat) }
didSet { enqueueDefault(imageFormat.rawValue, forKey: Constants.Keys.imageFormat) }
}

var jpegQuality: Double {
didSet { defaults.set(jpegQuality, forKey: Constants.Keys.jpegQuality) }
didSet { enqueueDefault(jpegQuality, forKey: Constants.Keys.jpegQuality) }
}

var filenamePattern: String {
didSet { defaults.set(filenamePattern, forKey: Constants.Keys.filenamePattern) }
didSet { enqueueDefault(filenamePattern, forKey: Constants.Keys.filenamePattern) }
}

var showCrosshair: Bool {
didSet { defaults.set(showCrosshair, forKey: Constants.Keys.showCrosshair) }
didSet { enqueueDefault(showCrosshair, forKey: Constants.Keys.showCrosshair) }
}

var showMagnifier: Bool {
didSet { defaults.set(showMagnifier, forKey: Constants.Keys.showMagnifier) }
didSet { enqueueDefault(showMagnifier, forKey: Constants.Keys.showMagnifier) }
}

var freezeScreen: Bool {
didSet { defaults.set(freezeScreen, forKey: Constants.Keys.freezeScreen) }
didSet { enqueueDefault(freezeScreen, forKey: Constants.Keys.freezeScreen) }
}

var retina2x: Bool {
didSet { defaults.set(retina2x, forKey: Constants.Keys.retina2x) }
didSet { enqueueDefault(retina2x, forKey: Constants.Keys.retina2x) }
}

var windowCaptureIncludeShadow: Bool {
didSet { enqueueDefault(windowCaptureIncludeShadow, forKey: Constants.Keys.windowCaptureIncludeShadow) }
}

var overlayCorner: OverlayCorner {
didSet {
defaults.set(overlayCorner.rawValue, forKey: Constants.Keys.overlayCorner)
enqueueDefault(overlayCorner.rawValue, forKey: Constants.Keys.overlayCorner)
NotificationCenter.default.post(name: .overlayCornerChanged, object: nil)
}
}

var historyRetentionDays: Int {
didSet { defaults.set(historyRetentionDays, forKey: Constants.Keys.historyRetentionDays) }
didSet { enqueueDefault(historyRetentionDays, forKey: Constants.Keys.historyRetentionDays) }
}

var defaultPinnedOpacity: Double {
didSet { defaults.set(defaultPinnedOpacity, forKey: Constants.Keys.defaultPinnedOpacity) }
didSet {
enqueueDefault(defaultPinnedOpacity, forKey: Constants.Keys.defaultPinnedOpacity)
NotificationCenter.default.post(name: .pinnedOpacityChanged, object: nil)
}
}

init(defaults: UserDefaults = .standard) {
Expand All @@ -102,7 +111,8 @@ final class AppState {
if let path = defaults.string(forKey: Constants.Keys.saveDirectory) {
self.saveDirectory = URL(fileURLWithPath: path)
} else {
self.saveDirectory = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first!
self.saveDirectory = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory())
}

if let raw = defaults.string(forKey: Constants.Keys.imageFormat),
Expand All @@ -115,9 +125,10 @@ final class AppState {
self.jpegQuality = defaults.object(forKey: Constants.Keys.jpegQuality) as? Double ?? 0.9
self.filenamePattern = defaults.string(forKey: Constants.Keys.filenamePattern) ?? Constants.Defaults.filenamePattern
self.showCrosshair = defaults.object(forKey: Constants.Keys.showCrosshair) as? Bool ?? true
self.showMagnifier = defaults.object(forKey: Constants.Keys.showMagnifier) as? Bool ?? true
self.showMagnifier = defaults.object(forKey: Constants.Keys.showMagnifier) as? Bool ?? false
self.freezeScreen = defaults.bool(forKey: Constants.Keys.freezeScreen)
self.retina2x = defaults.object(forKey: Constants.Keys.retina2x) as? Bool ?? true
self.windowCaptureIncludeShadow = defaults.object(forKey: Constants.Keys.windowCaptureIncludeShadow) as? Bool ?? true

if let raw = defaults.string(forKey: Constants.Keys.overlayCorner),
let corner = OverlayCorner(rawValue: raw) {
Expand All @@ -129,6 +140,26 @@ final class AppState {
self.historyRetentionDays = defaults.object(forKey: Constants.Keys.historyRetentionDays) as? Int ?? 30
self.defaultPinnedOpacity = defaults.object(forKey: Constants.Keys.defaultPinnedOpacity) as? Double ?? 1.0
}

private func enqueueDefault(_ value: Any, forKey key: String) {
pendingDefaults[key] = value
flushWorkItem?.cancel()
let item = DispatchWorkItem { [weak self] in
self?.flushDefaults()
}
flushWorkItem = item
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: item)
}

func flushDefaults() {
flushWorkItem?.cancel()
flushWorkItem = nil
guard !pendingDefaults.isEmpty else { return }
for (key, value) in pendingDefaults {
defaults.set(value, forKey: key)
}
pendingDefaults.removeAll()
}
}

enum ImageFormat: String, CaseIterable {
Expand Down
9 changes: 8 additions & 1 deletion Snapper/Capture/AllInOneHUD/AllInOneHUDPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import SwiftUI

final class AllInOneHUDPanel {
private var panel: NSPanel?
private var observerToken: NSObjectProtocol?

init() {
NotificationCenter.default.addObserver(
observerToken = NotificationCenter.default.addObserver(
forName: .showAllInOneHUD,
object: nil,
queue: .main
Expand All @@ -14,6 +15,12 @@ final class AllInOneHUDPanel {
}
}

deinit {
if let observerToken {
NotificationCenter.default.removeObserver(observerToken)
}
}

func show() {
if let existing = panel {
existing.makeKeyAndOrderFront(nil)
Expand Down
Loading