commit 1c2b52fa30bb123a43889b8c2a173717f416b79e Author: Marco Wende Date: Wed Jul 1 15:27:57 2026 +0200 Initial commit: Snipping Tool menu bar screenshot app diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4a70fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +build/ +Distribution/ +Generated/ +SnippingTool.xcodeproj/ +.DS_Store +xcuserdata/ +*.xcuserstate diff --git a/Design/icon.svg b/Design/icon.svg new file mode 100644 index 0000000..063b6a0 --- /dev/null +++ b/Design/icon.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Sources/AppCoordinator.swift b/Sources/AppCoordinator.swift new file mode 100644 index 0000000..2a26bfd --- /dev/null +++ b/Sources/AppCoordinator.swift @@ -0,0 +1,152 @@ +import AppKit +import Foundation + +/// Central state + logic for the app: persisted settings, hot key +/// registration, and running screencapture to save/copy screenshots. +final class AppCoordinator: ObservableObject { + private enum Keys { + static let outputAction = "outputAction" + static let imageFormat = "imageFormat" + static let saveFolder = "saveFolder" + static func hotKeyCode(_ mode: CaptureMode) -> String { "hotkey.\(mode.rawValue).keyCode" } + static func hotKeyMods(_ mode: CaptureMode) -> String { "hotkey.\(mode.rawValue).modifiers" } + } + + @Published var outputAction: OutputAction { + didSet { UserDefaults.standard.set(outputAction.rawValue, forKey: Keys.outputAction) } + } + + @Published var imageFormat: ImageFormat { + didSet { UserDefaults.standard.set(imageFormat.rawValue, forKey: Keys.imageFormat) } + } + + @Published var saveFolder: URL { + didSet { UserDefaults.standard.set(saveFolder.path, forKey: Keys.saveFolder) } + } + + @Published var hotKeyFullScreen: HotKeyCombo { + didSet { persistAndRegister(mode: .fullScreen, combo: hotKeyFullScreen) } + } + + @Published var hotKeyWindow: HotKeyCombo { + didSet { persistAndRegister(mode: .window, combo: hotKeyWindow) } + } + + @Published var hotKeyRegion: HotKeyCombo { + didSet { persistAndRegister(mode: .region, combo: hotKeyRegion) } + } + + init() { + let defaults = UserDefaults.standard + + outputAction = OutputAction(rawValue: defaults.string(forKey: Keys.outputAction) ?? "") ?? .both + imageFormat = ImageFormat(rawValue: defaults.string(forKey: Keys.imageFormat) ?? "") ?? .png + + if let path = defaults.string(forKey: Keys.saveFolder) { + saveFolder = URL(fileURLWithPath: path) + } else { + saveFolder = Self.defaultSaveFolder() + } + + hotKeyFullScreen = Self.loadCombo(mode: .fullScreen, default: .defaultFullScreen) + hotKeyWindow = Self.loadCombo(mode: .window, default: .defaultWindow) + hotKeyRegion = Self.loadCombo(mode: .region, default: .defaultRegion) + + registerHotKey(mode: .fullScreen, combo: hotKeyFullScreen) + registerHotKey(mode: .window, combo: hotKeyWindow) + registerHotKey(mode: .region, combo: hotKeyRegion) + } + + private static func defaultSaveFolder() -> URL { + let pictures = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first + ?? FileManager.default.homeDirectoryForCurrentUser + return pictures.appendingPathComponent("Screenshots", isDirectory: true) + } + + private static func loadCombo(mode: CaptureMode, default defaultValue: HotKeyCombo) -> HotKeyCombo { + let defaults = UserDefaults.standard + let codeKey = Keys.hotKeyCode(mode) + let modsKey = Keys.hotKeyMods(mode) + guard defaults.object(forKey: codeKey) != nil, defaults.object(forKey: modsKey) != nil else { + return defaultValue + } + let code = UInt32(defaults.integer(forKey: codeKey)) + let mods = UInt32(defaults.integer(forKey: modsKey)) + return HotKeyCombo(keyCode: code, carbonModifiers: mods) + } + + private func persistAndRegister(mode: CaptureMode, combo: HotKeyCombo) { + let defaults = UserDefaults.standard + defaults.set(Int(combo.keyCode), forKey: Keys.hotKeyCode(mode)) + defaults.set(Int(combo.carbonModifiers), forKey: Keys.hotKeyMods(mode)) + registerHotKey(mode: mode, combo: combo) + } + + private func registerHotKey(mode: CaptureMode, combo: HotKeyCombo) { + HotKeyManager.shared.register( + id: mode.hotKeyID, + keyCode: combo.keyCode, + modifiers: combo.carbonModifiers + ) { [weak self] in + self?.capture(mode: mode) + } + } + + // MARK: - Capture + + func capture(mode: CaptureMode) { + let saves = outputAction.savesToFile + let copies = outputAction.copiesToClipboard + let ext = imageFormat.fileExtension + + let targetURL: URL + if saves { + try? FileManager.default.createDirectory(at: saveFolder, withIntermediateDirectories: true) + targetURL = saveFolder.appendingPathComponent(Self.filename(ext: ext)) + } else { + targetURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(Self.filename(ext: ext)) + } + + var arguments = mode.screencaptureArguments + arguments += ["-t", imageFormat.rawValue, targetURL.path] + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") + process.arguments = arguments + process.terminationHandler = { [weak self] _ in + DispatchQueue.main.async { + self?.handleCaptured(at: targetURL, saves: saves, copies: copies) + } + } + + do { + try process.run() + } catch { + NSLog("SnippingTool: failed to launch screencapture: \(error)") + } + } + + private func handleCaptured(at url: URL, saves: Bool, copies: Bool) { + guard FileManager.default.fileExists(atPath: url.path) else { + // User cancelled the interactive capture (Esc). + return + } + + if copies, let image = NSImage(contentsOf: url) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.writeObjects([image]) + } + + if !saves { + try? FileManager.default.removeItem(at: url) + } + } + + private static func filename(ext: String) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd 'um' HH.mm.ss" + return "Bildschirmfoto \(formatter.string(from: Date())).\(ext)" + } +} diff --git a/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..c9ddaab --- /dev/null +++ b/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "filename" : "icon_16.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "filename" : "icon_32.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "filename" : "icon_32.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "filename" : "icon_64.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "filename" : "icon_128.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "filename" : "icon_256.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "filename" : "icon_256.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "filename" : "icon_512.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "icon_512.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "icon_1024.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Assets.xcassets/AppIcon.appiconset/icon_1024.png b/Sources/Assets.xcassets/AppIcon.appiconset/icon_1024.png new file mode 100644 index 0000000..59893c0 Binary files /dev/null and b/Sources/Assets.xcassets/AppIcon.appiconset/icon_1024.png differ diff --git a/Sources/Assets.xcassets/AppIcon.appiconset/icon_128.png b/Sources/Assets.xcassets/AppIcon.appiconset/icon_128.png new file mode 100644 index 0000000..ba6fae2 Binary files /dev/null and b/Sources/Assets.xcassets/AppIcon.appiconset/icon_128.png differ diff --git a/Sources/Assets.xcassets/AppIcon.appiconset/icon_16.png b/Sources/Assets.xcassets/AppIcon.appiconset/icon_16.png new file mode 100644 index 0000000..2b51e20 Binary files /dev/null and b/Sources/Assets.xcassets/AppIcon.appiconset/icon_16.png differ diff --git a/Sources/Assets.xcassets/AppIcon.appiconset/icon_256.png b/Sources/Assets.xcassets/AppIcon.appiconset/icon_256.png new file mode 100644 index 0000000..144db94 Binary files /dev/null and b/Sources/Assets.xcassets/AppIcon.appiconset/icon_256.png differ diff --git a/Sources/Assets.xcassets/AppIcon.appiconset/icon_32.png b/Sources/Assets.xcassets/AppIcon.appiconset/icon_32.png new file mode 100644 index 0000000..cc0f402 Binary files /dev/null and b/Sources/Assets.xcassets/AppIcon.appiconset/icon_32.png differ diff --git a/Sources/Assets.xcassets/AppIcon.appiconset/icon_512.png b/Sources/Assets.xcassets/AppIcon.appiconset/icon_512.png new file mode 100644 index 0000000..b7d6696 Binary files /dev/null and b/Sources/Assets.xcassets/AppIcon.appiconset/icon_512.png differ diff --git a/Sources/Assets.xcassets/AppIcon.appiconset/icon_64.png b/Sources/Assets.xcassets/AppIcon.appiconset/icon_64.png new file mode 100644 index 0000000..063dabb Binary files /dev/null and b/Sources/Assets.xcassets/AppIcon.appiconset/icon_64.png differ diff --git a/Sources/Assets.xcassets/Contents.json b/Sources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Sources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/CaptureMode.swift b/Sources/CaptureMode.swift new file mode 100644 index 0000000..a7e54f5 --- /dev/null +++ b/Sources/CaptureMode.swift @@ -0,0 +1,25 @@ +import Foundation + +enum CaptureMode: String, CaseIterable { + case fullScreen + case window + case region + + /// Stable id used to register/unregister the matching global hot key. + var hotKeyID: UInt32 { + switch self { + case .fullScreen: return 1 + case .window: return 2 + case .region: return 3 + } + } + + /// Extra arguments passed to /usr/sbin/screencapture for this mode. + var screencaptureArguments: [String] { + switch self { + case .fullScreen: return [] + case .window: return ["-i", "-W"] + case .region: return ["-i", "-s"] + } + } +} diff --git a/Sources/HotKeyCombo.swift b/Sources/HotKeyCombo.swift new file mode 100644 index 0000000..208d56d --- /dev/null +++ b/Sources/HotKeyCombo.swift @@ -0,0 +1,23 @@ +import Carbon.HIToolbox + +/// A recorded global keyboard shortcut: a virtual key code plus Carbon-style modifier flags. +struct HotKeyCombo: Codable, Equatable { + var keyCode: UInt32 + var carbonModifiers: UInt32 + + var displayString: String { + var symbol = "" + if carbonModifiers & UInt32(controlKey) != 0 { symbol += "\u{2303}" } + if carbonModifiers & UInt32(optionKey) != 0 { symbol += "\u{2325}" } + if carbonModifiers & UInt32(shiftKey) != 0 { symbol += "\u{21E7}" } + if carbonModifiers & UInt32(cmdKey) != 0 { symbol += "\u{2318}" } + symbol += KeyCodeSymbols.symbol(for: keyCode) + return symbol + } + + static let defaultModifiers = UInt32(cmdKey) | UInt32(optionKey) | UInt32(shiftKey) + + static let defaultFullScreen = HotKeyCombo(keyCode: UInt32(kVK_ANSI_3), carbonModifiers: defaultModifiers) + static let defaultWindow = HotKeyCombo(keyCode: UInt32(kVK_ANSI_4), carbonModifiers: defaultModifiers) + static let defaultRegion = HotKeyCombo(keyCode: UInt32(kVK_ANSI_5), carbonModifiers: defaultModifiers) +} diff --git a/Sources/HotKeyManager.swift b/Sources/HotKeyManager.swift new file mode 100644 index 0000000..ba9883c --- /dev/null +++ b/Sources/HotKeyManager.swift @@ -0,0 +1,86 @@ +import Carbon.HIToolbox +import Foundation + +/// Thin wrapper around the Carbon RegisterEventHotKey API to register +/// system-wide keyboard shortcuts without requiring Accessibility/Input +/// Monitoring permission. +final class HotKeyManager { + static let shared = HotKeyManager() + + private var hotKeyRefs: [UInt32: EventHotKeyRef] = [:] + private var handlers: [UInt32: () -> Void] = [:] + private var eventHandlerRef: EventHandlerRef? + + private static let signature: FourCharCode = { + var result: FourCharCode = 0 + for byte in "SNAP".utf8 { + result = (result << 8) + FourCharCode(byte) + } + return result + }() + + private init() { + installEventHandler() + } + + private func installEventHandler() { + var eventType = EventTypeSpec( + eventClass: OSType(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyPressed) + ) + + let callback: EventHandlerUPP = { _, event, userData in + guard let event, let userData else { return noErr } + var hotKeyID = EventHotKeyID() + GetEventParameter( + event, + EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), + nil, + MemoryLayout.size, + nil, + &hotKeyID + ) + let manager = Unmanaged.fromOpaque(userData).takeUnretainedValue() + manager.handlers[hotKeyID.id]?() + return noErr + } + + InstallEventHandler( + GetEventDispatcherTarget(), + callback, + 1, + &eventType, + Unmanaged.passUnretained(self).toOpaque(), + &eventHandlerRef + ) + } + + @discardableResult + func register(id: UInt32, keyCode: UInt32, modifiers: UInt32, handler: @escaping () -> Void) -> Bool { + unregister(id: id) + + var hotKeyRef: EventHotKeyRef? + let hotKeyID = EventHotKeyID(signature: Self.signature, id: id) + let status = RegisterEventHotKey( + keyCode, + modifiers, + hotKeyID, + GetEventDispatcherTarget(), + 0, + &hotKeyRef + ) + + guard status == noErr, let hotKeyRef else { return false } + hotKeyRefs[id] = hotKeyRef + handlers[id] = handler + return true + } + + func unregister(id: UInt32) { + if let ref = hotKeyRefs.removeValue(forKey: id) { + UnregisterEventHotKey(ref) + } + handlers.removeValue(forKey: id) + } +} diff --git a/Sources/HotKeyRecorderView.swift b/Sources/HotKeyRecorderView.swift new file mode 100644 index 0000000..90939cf --- /dev/null +++ b/Sources/HotKeyRecorderView.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct HotKeyRecorderView: View { + let title: String + @Binding var combo: HotKeyCombo + + @State private var isRecording = false + + var body: some View { + HStack { + Text(title) + Spacer() + ZStack { + RoundedRectangle(cornerRadius: 6) + .fill(Color.secondary.opacity(isRecording ? 0.3 : 0.15)) + Text(isRecording ? "Taste drücken… (Esc = Abbrechen)" : combo.displayString) + .font(isRecording ? .callout : .body) + .lineLimit(1) + KeyCaptureRepresentable(isRecording: $isRecording) { keyCode, modifiers in + combo = HotKeyCombo(keyCode: keyCode, carbonModifiers: modifiers) + } + } + .frame(width: 190, height: 24) + .onTapGesture { isRecording = true } + } + } +} diff --git a/Sources/ImageFormat.swift b/Sources/ImageFormat.swift new file mode 100644 index 0000000..6ec2f48 --- /dev/null +++ b/Sources/ImageFormat.swift @@ -0,0 +1,10 @@ +import Foundation + +enum ImageFormat: String, CaseIterable, Identifiable, Codable { + case png + case jpg + + var id: String { rawValue } + var label: String { rawValue.uppercased() } + var fileExtension: String { rawValue } +} diff --git a/Sources/KeyCaptureRepresentable.swift b/Sources/KeyCaptureRepresentable.swift new file mode 100644 index 0000000..b03b3ff --- /dev/null +++ b/Sources/KeyCaptureRepresentable.swift @@ -0,0 +1,54 @@ +import AppKit +import Carbon.HIToolbox +import SwiftUI + +/// Invisible NSView overlay that becomes first responder while recording +/// and reports the next key combo (key code + modifiers) it sees. +final class KeyCaptureView: NSView { + var onCapture: ((UInt32, UInt32) -> Void)? + var onCancel: (() -> Void)? + + override var acceptsFirstResponder: Bool { true } + + override func keyDown(with event: NSEvent) { + if Int(event.keyCode) == kVK_Escape { + onCancel?() + return + } + + var modifiers: UInt32 = 0 + if event.modifierFlags.contains(.control) { modifiers |= UInt32(controlKey) } + if event.modifierFlags.contains(.option) { modifiers |= UInt32(optionKey) } + if event.modifierFlags.contains(.shift) { modifiers |= UInt32(shiftKey) } + if event.modifierFlags.contains(.command) { modifiers |= UInt32(cmdKey) } + + // Require at least one modifier so plain letters still work normally elsewhere. + guard modifiers != 0 else { return } + onCapture?(UInt32(event.keyCode), modifiers) + } +} + +struct KeyCaptureRepresentable: NSViewRepresentable { + @Binding var isRecording: Bool + let onCapture: (UInt32, UInt32) -> Void + + func makeNSView(context: Context) -> KeyCaptureView { + let view = KeyCaptureView() + view.onCapture = { keyCode, modifiers in + onCapture(keyCode, modifiers) + isRecording = false + } + view.onCancel = { + isRecording = false + } + return view + } + + func updateNSView(_ nsView: KeyCaptureView, context: Context) { + if isRecording { + DispatchQueue.main.async { + nsView.window?.makeFirstResponder(nsView) + } + } + } +} diff --git a/Sources/KeyCodeSymbols.swift b/Sources/KeyCodeSymbols.swift new file mode 100644 index 0000000..c4f3212 --- /dev/null +++ b/Sources/KeyCodeSymbols.swift @@ -0,0 +1,27 @@ +import Carbon.HIToolbox + +/// Maps macOS virtual key codes to a short display symbol, for showing +/// recorded hot keys in the settings UI (e.g. "3", "F5", "Space"). +enum KeyCodeSymbols { + static let map: [UInt32: String] = [ + UInt32(kVK_ANSI_0): "0", UInt32(kVK_ANSI_1): "1", UInt32(kVK_ANSI_2): "2", UInt32(kVK_ANSI_3): "3", + UInt32(kVK_ANSI_4): "4", UInt32(kVK_ANSI_5): "5", UInt32(kVK_ANSI_6): "6", UInt32(kVK_ANSI_7): "7", + UInt32(kVK_ANSI_8): "8", UInt32(kVK_ANSI_9): "9", + UInt32(kVK_ANSI_A): "A", UInt32(kVK_ANSI_B): "B", UInt32(kVK_ANSI_C): "C", UInt32(kVK_ANSI_D): "D", + UInt32(kVK_ANSI_E): "E", UInt32(kVK_ANSI_F): "F", UInt32(kVK_ANSI_G): "G", UInt32(kVK_ANSI_H): "H", + UInt32(kVK_ANSI_I): "I", UInt32(kVK_ANSI_J): "J", UInt32(kVK_ANSI_K): "K", UInt32(kVK_ANSI_L): "L", + UInt32(kVK_ANSI_M): "M", UInt32(kVK_ANSI_N): "N", UInt32(kVK_ANSI_O): "O", UInt32(kVK_ANSI_P): "P", + UInt32(kVK_ANSI_Q): "Q", UInt32(kVK_ANSI_R): "R", UInt32(kVK_ANSI_S): "S", UInt32(kVK_ANSI_T): "T", + UInt32(kVK_ANSI_U): "U", UInt32(kVK_ANSI_V): "V", UInt32(kVK_ANSI_W): "W", UInt32(kVK_ANSI_X): "X", + UInt32(kVK_ANSI_Y): "Y", UInt32(kVK_ANSI_Z): "Z", + UInt32(kVK_F1): "F1", UInt32(kVK_F2): "F2", UInt32(kVK_F3): "F3", UInt32(kVK_F4): "F4", + UInt32(kVK_F5): "F5", UInt32(kVK_F6): "F6", UInt32(kVK_F7): "F7", UInt32(kVK_F8): "F8", + UInt32(kVK_F9): "F9", UInt32(kVK_F10): "F10", UInt32(kVK_F11): "F11", UInt32(kVK_F12): "F12", + UInt32(kVK_Space): "Space", UInt32(kVK_Return): "Return", UInt32(kVK_Tab): "Tab", + UInt32(kVK_Delete): "Delete", UInt32(kVK_Escape): "Esc" + ] + + static func symbol(for keyCode: UInt32) -> String { + map[keyCode] ?? "Code \(keyCode)" + } +} diff --git a/Sources/MenuBarContentView.swift b/Sources/MenuBarContentView.swift new file mode 100644 index 0000000..55223f6 --- /dev/null +++ b/Sources/MenuBarContentView.swift @@ -0,0 +1,26 @@ +import AppKit +import SwiftUI + +struct MenuBarContentView: View { + @EnvironmentObject var coordinator: AppCoordinator + + var body: some View { + Button("Ganzer Bildschirm") { coordinator.capture(mode: .fullScreen) } + Button("Fenster") { coordinator.capture(mode: .window) } + Button("Bereich auswählen") { coordinator.capture(mode: .region) } + + Divider() + + Button("Einstellungen…") { + SettingsWindowController.shared.show(coordinator: coordinator) + } + .keyboardShortcut(",") + + Divider() + + Button("Beenden") { + NSApplication.shared.terminate(nil) + } + .keyboardShortcut("q") + } +} diff --git a/Sources/OutputAction.swift b/Sources/OutputAction.swift new file mode 100644 index 0000000..538beb1 --- /dev/null +++ b/Sources/OutputAction.swift @@ -0,0 +1,20 @@ +import Foundation + +enum OutputAction: String, CaseIterable, Identifiable, Codable { + case saveOnly + case clipboardOnly + case both + + var id: String { rawValue } + + var label: String { + switch self { + case .saveOnly: return "Nur speichern" + case .clipboardOnly: return "Nur in Zwischenablage kopieren" + case .both: return "Speichern und in Zwischenablage kopieren" + } + } + + var savesToFile: Bool { self == .saveOnly || self == .both } + var copiesToClipboard: Bool { self == .clipboardOnly || self == .both } +} diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift new file mode 100644 index 0000000..456d61b --- /dev/null +++ b/Sources/SettingsView.swift @@ -0,0 +1,59 @@ +import AppKit +import SwiftUI + +struct SettingsView: View { + @EnvironmentObject var coordinator: AppCoordinator + + var body: some View { + Form { + Section("Nach der Aufnahme") { + Picker("Aktion:", selection: $coordinator.outputAction) { + ForEach(OutputAction.allCases) { action in + Text(action.label).tag(action) + } + } + .pickerStyle(.radioGroup) + + Picker("Format:", selection: $coordinator.imageFormat) { + ForEach(ImageFormat.allCases) { format in + Text(format.label).tag(format) + } + } + .pickerStyle(.segmented) + .frame(width: 160) + + HStack { + Text("Speicherort:") + Text(coordinator.saveFolder.path) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Button("Ändern…") { chooseFolder() } + } + } + + Section("Tastenkürzel") { + HotKeyRecorderView(title: "Ganzer Bildschirm", combo: $coordinator.hotKeyFullScreen) + HotKeyRecorderView(title: "Fenster", combo: $coordinator.hotKeyWindow) + HotKeyRecorderView(title: "Bereich auswählen", combo: $coordinator.hotKeyRegion) + } + } + .padding(20) + .frame(width: 440) + } + + private func chooseFolder() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.canCreateDirectories = true + panel.allowsMultipleSelection = false + panel.directoryURL = coordinator.saveFolder + panel.prompt = "Auswählen" + + if panel.runModal() == .OK, let url = panel.url { + coordinator.saveFolder = url + } + } +} diff --git a/Sources/SettingsWindowController.swift b/Sources/SettingsWindowController.swift new file mode 100644 index 0000000..c27f2ba --- /dev/null +++ b/Sources/SettingsWindowController.swift @@ -0,0 +1,38 @@ +import AppKit +import SwiftUI + +/// Manages the settings window manually instead of relying on SwiftUI's +/// `Settings` scene, which is unreliable for LSUIElement (menu bar-only, +/// no dock icon) apps: there's no existing window for the app to focus, +/// so the scene sometimes never becomes key. +final class SettingsWindowController: NSWindowController, NSWindowDelegate { + static let shared = SettingsWindowController() + + private convenience init() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 440, height: 420), + styleMask: [.titled, .closable, .miniaturizable], + backing: .buffered, + defer: false + ) + window.title = "Einstellungen" + window.isReleasedWhenClosed = false + window.center() + self.init(window: window) + window.delegate = self + } + + func show(coordinator: AppCoordinator) { + window?.contentView = NSHostingView( + rootView: SettingsView().environmentObject(coordinator) + ) + NSApplication.shared.setActivationPolicy(.regular) + NSApplication.shared.activate(ignoringOtherApps: true) + window?.makeKeyAndOrderFront(nil) + } + + func windowWillClose(_ notification: Notification) { + // Menu bar-only apps should not keep a Dock icon after the window closes. + NSApplication.shared.setActivationPolicy(.accessory) + } +} diff --git a/Sources/SnippingToolApp.swift b/Sources/SnippingToolApp.swift new file mode 100644 index 0000000..21f8265 --- /dev/null +++ b/Sources/SnippingToolApp.swift @@ -0,0 +1,14 @@ +import SwiftUI + +@main +struct SnippingToolApp: App { + @StateObject private var coordinator = AppCoordinator() + + var body: some Scene { + MenuBarExtra("Snipping Tool", systemImage: "camera.viewfinder") { + MenuBarContentView() + .environmentObject(coordinator) + } + .menuBarExtraStyle(.menu) + } +} diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..b0ce3dc --- /dev/null +++ b/project.yml @@ -0,0 +1,38 @@ +name: SnippingTool +options: + bundleIdPrefix: it.wende + deploymentTarget: + macOS: "14.0" + createIntermediateGroups: true +targets: + SnippingTool: + type: application + platform: macOS + deploymentTarget: "14.0" + sources: + - path: Sources + info: + path: Generated/Info.plist + properties: + CFBundleDisplayName: Snipping Tool + CFBundleName: Snipping Tool + LSUIElement: true + NSHumanReadableCopyright: "Copyright © 2026" + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: it.wende.snippingtool + PRODUCT_NAME: Snipping Tool + MARKETING_VERSION: "1.0" + CURRENT_PROJECT_VERSION: "1" + SWIFT_VERSION: "5.0" + MACOSX_DEPLOYMENT_TARGET: "14.0" + ENABLE_HARDENED_RUNTIME: false + CODE_SIGN_IDENTITY: "-" + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon +schemes: + SnippingTool: + build: + targets: + SnippingTool: all + run: + config: Debug