├── Sources └── AutoScreenshooter │ ├── Resources │ ├── autoshooter.icns │ └── autoshooter.png │ ├── AutoScreenshooterApp.swift │ ├── SelectionOverlay.swift │ ├── CountdownView.swift │ ├── CustomAreaSelector.swift │ ├── KeypressCaptureView.swift │ ├── ScreenshotManager.swift │ ├── ContentView.swift │ └── WindowPickerView.swift ├── Package.swift ├── .gitignore ├── LICENCE ├── make_app.sh ├── Info.plist └── README.md /Sources/AutoScreenshooter/Resources/autoshooter.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/underhubber/macos-auto-screenshooter/HEAD/Sources/AutoScreenshooter/Resources/autoshooter.icns -------------------------------------------------------------------------------- /Sources/AutoScreenshooter/Resources/autoshooter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/underhubber/macos-auto-screenshooter/HEAD/Sources/AutoScreenshooter/Resources/autoshooter.png -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "AutoScreenshooter", 6 | platforms: [ 7 | .macOS(.v13) 8 | ], 9 | products: [ 10 | .executable( 11 | name: "AutoScreenshooter", 12 | targets: ["AutoScreenshooter"] 13 | ) 14 | ], 15 | targets: [ 16 | .executableTarget( 17 | name: "AutoScreenshooter", 18 | dependencies: [], 19 | resources: [.process("Resources")] 20 | ) 21 | ] 22 | ) 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Swift Package Manager 26 | .build/ 27 | .swiftpm/ 28 | 29 | ## macOS 30 | .DS_Store 31 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Underhub 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/AutoScreenshooter/AutoScreenshooterApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import AppKit 3 | 4 | @main 5 | struct AutoScreenshooterApp: App { 6 | @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 7 | @StateObject private var screenshotManager = ScreenshotManager() 8 | 9 | var body: some Scene { 10 | WindowGroup { 11 | ContentView() 12 | .environmentObject(screenshotManager) 13 | .frame(minWidth: 600, minHeight: 500) 14 | } 15 | .windowStyle(.hiddenTitleBar) 16 | .windowResizability(.contentSize) 17 | } 18 | } 19 | 20 | class AppDelegate: NSObject, NSApplicationDelegate { 21 | func applicationDidFinishLaunching(_ notification: Notification) { 22 | NSApp.setActivationPolicy(.regular) 23 | NSApp.activate(ignoringOtherApps: true) 24 | requestScreenCaptureAccess() 25 | } 26 | 27 | func requestScreenCaptureAccess() { 28 | let options: NSDictionary = [kAXTrustedCheckOptionPrompt.takeRetainedValue() as NSString: true] 29 | _ = AXIsProcessTrustedWithOptions(options) 30 | _ = CGWindowListCopyWindowInfo(.optionOnScreenOnly, kCGNullWindowID) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /make_app.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zsh 2 | set -e 3 | 4 | APP_NAME="AutoScreenshooter" 5 | BUILD_DIR="$(pwd)/.build/release" 6 | EXEC="$BUILD_DIR/$APP_NAME" 7 | BUNDLE="$BUILD_DIR/$APP_NAME.app" 8 | 9 | # 1. Create bundle skeleton 10 | mkdir -p "$BUNDLE/Contents/MacOS" "$BUNDLE/Contents/Resources" 11 | 12 | # 2. Copy executable and icon 13 | cp "$EXEC" "$BUNDLE/Contents/MacOS/$APP_NAME" 14 | cp Sources/AutoScreenshooter/Resources/*.icns "$BUNDLE/Contents/Resources/" || echo "Warning: No icon files found, continuing without them." 15 | 16 | # 3. Write Info.plist that points at the icon 17 | cat >"$BUNDLE/Contents/Info.plist" < 19 | 21 | 22 | 23 | CFBundleExecutable $APP_NAME 24 | CFBundleIdentifier com.example.$APP_NAME 25 | CFBundleName $APP_NAME 26 | CFBundlePackageType APPL 27 | CFBundleIconFile autoshooter 28 | CFBundleShortVersionString1.0 29 | CFBundleVersion 1.0 30 | 31 | 32 | EOF 33 | 34 | echo "✨ Created $BUNDLE" 35 | open -R "$BUNDLE" -------------------------------------------------------------------------------- /Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleName 6 | AutoScreenshooter 7 | CFBundleDisplayName 8 | Auto Screenshooter 9 | CFBundleIdentifier 10 | com.autoscreenshooter.app 11 | CFBundleVersion 12 | 1.0 13 | CFBundleShortVersionString 14 | 1.0 15 | LSMinimumSystemVersion 16 | 13.0 17 | NSHumanReadableCopyright 18 | Copyright © 2024 Auto Screenshooter. All rights reserved. 19 | NSMainStoryboardFile 20 | Main 21 | NSPrincipalClass 22 | NSApplication 23 | LSApplicationCategoryType 24 | public.app-category.utilities 25 | NSScreenCaptureDescription 26 | Auto Screenshooter needs screen recording permission to capture screenshots. 27 | NSAppleEventsUsageDescription 28 | Auto Screenshooter needs access to control other applications for automated keypress functionality. 29 | LSUIElement 30 | 31 | NSRequiresAquaSystemAppearance 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /Sources/AutoScreenshooter/SelectionOverlay.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import AppKit 3 | 4 | final class SelectionOverlay { 5 | private var windows: [NSWindow] = [] 6 | private var escapeMonitor: Any? 7 | func begin(completion: @escaping (CGRect?) -> Void) { 8 | for screen in NSScreen.screens { 9 | let win = NSWindow( 10 | contentRect: screen.frame, 11 | styleMask: .borderless, 12 | backing: .buffered, 13 | defer: false, 14 | screen: screen 15 | ) 16 | win.level = .statusBar 17 | win.isOpaque = false 18 | win.backgroundColor = .clear 19 | win.ignoresMouseEvents = false 20 | win.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] 21 | 22 | let content = SelectionView(frame: screen.frame) 23 | content.onSelectionComplete = { [weak self] rect in 24 | self?.end() 25 | completion(rect) 26 | } 27 | win.contentView = content 28 | win.makeKeyAndOrderFront(nil) 29 | windows.append(win) 30 | } 31 | 32 | escapeMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in 33 | if event.keyCode == 53 { // ESC 34 | self?.end() 35 | completion(nil) 36 | return nil 37 | } 38 | return event 39 | } 40 | } 41 | func end() { 42 | windows.forEach { $0.orderOut(nil) } 43 | windows.removeAll() 44 | if let monitor = escapeMonitor { 45 | NSEvent.removeMonitor(monitor) 46 | escapeMonitor = nil 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Auto Screenshooter 2 | 3 | A powerful macOS app for automating screenshot capture with advanced features. 4 | 5 | autoscreenshooter 6 | 7 | 8 | ## Features 9 | 10 | - **Automated Screenshot Capture**: Set the number of screenshots to take automatically 11 | - **Timed Intervals**: Configure wait time between screenshots (in milliseconds) 12 | - **Custom Keypress Automation**: 13 | - Click the keypress button to capture any key combination 14 | - Automatically simulate the captured keyboard shortcut between screenshots 15 | - Default: ⌘+] (commonly used for next tab/page) 16 | - **Multiple Capture Modes**: 17 | - Entire Screen 18 | - Specific Window 19 | - Custom Area Selection 20 | - **Hands-Free Operation**: 3-second countdown before capture starts 21 | 22 | ## Requirements 23 | 24 | - macOS 13.0 or later 25 | - Screen Recording permission (will be requested on first launch) 26 | - Accessibility permission for keypress automation (optional, only if using auto-keypress) 27 | 28 | ## Installation 29 | 30 | 1. Download the latest release from the [Releases page](https://github.com/underhubber/macos-auto-screenshooter/releases) 31 | 2. Extract and open the downloaded .app bundle 32 | 33 | ## Building from Source 34 | 35 | 1. Make sure you have Xcode installed 36 | 2. Clone the repository 37 | 3. Build and run: 38 | 39 | ```bash 40 | swift build 41 | swift run 42 | ./make_app.sh # produce .app bundle 43 | ``` 44 | 45 | Or open in Xcode: 46 | ```bash 47 | open Package.swift 48 | ``` 49 | 50 | ## Usage 51 | 52 | 1. Launch Auto Screenshooter 53 | 2. Configure your settings: 54 | - Number of screenshots to take 55 | - Enable/disable automatic keypress (⌘+]) 56 | - Set interval between screenshots 57 | - Choose save location 58 | 3. Select capture mode: 59 | - Click "Select screen" to choose a display or window 60 | - Click "Custom Area" to draw a selection rectangle 61 | 4. The app will show a 3-second countdown 62 | 5. Screenshots will be taken automatically and saved to your specified location 63 | 64 | ## Permissions 65 | 66 | The app requires Screen Recording permission to function. You'll be prompted to grant this permission when you first run the app. To enable it manually: 67 | 68 | 1. Open System Preferences > Security & Privacy 69 | 2. Go to Privacy tab > Screen Recording 70 | 3. Check the box next to Auto Screenshooter 71 | 72 | ## File Naming 73 | 74 | Screenshots are saved with the following format: 75 | `Screenshot [Date] [Time] - [Index].png` 76 | 77 | Example: `Screenshot 2024-01-15 3-45-30 PM - 1.png` 78 | -------------------------------------------------------------------------------- /Sources/AutoScreenshooter/CountdownView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import AppKit 3 | 4 | struct CountdownView: View { 5 | @Binding var isPresented: Bool 6 | @State private var countdown = 3 7 | let onComplete: () -> Void 8 | let onCancel: () -> Void 9 | let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() 10 | @State private var eventMonitor: Any? = nil 11 | 12 | var body: some View { 13 | ZStack { 14 | Color.black.opacity(0.7) 15 | .ignoresSafeArea() 16 | 17 | VStack(spacing: 20) { 18 | Text("Get Ready!") 19 | .font(.largeTitle) 20 | .fontWeight(.bold) 21 | .foregroundColor(.white) 22 | 23 | Text("\(countdown)") 24 | .font(.system(size: 120, weight: .bold)) 25 | .foregroundColor(.white) 26 | .shadow(radius: 10) 27 | .scaleEffect(countdown == 0 ? 1.5 : 1.0) 28 | .animation(.easeOut(duration: 0.3), value: countdown) 29 | 30 | Text("Position your screen for capture") 31 | .font(.title3) 32 | .foregroundColor(.white.opacity(0.8)) 33 | 34 | Button("Cancel Capture", role: .cancel) { 35 | cancelCountdown() 36 | } 37 | .keyboardShortcut(.cancelAction) 38 | .buttonStyle(.borderedProminent) 39 | .padding(.top, 10) 40 | } 41 | .padding(50) 42 | .background( 43 | RoundedRectangle(cornerRadius: 20) 44 | .fill(Color.black.opacity(0.8)) 45 | .shadow(radius: 20) 46 | ) 47 | } 48 | .onReceive(timer) { _ in 49 | if countdown > 0 { 50 | countdown -= 1 51 | } else { 52 | timer.upstream.connect().cancel() 53 | isPresented = false 54 | onComplete() 55 | } 56 | } 57 | .onAppear { 58 | eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in 59 | if event.keyCode == 53 { // ESC 60 | cancelCountdown() 61 | return nil 62 | } 63 | return event 64 | } 65 | } 66 | .onDisappear { 67 | if let monitor = eventMonitor { 68 | NSEvent.removeMonitor(monitor) 69 | } 70 | } 71 | } 72 | 73 | private func cancelCountdown() { 74 | timer.upstream.connect().cancel() 75 | isPresented = false 76 | onCancel() 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/AutoScreenshooter/CustomAreaSelector.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import AppKit 3 | 4 | struct CustomAreaSelector: NSViewRepresentable { 5 | @Binding var selectedArea: CGRect? 6 | @Binding var isSelecting: Bool 7 | 8 | func makeNSView(context: Context) -> NSView { 9 | let view = SelectionView() 10 | view.onSelectionComplete = { rect in 11 | selectedArea = rect 12 | isSelecting = false 13 | } 14 | return view 15 | } 16 | 17 | func updateNSView(_ nsView: NSView, context: Context) {} 18 | } 19 | 20 | class SelectionView: NSView { 21 | var onSelectionComplete: ((CGRect) -> Void)? 22 | private var startPoint: NSPoint? 23 | private var currentPoint: NSPoint? 24 | private var selectionLayer: CALayer? 25 | 26 | override init(frame frameRect: NSRect) { 27 | super.init(frame: frameRect) 28 | setup() 29 | } 30 | 31 | required init?(coder: NSCoder) { 32 | super.init(coder: coder) 33 | setup() 34 | } 35 | 36 | private func setup() { 37 | wantsLayer = true 38 | layer?.backgroundColor = NSColor.black.withAlphaComponent(0.3).cgColor 39 | 40 | let layer = CALayer() 41 | layer.borderColor = NSColor.systemBlue.cgColor 42 | layer.borderWidth = 2 43 | layer.backgroundColor = NSColor.systemBlue.withAlphaComponent(0.1).cgColor 44 | self.layer?.addSublayer(layer) 45 | selectionLayer = layer 46 | 47 | let trackingArea = NSTrackingArea( 48 | rect: bounds, 49 | options: [.activeInKeyWindow, .mouseMoved], 50 | owner: self, 51 | userInfo: nil 52 | ) 53 | addTrackingArea(trackingArea) 54 | } 55 | 56 | override func mouseDown(with event: NSEvent) { 57 | let point = convert(event.locationInWindow, from: nil) 58 | startPoint = point 59 | currentPoint = point 60 | updateSelection() 61 | } 62 | 63 | override func mouseDragged(with event: NSEvent) { 64 | currentPoint = convert(event.locationInWindow, from: nil) 65 | updateSelection() 66 | } 67 | 68 | override func mouseUp(with event: NSEvent) { 69 | if let start = startPoint, let current = currentPoint { 70 | let rect = CGRect( 71 | x: min(start.x, current.x), 72 | y: min(start.y, current.y), 73 | width: abs(current.x - start.x), 74 | height: abs(current.y - start.y) 75 | ) 76 | 77 | let screenRect = window?.convertToScreen(convert(rect, to: nil)) ?? rect 78 | onSelectionComplete?(screenRect) 79 | } 80 | } 81 | 82 | private func updateSelection() { 83 | guard let start = startPoint, let current = currentPoint else { return } 84 | 85 | let rect = CGRect( 86 | x: min(start.x, current.x), 87 | y: min(start.y, current.y), 88 | width: abs(current.x - start.x), 89 | height: abs(current.y - start.y) 90 | ) 91 | 92 | CATransaction.begin() 93 | CATransaction.setDisableActions(true) 94 | selectionLayer?.frame = rect 95 | CATransaction.commit() 96 | } 97 | } 98 | 99 | struct CustomAreaOverlay: View { 100 | @Binding var isPresented: Bool 101 | @EnvironmentObject var screenshotManager: ScreenshotManager 102 | 103 | var body: some View { 104 | ZStack { 105 | Color.black.opacity(0.5) 106 | .ignoresSafeArea() 107 | 108 | VStack { 109 | HStack { 110 | Text("Click & drag to select.") 111 | .font(.title) 112 | .fontWeight(.bold) 113 | .foregroundColor(.white) 114 | 115 | Spacer() 116 | 117 | Text("Press Esc to cancel.") 118 | .font(.title2) 119 | .foregroundColor(.white.opacity(0.8)) 120 | } 121 | .padding() 122 | .background(Color.black.opacity(0.7)) 123 | 124 | Spacer() 125 | } 126 | 127 | CustomAreaSelector( 128 | selectedArea: $screenshotManager.customArea, 129 | isSelecting: $isPresented 130 | ) 131 | } 132 | .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in 133 | NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in 134 | if event.keyCode == 53 { // ESC 135 | isPresented = false 136 | return nil 137 | } 138 | return event 139 | } 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /Sources/AutoScreenshooter/KeypressCaptureView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import AppKit 3 | 4 | struct KeypressCaptureView: View { 5 | @Binding var isPresented: Bool 6 | @Binding var capturedKeypress: KeypressInfo? 7 | @State private var isCapturing = false 8 | @State private var currentKeypress: KeypressInfo? 9 | 10 | var body: some View { 11 | VStack(spacing: 20) { 12 | Text("Keypress Capture") 13 | .font(.title2) 14 | .fontWeight(.bold) 15 | 16 | if isCapturing { 17 | VStack(spacing: 10) { 18 | Image(systemName: "keyboard") 19 | .font(.system(size: 50)) 20 | .foregroundColor(.blue) 21 | 22 | Text("Press any key combination...") 23 | .font(.headline) 24 | .foregroundColor(.secondary) 25 | } 26 | .frame(width: 300, height: 150) 27 | .background(Color.blue.opacity(0.1)) 28 | .cornerRadius(10) 29 | } else if let keypress = currentKeypress { 30 | VStack(spacing: 10) { 31 | HStack(spacing: 5) { 32 | ForEach(keypress.displayComponents, id: \.self) { component in 33 | Text(component) 34 | .font(.title2) 35 | .fontWeight(.medium) 36 | } 37 | } 38 | .padding(.horizontal, 20) 39 | .padding(.vertical, 10) 40 | .background(Color.gray.opacity(0.2)) 41 | .cornerRadius(8) 42 | 43 | Text("Key Code: \(keypress.keyCode)") 44 | .font(.caption) 45 | .foregroundColor(.secondary) 46 | } 47 | .frame(width: 300, height: 150) 48 | } else { 49 | VStack { 50 | Image(systemName: "keyboard") 51 | .font(.system(size: 50)) 52 | .foregroundColor(.gray) 53 | Text("Click 'Start Capture' to record a keypress") 54 | .foregroundColor(.secondary) 55 | } 56 | .frame(width: 300, height: 150) 57 | } 58 | 59 | HStack(spacing: 20) { 60 | Button("Cancel") { 61 | isPresented = false 62 | } 63 | .keyboardShortcut(.escape) 64 | 65 | if !isCapturing { 66 | Button("Start Capture") { 67 | startCapture() 68 | } 69 | 70 | if currentKeypress != nil { 71 | Button("Use This Keypress") { 72 | capturedKeypress = currentKeypress 73 | isPresented = false 74 | } 75 | .buttonStyle(.borderedProminent) 76 | } 77 | } else { 78 | Button("Stop Capture") { 79 | stopCapture() 80 | } 81 | .buttonStyle(.borderedProminent) 82 | } 83 | } 84 | } 85 | .padding(30) 86 | .frame(width: 400) 87 | .background(Color(NSColor.windowBackgroundColor)) 88 | .cornerRadius(10) 89 | .shadow(radius: 10) 90 | } 91 | 92 | func startCapture() { 93 | isCapturing = true 94 | currentKeypress = nil 95 | 96 | NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in 97 | if self.isCapturing { 98 | self.currentKeypress = KeypressInfo( 99 | keyCode: event.keyCode, 100 | modifierFlags: event.modifierFlags, 101 | characters: event.charactersIgnoringModifiers ?? "" 102 | ) 103 | self.stopCapture() 104 | return nil 105 | } 106 | return event 107 | } 108 | } 109 | 110 | func stopCapture() { 111 | isCapturing = false 112 | } 113 | } 114 | 115 | struct KeypressInfo: Equatable { 116 | let keyCode: UInt16 117 | let modifierFlags: NSEvent.ModifierFlags 118 | let characters: String 119 | let customDisplayComponents: [String]? 120 | 121 | init(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags, characters: String, displayComponents: [String]? = nil) { 122 | self.keyCode = keyCode 123 | self.modifierFlags = modifierFlags 124 | self.characters = characters 125 | self.customDisplayComponents = displayComponents 126 | } 127 | 128 | var displayComponents: [String] { 129 | if let customComponents = customDisplayComponents { 130 | return customComponents 131 | } 132 | 133 | var components: [String] = [] 134 | 135 | if modifierFlags.contains(.command) { 136 | components.append("⌘") 137 | } 138 | if modifierFlags.contains(.option) { 139 | components.append("⌥") 140 | } 141 | if modifierFlags.contains(.control) { 142 | components.append("⌃") 143 | } 144 | if modifierFlags.contains(.shift) { 145 | components.append("⇧") 146 | } 147 | 148 | let specialKeyMap: [UInt16: String] = [ 149 | 36: "↩", // Return 150 | 48: "⇥", // Tab 151 | 49: "Space", 152 | 51: "⌫", // Delete 153 | 53: "⎋", // Escape 154 | 123: "←", // Left arrow 155 | 124: "→", // Right arrow 156 | 125: "↓", // Down arrow 157 | 126: "↑" // Up arrow 158 | ] 159 | 160 | if let symbol = specialKeyMap[keyCode] { 161 | components.append(symbol) 162 | } else if !characters.isEmpty { 163 | components.append(characters.uppercased()) 164 | } else { 165 | components.append("Key \(keyCode)") 166 | } 167 | 168 | return components 169 | } 170 | 171 | var displayString: String { 172 | displayComponents.joined(separator: " + ") 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /Sources/AutoScreenshooter/ScreenshotManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import AppKit 4 | import Quartz 5 | import UniformTypeIdentifiers 6 | 7 | enum CaptureMode { 8 | case screen 9 | case window 10 | case customArea 11 | } 12 | 13 | class ScreenshotManager: ObservableObject { 14 | @Published var captureMode: CaptureMode = .screen 15 | @Published var selectedScreen: CGDirectDisplayID? 16 | @Published var selectedWindow: CGWindowID? 17 | @Published var customArea: CGRect? 18 | @Published var isCountingDown = false 19 | @Published var isCapturing = false 20 | @Published var captureProgress = 0 21 | 22 | private var screenshotCount = 20 23 | private var interval = 1000 24 | private var autoKeypress = true 25 | private var keypressInfo: KeypressInfo? 26 | private var savePath: String = "" 27 | 28 | func configure(count: Int, interval: Int, autoKeypress: Bool, keypressInfo: KeypressInfo?, savePath: String) { 29 | self.screenshotCount = count 30 | self.interval = interval 31 | self.autoKeypress = autoKeypress 32 | self.keypressInfo = keypressInfo 33 | self.savePath = savePath 34 | } 35 | 36 | func startCountdown() { 37 | NSApp.keyWindow?.makeFirstResponder(nil) 38 | DispatchQueue.main.async { 39 | self.isCountingDown = true 40 | } 41 | } 42 | 43 | func startCapture() { 44 | isCapturing = true 45 | isCountingDown = false 46 | captureProgress = 0 47 | 48 | createSaveDirectory() 49 | captureScreenshots() 50 | } 51 | 52 | private func createSaveDirectory() { 53 | let fileManager = FileManager.default 54 | if !fileManager.fileExists(atPath: savePath) { 55 | try? fileManager.createDirectory(atPath: savePath, withIntermediateDirectories: true) 56 | } 57 | } 58 | 59 | private func captureScreenshots() { 60 | Task { 61 | for i in 0.. 0, rect.height > 0 { 113 | if let image = CGDisplayCreateImage(displayID, rect: rect) { 114 | saveImage(image, to: filePath) 115 | } 116 | } 117 | } 118 | } 119 | 120 | private func saveImage(_ cgImage: CGImage, to path: String) { 121 | let url = URL(fileURLWithPath: path) 122 | let destination = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil) 123 | 124 | if let destination = destination { 125 | CGImageDestinationAddImage(destination, cgImage, nil) 126 | CGImageDestinationFinalize(destination) 127 | } 128 | } 129 | 130 | private func pressKey() { 131 | guard let keypress = keypressInfo else { return } 132 | 133 | let source = CGEventSource(stateID: .hidSystemState) 134 | var flags = CGEventFlags(rawValue: 0) 135 | 136 | let flagMapping: [(NSEvent.ModifierFlags, CGEventFlags)] = [ 137 | (.command, .maskCommand), 138 | (.option, .maskAlternate), 139 | (.control, .maskControl), 140 | (.shift, .maskShift) 141 | ] 142 | 143 | for (modifier, cgFlag) in flagMapping { 144 | if keypress.modifierFlags.contains(modifier) { 145 | flags.insert(cgFlag) 146 | } 147 | } 148 | 149 | if let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keypress.keyCode, keyDown: true) { 150 | keyDown.flags = flags 151 | keyDown.post(tap: .cghidEventTap) 152 | } 153 | 154 | if let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keypress.keyCode, keyDown: false) { 155 | keyUp.flags = flags 156 | keyUp.post(tap: .cghidEventTap) 157 | } 158 | } 159 | 160 | private func activateWindow(_ windowID: CGWindowID) { 161 | if let windowList = CGWindowListCopyWindowInfo([.optionIncludingWindow], windowID) as? [[String: Any]], 162 | let windowInfo = windowList.first, 163 | let ownerPID = windowInfo[kCGWindowOwnerPID as String] as? Int32 { 164 | 165 | if let app = NSRunningApplication(processIdentifier: ownerPID) { 166 | app.activate(options: .activateIgnoringOtherApps) 167 | usleep(100000) // 100ms 168 | } 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /Sources/AutoScreenshooter/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import AppKit 3 | import Foundation 4 | 5 | struct ContentView: View { 6 | @EnvironmentObject var screenshotManager: ScreenshotManager 7 | @State private var screenshotCount = 10 8 | @State private var waitInterval = 500 9 | @State private var autoKeypress = true 10 | @State private var savePath = NSHomeDirectory() + "/Pictures/AutoScreenshooter" 11 | @State private var showingWindowPicker = false 12 | @State private var showingAreaSelector = false 13 | @State private var isCapturingKeypress = false 14 | @State private var selectionOverlay: SelectionOverlay? 15 | @State private var capturedKeypress: KeypressInfo? = KeypressInfo( 16 | keyCode: 0x31, // space key 17 | modifierFlags: [], 18 | characters: " ", 19 | displayComponents: ["Space"] 20 | ) 21 | 22 | var body: some View { 23 | ZStack { 24 | VStack(spacing: 0) { 25 | VStack(spacing: 30) { 26 | // Header 27 | HStack { 28 | Spacer() 29 | 30 | HStack(spacing: 15) { 31 | if let url = Bundle.module.url(forResource: "autoshooter", withExtension: "png"), 32 | let nsImg = NSImage(contentsOf: url) { 33 | Image(nsImage: nsImg) 34 | .resizable() 35 | .aspectRatio(contentMode: .fit) 36 | .frame(width: 40, height: 40) 37 | .clipShape(RoundedRectangle(cornerRadius: 8)) 38 | } 39 | 40 | Text("Auto Screenshooter") 41 | .font(.largeTitle) 42 | .fontWeight(.bold) 43 | } 44 | 45 | Spacer() 46 | } 47 | .padding(.top, 20) 48 | .padding(.bottom, 10) 49 | HStack { 50 | HStack(spacing: 12) { 51 | Text("Take") 52 | .font(.title2) 53 | 54 | TextField("", value: $screenshotCount, format: .number) 55 | .textFieldStyle(.roundedBorder) 56 | .frame(width: 80) 57 | .multilineTextAlignment(.center) 58 | .onChange(of: screenshotCount) { newValue in 59 | if newValue <= 0 { 60 | screenshotCount = 1 61 | } 62 | } 63 | 64 | Text("screenshots") 65 | .font(.title2) 66 | } 67 | 68 | Spacer() 69 | } 70 | HStack(spacing: 8) { 71 | HStack(spacing: 8) { 72 | Text("Automatically press") 73 | 74 | Button(action: { 75 | if !isCapturingKeypress { 76 | startKeypressCapture() 77 | } 78 | }) { 79 | HStack(spacing: 4) { 80 | if isCapturingKeypress { 81 | Text("Capturing") 82 | .foregroundColor(.blue) 83 | } else if let keypress = capturedKeypress { 84 | ForEach(keypress.displayComponents, id: \.self) { component in 85 | Text(component) 86 | .font(.system(size: 16, weight: .regular)) 87 | 88 | } 89 | } else { 90 | Text("") 91 | } 92 | } 93 | .padding(.horizontal, 8) 94 | .padding(.vertical, 4) 95 | } 96 | .buttonStyle(.bordered) 97 | .background(isCapturingKeypress ? Color.blue.opacity(0.1) : Color.clear) 98 | .cornerRadius(4) 99 | .disabled(!autoKeypress) 100 | .opacity(autoKeypress ? 1 : 0.5) 101 | 102 | Text("after each screenshot") 103 | .opacity(autoKeypress ? 1 : 0.5) 104 | } 105 | 106 | Spacer() 107 | 108 | HStack(spacing: 6) { 109 | Toggle("", isOn: $autoKeypress) 110 | .labelsHidden() 111 | .toggleStyle(SwitchToggleStyle()) 112 | 113 | Image(systemName: "info.circle") 114 | .foregroundColor(.secondary) 115 | .help("Click the button to capture a custom keypress") 116 | } 117 | } 118 | HStack { 119 | HStack(spacing: 8) { 120 | Text("Wait") 121 | 122 | TextField("", value: $waitInterval, format: .number) 123 | .textFieldStyle(.roundedBorder) 124 | .frame(width: 80) 125 | .multilineTextAlignment(.center) 126 | .onChange(of: waitInterval) { newValue in 127 | if newValue <= 0 { 128 | waitInterval = 500 129 | } 130 | } 131 | 132 | Text("ms after each screenshot") 133 | } 134 | 135 | Spacer() 136 | } 137 | HStack(spacing: 8) { 138 | Text("Save captured images to") 139 | 140 | Spacer() 141 | 142 | HStack(spacing: 8) { 143 | TextField("", text: $savePath) 144 | .textFieldStyle(.roundedBorder) 145 | .frame(minWidth: 200) 146 | .disabled(true) 147 | 148 | Button("Browse...") { 149 | selectSaveDirectory() 150 | } 151 | .buttonStyle(.bordered) 152 | } 153 | } 154 | HStack(spacing: 20) { 155 | Button(action: selectScreen) { 156 | Label("Select screen", systemImage: "rectangle.on.rectangle") 157 | .frame(maxWidth: .infinity) 158 | .padding(.vertical, 10) 159 | } 160 | .buttonStyle(.borderedProminent) 161 | .controlSize(.large) 162 | .shadow(color: Color.black.opacity(0.1), radius: 2, x: 0, y: 1) 163 | 164 | Button(action: selectCustomArea) { 165 | Label("Custom Area", systemImage: "crop") 166 | .frame(maxWidth: .infinity) 167 | .padding(.vertical, 10) 168 | } 169 | .buttonStyle(.bordered) 170 | .controlSize(.large) 171 | .shadow(color: Color.black.opacity(0.1), radius: 2, x: 0, y: 1) 172 | } 173 | .padding(.top, 10) 174 | } 175 | .padding(40) 176 | 177 | Spacer() 178 | } 179 | .background(Color(NSColor.controlBackgroundColor)) 180 | 181 | if showingAreaSelector { 182 | CustomAreaOverlay(isPresented: $showingAreaSelector) 183 | } 184 | 185 | if screenshotManager.isCountingDown { 186 | CountdownView( 187 | isPresented: $screenshotManager.isCountingDown, 188 | onComplete: { 189 | defocus() 190 | screenshotManager.startCapture() 191 | }, 192 | onCancel: { screenshotManager.isCountingDown = false } 193 | ) 194 | 195 | } 196 | 197 | } 198 | .frame(width: 600) 199 | .sheet(isPresented: $showingWindowPicker) { 200 | WindowPickerView(isPresented: $showingWindowPicker) 201 | } 202 | .onChange(of: showingAreaSelector) { newValue in 203 | if !newValue, screenshotManager.customArea != nil { 204 | screenshotManager.startCountdown() 205 | } 206 | } 207 | } 208 | 209 | func startKeypressCapture() { 210 | isCapturingKeypress = true 211 | 212 | // Create a local monitor to capture key presses 213 | NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in 214 | if self.isCapturingKeypress { 215 | self.capturedKeypress = KeypressInfo( 216 | keyCode: event.keyCode, 217 | modifierFlags: event.modifierFlags, 218 | characters: event.charactersIgnoringModifiers ?? "" 219 | ) 220 | self.isCapturingKeypress = false 221 | return nil 222 | } 223 | return event 224 | } 225 | } 226 | 227 | func selectScreen() { 228 | configureScreenshotManager() 229 | showingWindowPicker = true 230 | } 231 | 232 | func selectCustomArea() { 233 | configureScreenshotManager() 234 | screenshotManager.captureMode = .customArea 235 | selectionOverlay = SelectionOverlay() 236 | selectionOverlay?.begin { rect in 237 | screenshotManager.customArea = rect 238 | selectionOverlay = nil 239 | if rect != nil { 240 | screenshotManager.startCountdown() 241 | } 242 | } 243 | } 244 | 245 | private func configureScreenshotManager() { 246 | if screenshotCount <= 0 { screenshotCount = 10 } 247 | if waitInterval <= 0 { waitInterval = 500 } 248 | 249 | screenshotManager.configure( 250 | count: screenshotCount, 251 | interval: waitInterval, 252 | autoKeypress: autoKeypress, 253 | keypressInfo: capturedKeypress, 254 | savePath: savePath 255 | ) 256 | } 257 | 258 | private func defocus() { 259 | NSApp.keyWindow?.makeFirstResponder(nil) 260 | } 261 | 262 | 263 | 264 | func selectSaveDirectory() { 265 | let panel = NSOpenPanel() 266 | panel.canChooseFiles = false 267 | panel.canChooseDirectories = true 268 | panel.allowsMultipleSelection = false 269 | panel.canCreateDirectories = true 270 | panel.prompt = "Select Save Location" 271 | 272 | if panel.runModal() == .OK { 273 | if let url = panel.url { 274 | savePath = url.path 275 | } 276 | } 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /Sources/AutoScreenshooter/WindowPickerView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import AppKit 3 | 4 | struct WindowPickerView: View { 5 | @Binding var isPresented: Bool 6 | @State private var selectedTab = 0 7 | @State private var windows: [WindowInfo] = [] 8 | @State private var screens: [ScreenInfo] = [] 9 | @State private var windowThumbnails: [CGWindowID: NSImage] = [:] 10 | @State private var screenThumbnails: [CGDirectDisplayID: NSImage] = [:] 11 | @EnvironmentObject var screenshotManager: ScreenshotManager 12 | 13 | var body: some View { 14 | VStack(spacing: 0) { 15 | HStack { 16 | Spacer() 17 | 18 | Picker("", selection: $selectedTab) { 19 | Text("Screens").tag(0) 20 | Text("Windows").tag(1) 21 | } 22 | .pickerStyle(SegmentedPickerStyle()) 23 | .frame(width: 300) 24 | 25 | Spacer() 26 | 27 | Button(action: { isPresented = false }) { 28 | Image(systemName: "xmark") 29 | .foregroundColor(.secondary) 30 | } 31 | .buttonStyle(.plain) 32 | } 33 | .padding() 34 | .background(Color(NSColor.controlBackgroundColor)) 35 | 36 | ScrollView { 37 | LazyVGrid(columns: [GridItem(.adaptive(minimum: 250))], spacing: 20) { 38 | if selectedTab == 0 { 39 | ForEach(screens) { screen in 40 | ScreenThumbnail( 41 | screen: screen, 42 | thumbnail: screenThumbnails[screen.displayID], 43 | isSelected: screenshotManager.selectedScreen == screen.displayID 44 | ) { 45 | screenshotManager.selectedScreen = screen.displayID 46 | screenshotManager.selectedWindow = nil 47 | screenshotManager.captureMode = .screen 48 | } 49 | } 50 | } else { 51 | ForEach(windows) { window in 52 | WindowThumbnail( 53 | window: window, 54 | thumbnail: windowThumbnails[window.windowID], 55 | isSelected: screenshotManager.selectedWindow == window.windowID, 56 | action: { 57 | screenshotManager.selectedWindow = window.windowID 58 | screenshotManager.selectedScreen = nil 59 | screenshotManager.captureMode = .window 60 | } 61 | ) 62 | } 63 | } 64 | } 65 | .padding() 66 | } 67 | 68 | Button("Start Capture") { 69 | startCapture() 70 | } 71 | .controlSize(.large) 72 | .buttonStyle(.borderedProminent) 73 | .padding() 74 | .disabled(!isSelectionValid()) 75 | } 76 | .frame(width: 800, height: 600) 77 | .background(Color(NSColor.windowBackgroundColor).opacity(0.95)) 78 | .cornerRadius(12) 79 | .shadow(radius: 20) 80 | .onAppear { 81 | loadWindowsAndScreens() 82 | } 83 | } 84 | 85 | func loadWindowsAndScreens() { 86 | screens = NSScreen.screens.enumerated().map { index, screen in 87 | ScreenInfo( 88 | displayID: screen.displayID, 89 | name: "Display \(index + 1)", 90 | frame: screen.frame 91 | ) 92 | } 93 | 94 | if let windowList = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] { 95 | windows = windowList.compactMap { windowInfo in 96 | guard let windowID = windowInfo[kCGWindowNumber as String] as? CGWindowID, 97 | let ownerName = windowInfo[kCGWindowOwnerName as String] as? String, 98 | let alpha = windowInfo[kCGWindowAlpha as String] as? Double, 99 | let layer = windowInfo[kCGWindowLayer as String] as? Int, 100 | let bounds = windowInfo[kCGWindowBounds as String] as? [String: Double], 101 | let height = bounds["Height"], let width = bounds["Width"], 102 | alpha >= 0.1, 103 | layer <= 25, 104 | height >= 60, 105 | width >= 60 106 | else { return nil } 107 | 108 | let systemAppsToExclude = ["Window Server", "Dock", "SystemUIServer", "Control Center", 109 | "Notification Center", "Screenshot", "ScreenFloat", "coreautha", "loginwindow"] 110 | 111 | if systemAppsToExclude.contains(ownerName) { return nil } 112 | 113 | let windowName = windowInfo[kCGWindowName as String] as? String ?? "Untitled" 114 | 115 | return WindowInfo( 116 | windowID: windowID, 117 | ownerName: ownerName, 118 | windowName: windowName, 119 | bounds: CGRect(x: bounds["X"] ?? 0, y: bounds["Y"] ?? 0, 120 | width: width, height: height) 121 | ) 122 | } 123 | } 124 | 125 | // Generate thumbnails after loading windows and screens 126 | Task { 127 | await generateThumbnails() 128 | } 129 | } 130 | 131 | private func generateThumbnails() async { 132 | // Generate window thumbnails 133 | for window in windows { 134 | if let image = generateWindowThumbnail(for: window.windowID) { 135 | await MainActor.run { 136 | windowThumbnails[window.windowID] = image 137 | } 138 | } 139 | } 140 | 141 | // Generate screen thumbnails 142 | for screen in screens { 143 | if let image = generateScreenThumbnail(for: screen.displayID) { 144 | await MainActor.run { 145 | screenThumbnails[screen.displayID] = image 146 | } 147 | } 148 | } 149 | } 150 | 151 | private func generateWindowThumbnail(for windowID: CGWindowID) -> NSImage? { 152 | let options: CGWindowImageOption = [.boundsIgnoreFraming, .nominalResolution] 153 | guard let cgImage = CGWindowListCreateImage( 154 | .null, 155 | .optionIncludingWindow, 156 | windowID, 157 | options 158 | ) else { return nil } 159 | 160 | return NSImage(cgImage: cgImage, size: .zero) 161 | } 162 | 163 | private func generateScreenThumbnail(for displayID: CGDirectDisplayID) -> NSImage? { 164 | guard let cgImage = CGDisplayCreateImage(displayID) else { return nil } 165 | return NSImage(cgImage: cgImage, size: .zero) 166 | } 167 | 168 | private func isSelectionValid() -> Bool { 169 | if selectedTab == 0 { 170 | return screenshotManager.selectedScreen != nil 171 | } else { 172 | return screenshotManager.selectedWindow != nil 173 | } 174 | } 175 | 176 | func startCapture() { 177 | guard isSelectionValid() else { return } 178 | isPresented = false 179 | 180 | // Ensure we have a valid selection 181 | if selectedTab == 0 { 182 | if screenshotManager.selectedScreen == nil { 183 | screenshotManager.selectedScreen = screens.first?.displayID ?? CGMainDisplayID() 184 | } 185 | screenshotManager.captureMode = .screen 186 | } else { 187 | screenshotManager.captureMode = .window 188 | } 189 | 190 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 191 | screenshotManager.startCountdown() 192 | } 193 | } 194 | } 195 | 196 | struct ScreenThumbnail: View { 197 | let screen: ScreenInfo 198 | let thumbnail: NSImage? 199 | let isSelected: Bool 200 | let action: () -> Void 201 | 202 | var body: some View { 203 | Button(action: action) { 204 | VStack { 205 | if let thumbnail = thumbnail { 206 | Image(nsImage: thumbnail) 207 | .resizable() 208 | .aspectRatio(contentMode: .fit) 209 | .frame(height: 150) 210 | .clipped() 211 | } else { 212 | Rectangle() 213 | .fill(Color.gray.opacity(0.3)) 214 | .frame(height: 150) 215 | .overlay( 216 | Image(systemName: "display") 217 | .font(.system(size: 50)) 218 | .foregroundColor(.gray) 219 | ) 220 | } 221 | 222 | Text(screen.name) 223 | .font(.caption) 224 | } 225 | .padding() 226 | .background(Color(NSColor.controlBackgroundColor)) 227 | .cornerRadius(8) 228 | .overlay( 229 | Group { 230 | if isSelected { 231 | RoundedRectangle(cornerRadius: 8) 232 | .stroke(Color.accentColor, lineWidth: 3) 233 | .overlay( 234 | Image(systemName: "checkmark.circle.fill") 235 | .foregroundColor(.accentColor) 236 | .background(Color(NSColor.windowBackgroundColor)) 237 | .clipShape(Circle()) 238 | .offset(x: -8, y: 8), 239 | alignment: .topTrailing 240 | ) 241 | } 242 | } 243 | ) 244 | } 245 | .buttonStyle(.plain) 246 | } 247 | } 248 | 249 | struct WindowThumbnail: View { 250 | let window: WindowInfo 251 | let thumbnail: NSImage? 252 | let isSelected: Bool 253 | let action: () -> Void 254 | 255 | var body: some View { 256 | Button(action: action) { 257 | VStack { 258 | if let thumbnail = thumbnail { 259 | Image(nsImage: thumbnail) 260 | .resizable() 261 | .aspectRatio(contentMode: .fit) 262 | .frame(height: 150) 263 | .clipped() 264 | } else { 265 | Rectangle() 266 | .fill(Color.gray.opacity(0.3)) 267 | .frame(height: 150) 268 | .overlay( 269 | VStack { 270 | Image(systemName: "macwindow") 271 | .font(.system(size: 40)) 272 | .foregroundColor(.gray) 273 | Text(window.ownerName) 274 | .font(.caption2) 275 | .foregroundColor(.secondary) 276 | } 277 | ) 278 | } 279 | 280 | VStack(alignment: .leading, spacing: 2) { 281 | Text(window.windowName) 282 | .font(.caption) 283 | .fontWeight(.semibold) 284 | .lineLimit(1) 285 | 286 | Text(window.ownerName) 287 | .font(.caption2) 288 | .foregroundColor(.secondary) 289 | .lineLimit(1) 290 | } 291 | .frame(maxWidth: .infinity, alignment: .leading) 292 | } 293 | .padding() 294 | .background(Color(NSColor.controlBackgroundColor)) 295 | .cornerRadius(8) 296 | .overlay( 297 | Group { 298 | if isSelected { 299 | RoundedRectangle(cornerRadius: 8) 300 | .stroke(Color.accentColor, lineWidth: 3) 301 | .overlay( 302 | Image(systemName: "checkmark.circle.fill") 303 | .foregroundColor(.accentColor) 304 | .background(Color(NSColor.windowBackgroundColor)) 305 | .clipShape(Circle()) 306 | .offset(x: -8, y: 8), 307 | alignment: .topTrailing 308 | ) 309 | } 310 | } 311 | ) 312 | } 313 | .buttonStyle(.plain) 314 | .help("\(window.ownerName): \(window.windowName)") 315 | } 316 | } 317 | 318 | struct ScreenInfo: Identifiable { 319 | let id = UUID() 320 | let displayID: CGDirectDisplayID 321 | let name: String 322 | let frame: CGRect 323 | } 324 | 325 | struct WindowInfo: Identifiable { 326 | let id = UUID() 327 | let windowID: CGWindowID 328 | let ownerName: String 329 | let windowName: String 330 | let bounds: CGRect 331 | } 332 | 333 | extension NSScreen { 334 | var displayID: CGDirectDisplayID { 335 | let key = NSDeviceDescriptionKey("NSScreenNumber") 336 | return deviceDescription[key] as? CGDirectDisplayID ?? 0 337 | } 338 | } 339 | --------------------------------------------------------------------------------