├── shell ├── polaris └── polaris.swift ├── assets └── banner.png ├── Polaris ├── Assets.xcassets │ ├── Contents.json │ ├── AccentColor.colorset │ │ └── Contents.json │ └── AppIcon.appiconset │ │ └── Contents.json ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Polaris.entitlements ├── Views │ ├── SettingsView.swift │ ├── AppMenu.swift │ └── GeneralSettings.swift ├── KeyLogging │ ├── KeylogManager.swift │ ├── MouseTracker.swift │ ├── CallBackFunctions.swift │ └── Keylogger.swift ├── ContentView.swift ├── PolarisApp.swift └── ScreenCapture │ ├── AudioPlayer.swift │ ├── CaptureView.swift │ ├── PowerMeter.swift │ ├── ScreenCaptureEngine.swift │ └── ScreenRecorder.swift ├── Polaris.xcodeproj ├── project.xcworkspace │ └── contents.xcworkspacedata ├── xcuserdata │ └── cyril.xcuserdatad │ │ ├── xcdebugger │ │ └── Breakpoints_v2.xcbkptlist │ │ └── xcschemes │ │ └── xcschememanagement.plist └── project.pbxproj ├── README.md └── LICENSE.md /shell/polaris: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cyrilzakka/Polaris/HEAD/shell/polaris -------------------------------------------------------------------------------- /assets/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cyrilzakka/Polaris/HEAD/assets/banner.png -------------------------------------------------------------------------------- /Polaris/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Polaris/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Polaris.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Polaris/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Polaris.xcodeproj/xcuserdata/cyril.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /Polaris/Polaris.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Polaris.xcodeproj/xcuserdata/cyril.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | Polaris.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Polaris/Views/SettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsView.swift 3 | // Polaris 4 | // 5 | // Created by Cyril Zakka on 10/25/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SettingsView: View { 11 | var body: some View { 12 | TabView { 13 | Tab("General", systemImage: "gear") { 14 | GeneralSettings() 15 | } 16 | } 17 | // .scenePadding() 18 | .frame(maxWidth: 450, maxHeight: .infinity) 19 | } 20 | } 21 | 22 | #Preview { 23 | SettingsView() 24 | } 25 | -------------------------------------------------------------------------------- /Polaris/KeyLogging/KeylogManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeylogManager.swift 3 | // Polaris 4 | // 5 | // Created by Cyril Zakka on 10/25/24. 6 | // 7 | 8 | import SwiftUI 9 | import os 10 | 11 | struct KeylogManager { 12 | 13 | let logger = Logger(subsystem: "com.cyrilzakka.Polaris.Keylogger", category: "Keylogger") 14 | 15 | static let shared = Keylogger() 16 | 17 | init () { } 18 | 19 | func start() { 20 | logger.info("Starting Keylogger") 21 | KeylogManager.shared.start() 22 | } 23 | 24 | func stop() { 25 | logger.info("Stopped keylogger") 26 | KeylogManager.shared.stop() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Polaris/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // Polaris 4 | // 5 | // Created by Cyril Zakka on 10/25/24. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | 11 | 12 | struct ContentView: View { 13 | @StateObject private var mouseManager = MouseLocationManager() 14 | 15 | var body: some View { 16 | VStack(spacing: 20) { 17 | Text("Global Mouse Position:") 18 | Text(String(format: "X: %.1f, Y: %.1f", 19 | mouseManager.mouseLocation.x, 20 | mouseManager.mouseLocation.y)) 21 | } 22 | .padding() 23 | .frame(width: 300, height: 200) 24 | } 25 | } 26 | 27 | #Preview { 28 | ContentView() 29 | } 30 | -------------------------------------------------------------------------------- /Polaris/PolarisApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PolarisApp.swift 3 | // Polaris 4 | // 5 | // Created by Cyril Zakka on 10/25/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct PolarisApp: App { 12 | 13 | @StateObject var screenRecorder = ScreenRecorder() 14 | @StateObject var mouseRecorder = MouseLocationManager() 15 | 16 | var body: some Scene { 17 | // WindowGroup{ 18 | // ContentView() 19 | // .environmentObject(screenRecorder) 20 | // } 21 | MenuBarExtra("Polaris", systemImage: "macwindow.and.cursorarrow") { 22 | AppMenu() 23 | .environmentObject(screenRecorder) 24 | .environmentObject(mouseRecorder) 25 | } 26 | 27 | Settings { 28 | SettingsView() 29 | .environmentObject(screenRecorder) 30 | } 31 | .windowResizability(.contentSize) 32 | .restorationBehavior(.disabled) 33 | 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Polaris/ScreenCapture/AudioPlayer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AudioPlayer.swift 3 | // Polaris 4 | // 5 | // Created by Cyril Zakka on 10/25/24. 6 | // 7 | 8 | import Foundation 9 | import AVFoundation 10 | 11 | class AudioPlayer: ObservableObject { 12 | 13 | let audioPlayer: AVAudioPlayer 14 | 15 | @Published var isPlaying = false 16 | 17 | init() { 18 | guard let url = Bundle.main.url(forResource: "Synth", withExtension: "aif") else { 19 | fatalError("Couldn't find Synth.aif in the app bundle.") 20 | } 21 | audioPlayer = try! AVAudioPlayer(contentsOf: url, fileTypeHint: AVFileType.aiff.rawValue) 22 | audioPlayer.numberOfLoops = -1 // Loop indefinitely. 23 | audioPlayer.prepareToPlay() 24 | } 25 | 26 | func play() { 27 | audioPlayer.play() 28 | isPlaying = true 29 | } 30 | 31 | func stop() { 32 | audioPlayer.stop() 33 | isPlaying = false 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Polaris/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "256x256" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "1x", 46 | "size" : "512x512" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "2x", 51 | "size" : "512x512" 52 | } 53 | ], 54 | "info" : { 55 | "author" : "xcode", 56 | "version" : 1 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Polaris/ScreenCapture/CaptureView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CaptureView.swift 3 | // Polaris 4 | // 5 | // Created by Cyril Zakka on 10/25/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CapturePreview: NSViewRepresentable { 11 | 12 | // A layer that renders the video contents. 13 | private let contentLayer = CALayer() 14 | 15 | init() { 16 | contentLayer.contentsGravity = .resizeAspect 17 | } 18 | 19 | func makeNSView(context: Context) -> CaptureVideoPreview { 20 | CaptureVideoPreview(layer: contentLayer) 21 | } 22 | 23 | // Called by ScreenRecorder as it receives new video frames. 24 | func updateFrame(_ frame: CapturedFrame) { 25 | contentLayer.contents = frame.surface 26 | } 27 | 28 | // The view isn't updatable. Updates to the layer's content are done in outputFrame(frame:). 29 | func updateNSView(_ nsView: CaptureVideoPreview, context: Context) {} 30 | 31 | class CaptureVideoPreview: NSView { 32 | // Create the preview with the video layer as the backing layer. 33 | init(layer: CALayer) { 34 | super.init(frame: .zero) 35 | // Make this a layer-hosting view. First set the layer, then set wantsLayer to true. 36 | self.layer = layer 37 | wantsLayer = true 38 | } 39 | 40 | required init?(coder: NSCoder) { 41 | fatalError("init(coder:) has not been implemented") 42 | } 43 | } 44 | } 45 | 46 | -------------------------------------------------------------------------------- /Polaris/Views/AppMenu.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppMenu.swift 3 | // Polaris 4 | // 5 | // Created by Cyril Zakka on 10/25/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AppMenu: View { 11 | 12 | @AppStorage("enableMouseTracking") private var enableMouseTracking = false 13 | @AppStorage("enableKeyLogger") private var enableKeyLogger = false 14 | @Environment(\.openSettings) private var openSettings 15 | @EnvironmentObject var screenRecorder: ScreenRecorder 16 | 17 | @State var isUnauthorized = false 18 | @State var showPickerSettingsView = false 19 | 20 | var body: some View { 21 | Button(action: { 22 | screenRecorder.isRecordingStream.toggle() 23 | }, label: { Text(screenRecorder.isRecordingStream ? "Stop Recording" : "Start Recording") }) 24 | .keyboardShortcut("c", modifiers: [.shift, .command]) 25 | .disabled(!screenRecorder.isRunning || isUnauthorized) 26 | .onChange(of: screenRecorder.isRecordingStream) { 27 | if enableKeyLogger { 28 | if screenRecorder.isRecordingStream { KeylogManager.shared.start() } 29 | else { KeylogManager.shared.stop() } 30 | } 31 | } 32 | 33 | Button(action: { 34 | screenRecorder.isPickerActive = true 35 | screenRecorder.presentPicker() 36 | }, label: { Text("Select Capture Area...") }) 37 | .keyboardShortcut("w", modifiers: [.shift, .command]) 38 | .onChange(of: screenRecorder.pickerUpdate) { 39 | if !screenRecorder.isRunning { 40 | Task { await screenRecorder.start() } 41 | } 42 | } 43 | 44 | Divider() 45 | 46 | Button(action: { 47 | screenRecorder.openRecordingFolder() 48 | }, label: { Text("View Recordings") }) 49 | .keyboardShortcut("o", modifiers: [.shift, .command]) 50 | 51 | Divider() 52 | Button(action: { 53 | openSettings() 54 | }, label: { Text("Settings...") }) 55 | .keyboardShortcut(",") 56 | Divider() 57 | Button(action: { 58 | NSApplication.shared.terminate(nil) 59 | }, label: { Text("Quit Polaris") }) 60 | .keyboardShortcut("q") 61 | 62 | .onAppear { 63 | Task { 64 | if await !screenRecorder.canRecord { 65 | isUnauthorized = true 66 | } else { 67 | if !screenRecorder.isRunning { 68 | await screenRecorder.start() 69 | } 70 | 71 | } 72 | } 73 | } 74 | } 75 | } 76 | 77 | #Preview { 78 | AppMenu() 79 | } 80 | -------------------------------------------------------------------------------- /Polaris/KeyLogging/MouseTracker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MouseTracker.swift 3 | // Polaris 4 | // 5 | // Created by Cyril Zakka on 10/25/24. 6 | // 7 | 8 | import SwiftUI 9 | import ApplicationServices 10 | 11 | class MouseLocationManager: ObservableObject { 12 | @Published var mouseLocation: CGPoint = .zero 13 | @Published var isTracking: Bool = false 14 | 15 | private var eventTap: CFMachPort? 16 | private var runLoopSource: CFRunLoopSource? 17 | 18 | init() {} 19 | 20 | func start() { 21 | guard !isTracking else { return } 22 | setupEventTap() 23 | isTracking = true 24 | } 25 | 26 | func stop() { 27 | guard isTracking else { return } 28 | cleanupEventTap() 29 | isTracking = false 30 | } 31 | 32 | func getCurrentMouseLocation() -> CGPoint { 33 | return mouseLocation 34 | } 35 | 36 | private func setupEventTap() { 37 | // Create a callback as a block 38 | let callback: CGEventTapCallBack = { (proxy, type, event, refcon) -> Unmanaged? in 39 | guard type == .mouseMoved else { 40 | return Unmanaged.passRetained(event) 41 | } 42 | let location = event.location 43 | if let refcon = refcon { 44 | let manager = Unmanaged.fromOpaque(refcon).takeUnretainedValue() 45 | DispatchQueue.main.async { 46 | manager.mouseLocation = location 47 | } 48 | } 49 | 50 | return Unmanaged.passRetained(event) 51 | } 52 | 53 | // Create event tap 54 | let eventMask = CGEventMask(1 << CGEventType.mouseMoved.rawValue) 55 | guard let eventTap = CGEvent.tapCreate( 56 | tap: .cghidEventTap, 57 | place: .headInsertEventTap, 58 | options: .defaultTap, 59 | eventsOfInterest: eventMask, 60 | callback: callback, 61 | userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) 62 | ) else { 63 | print("Failed to create event tap") 64 | return 65 | } 66 | 67 | self.eventTap = eventTap 68 | self.runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) 69 | 70 | if let runLoopSource = self.runLoopSource { 71 | CFRunLoopAddSource(CFRunLoopGetMain(), runLoopSource, .commonModes) 72 | CGEvent.tapEnable(tap: eventTap, enable: true) 73 | } 74 | } 75 | 76 | private func cleanupEventTap() { 77 | if let runLoopSource = runLoopSource { 78 | CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource, .commonModes) 79 | self.runLoopSource = nil 80 | } 81 | if let eventTap = eventTap { 82 | CGEvent.tapEnable(tap: eventTap, enable: false) 83 | self.eventTap = nil 84 | } 85 | mouseLocation = .zero 86 | } 87 | 88 | deinit { 89 | stop() 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Polaris Banner 3 |

4 |

Polaris

5 | 6 | ![Static Badge](https://img.shields.io/badge/License-Apache-orange) 7 | [![swift-version](https://img.shields.io/badge/Swift-6.0-brightgreen.svg)](https://github.com/apple/swift) 8 | [![platform](https://img.shields.io/badge/Platform-macOS_15.0-blue.svg)](https://github.com/apple/swift) 9 | 10 | 11 | ### About 12 | Polaris is a powerful, open-source data collection and evaluation framework designed for macOS to facilitate the training and deployment of AI agents. Polaris enables screen capture, keystroke logging, and mouse tracking, all while maintaining user privacy and system performance. 13 | 14 | ### Features 15 | - Key logging using low-level HID APIs to circumvent potential deprecation. 16 | - Screen and audio capture built on the [ScreenCaptureKit framework ](https://developer.apple.com/documentation/screencapturekit/) 17 | - Mouse tracking using `NSEvent.mouseLocation` 18 | - Interactive shell for agent deployment 19 | - All features use official macOS APIs and require explicit user permission before data collection 20 | 21 | Interactive shell preview 22 | 23 | 24 | ### Getting Started 25 | Clone the repository: 26 | ```bash 27 | git clone https://github.com/cyrilzakka/Polaris.git 28 | cd Polaris 29 | ``` 30 | 31 | #### Data Collection 32 | - Xcode 16.0 or later 33 | - macOS 15.0 or later 34 | 1. Open `Polaris.xcodeproj` in Xcode 35 | 2. Select your development team in the project settings 36 | 3. Build and run the project (⌘ + R) 37 | 38 | Click "Start Capture" in the menu bar icon to begin data collection. Keyboard inputs, mouse movements, and screen recordings are saved to your specified destination folder with the following structure: 39 | ``` 40 | destination_folder/ 41 | ├── recorded_output_2024-10-25_20-23-37.mp4 # Screen recording 42 | ├── recorded_output_2024-10-25_20-23-37.txt # Mouse, keyboard, and application events 43 | ├── recorded_output_2024-10-25_20-25-42.mp4 44 | └── recorded_output_2024-10-25_20-25-42.txt 45 | ``` 46 | 47 | #### Interactive Shell 48 | 1. Run the following commands: 49 | ```bash 50 | cd Shell 51 | swiftc polaris.swift -o polaris 52 | chmod +x polaris 53 | ./polaris 54 | ``` 55 | 2. In the interactive shell, run: 56 | ```swift 57 | polaris> shot 58 | Screenshot taken! 59 | 60 | polaris> pos 61 | Mouse position: (862, 596) 62 | 63 | polaris> move 100 0 64 | Moved by (100, 0) 65 | ``` 66 | 67 | ### Liability 68 | This software is provided "as is", without warranty of any kind. By using Polaris and PolarisGUI, you accept full responsibility for the data collected and how it is used. Always obtain proper consent before collecting any data and comply with all applicable privacy laws and regulations. 69 | 70 | ### Acknowledgements 71 | This project was built using https://github.com/NakaokaRei/SwiftAutoGUI and https://github.com/SkrewEverything/Swift-Keylogger 72 | -------------------------------------------------------------------------------- /Polaris/KeyLogging/CallBackFunctions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CallBackFunctions.swift 3 | // Keylogger 4 | // 5 | // Created by Cyril Zakka on 10/25/24. 6 | // 7 | // Adapted and modified from: https://github.com/SkrewEverything/Swift-Keylogger 8 | 9 | import Foundation 10 | import Cocoa 11 | import os 12 | 13 | class CallBackFunctions { 14 | static var CAPSLOCK = false 15 | static var calendar = Calendar.current 16 | 17 | static func getSensitivityThreshold() -> Int { 18 | let defaults = UserDefaults.standard 19 | return defaults.integer(forKey: "scrollSensitivity") 20 | } 21 | 22 | static func getMousePosition() -> String { 23 | let mouseLocation = NSEvent.mouseLocation 24 | let x = Int(round(mouseLocation.x)) 25 | let y = Int(round(mouseLocation.y)) 26 | return "(x:\(x), y:\(y))" 27 | } 28 | 29 | static let Handle_DeviceMatchingCallback: IOHIDDeviceCallback = { context, result, sender, device in 30 | let timeStamp = "\n[" + Date().description(with: Locale.current) + "] Device Connected: \(device)\n" 31 | print(timeStamp) 32 | } 33 | 34 | static let Handle_DeviceRemovalCallback: IOHIDDeviceCallback = { context, result, sender, device in 35 | let timeStamp = "\n[" + Date().description(with: Locale.current) + "] Device Disconnected: \(device)\n" 36 | print(timeStamp) 37 | } 38 | 39 | static let Handle_IOHIDInputValueCallback: IOHIDValueCallback = { context, result, sender, device in 40 | let mySelf = Unmanaged.fromOpaque(context!).takeUnretainedValue() 41 | let elem: IOHIDElement = IOHIDValueGetElement(device) 42 | let usagePage = IOHIDElementGetUsagePage(elem) 43 | let usage = IOHIDElementGetUsage(elem) 44 | let pressed = IOHIDValueGetIntegerValue(device) 45 | 46 | // Handle keyboard input 47 | if usagePage == 0x07 { 48 | if (usage < 4 || usage > 231) { 49 | return 50 | } 51 | 52 | Outside:if pressed == 1 { 53 | if usage == 57 { 54 | CallBackFunctions.CAPSLOCK = !CallBackFunctions.CAPSLOCK 55 | break Outside 56 | } 57 | if usage >= 224 && usage <= 231 { 58 | mySelf.writeToLog(mySelf.keyMap[usage]![0] + "(") 59 | break Outside 60 | } 61 | if CallBackFunctions.CAPSLOCK { 62 | mySelf.writeToLog(mySelf.keyMap[usage]![1]) 63 | } else { 64 | mySelf.writeToLog(mySelf.keyMap[usage]![0]) 65 | } 66 | } else { 67 | if usage >= 224 && usage <= 231 { 68 | mySelf.writeToLog(")") 69 | } 70 | } 71 | return 72 | } 73 | 74 | if usagePage == kHIDPage_Button || usagePage == 0x0D { // Added 0x0D for trackpad gestures 75 | if pressed == 1 { // Button press 76 | let pos = getMousePosition() 77 | switch (usagePage, usage) { 78 | case (UInt32(kHIDPage_Button), 1): 79 | mySelf.writeToLog("\n[MOUSE_LEFT_CLICK \(pos)]") 80 | case (UInt32(kHIDPage_Button), 2), (0x0D, 2): 81 | mySelf.writeToLog("\n[MOUSE_RIGHT_CLICK \(pos)]") 82 | case (UInt32(kHIDPage_Button), 3): 83 | mySelf.writeToLog("\n[MOUSE_MIDDLE_CLICK \(pos)]") 84 | default: 85 | mySelf.writeToLog("\n[MOUSE_BUTTON_\(usage) \(pos)]") 86 | } 87 | } 88 | return 89 | } 90 | 91 | // Handle scroll wheel and trackpad scrolling 92 | if usagePage == kHIDPage_GenericDesktop { 93 | let pos = getMousePosition() 94 | 95 | switch usage { 96 | case UInt32(kHIDUsage_GD_Wheel): 97 | let scrollValue = IOHIDValueGetIntegerValue(device) 98 | if scrollValue > 0 { 99 | mySelf.writeToLog("\n[SCROLL_UP \(pos)]") 100 | } else if scrollValue < 0 { 101 | mySelf.writeToLog("\n[SCROLL_DOWN \(pos)]") 102 | } 103 | case UInt32(kHIDUsage_GD_Y): // Trackpad vertical scroll 104 | let scrollValue = IOHIDValueGetIntegerValue(device) 105 | if abs(scrollValue) > 50 { // Increased threshold for less sensitivity 106 | if scrollValue > 0 { 107 | mySelf.writeToLog("\n[SCROLL_UP \(pos)]") 108 | } else { 109 | mySelf.writeToLog("\n[SCROLL_DOWN \(pos)]") 110 | } 111 | } 112 | case UInt32(kHIDUsage_GD_X): // Trackpad horizontal scroll 113 | let scrollValue = IOHIDValueGetIntegerValue(device) 114 | if abs(scrollValue) > 50 { // Increased threshold for less sensitivity 115 | if scrollValue > 0 { 116 | mySelf.writeToLog("\n[SCROLL_RIGHT \(pos)]") 117 | } else { 118 | mySelf.writeToLog("\n[SCROLL_LEFT \(pos)]") 119 | } 120 | } 121 | default: 122 | break 123 | } 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /Polaris/ScreenCapture/PowerMeter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PowerMeter.swift 3 | // Polaris 4 | // 5 | // Created by Cyril Zakka on 10/25/24. 6 | // 7 | 8 | import Foundation 9 | import AVFoundation 10 | import Accelerate 11 | 12 | struct AudioLevels { 13 | static let zero = AudioLevels(level: 0, peakLevel: 0) 14 | let level: Float 15 | let peakLevel: Float 16 | } 17 | 18 | // The protocol for the object that provides peak and average power levels to adopt. 19 | protocol AudioLevelProvider { 20 | var levels: AudioLevels { get } 21 | } 22 | 23 | class PowerMeter: AudioLevelProvider { 24 | private let kMinLevel: Float = 0.000_000_01 // -160 dB 25 | 26 | private struct PowerLevels { 27 | let average: Float 28 | let peak: Float 29 | } 30 | 31 | private var values = [PowerLevels]() 32 | 33 | private var meterTableAverage = MeterTable() 34 | private var meterTablePeak = MeterTable() 35 | 36 | var levels: AudioLevels { 37 | if values.isEmpty { return AudioLevels(level: 0.0, peakLevel: 0.0) } 38 | return AudioLevels(level: meterTableAverage.valueForPower(values[0].average), 39 | peakLevel: meterTablePeak.valueForPower(values[0].peak)) 40 | } 41 | 42 | func processSilence() { 43 | if values.isEmpty { return } 44 | values = [] 45 | } 46 | 47 | // Calculates the average (rms) and peak level of each channel in the PCM buffer and caches data. 48 | func process(buffer: AVAudioPCMBuffer) { 49 | var powerLevels = [PowerLevels]() 50 | let channelCount = Int(buffer.format.channelCount) 51 | let length = vDSP_Length(buffer.frameLength) 52 | 53 | if let floatData = buffer.floatChannelData { 54 | for channel in 0.., strideFrames: Int, length: vDSP_Length) -> PowerLevels { 82 | var max: Float = 0.0 83 | vDSP_maxv(data, strideFrames, &max, length) 84 | if max < kMinLevel { 85 | max = kMinLevel 86 | } 87 | 88 | var rms: Float = 0.0 89 | vDSP_rmsqv(data, strideFrames, &rms, length) 90 | if rms < kMinLevel { 91 | rms = kMinLevel 92 | } 93 | 94 | return PowerLevels(average: 20.0 * log10(rms), peak: 20.0 * log10(max)) 95 | } 96 | } 97 | 98 | private struct MeterTable { 99 | 100 | // The decibel value of the minimum displayed amplitude. 101 | private let kMinDB: Float = -60.0 102 | 103 | // The table needs to be large enough so that there are no large gaps in the response. 104 | private let tableSize = 300 105 | 106 | private let scaleFactor: Float 107 | private var meterTable = [Float]() 108 | 109 | init() { 110 | let dbResolution = kMinDB / Float(tableSize - 1) 111 | scaleFactor = 1.0 / dbResolution 112 | 113 | // This controls the curvature of the response. 114 | // 2.0 is the square root, 3.0 is the cube root. 115 | let root: Float = 2.0 116 | 117 | let rroot = 1.0 / root 118 | let minAmp = dbToAmp(dBValue: kMinDB) 119 | let ampRange = 1.0 - minAmp 120 | let invAmpRange = 1.0 / ampRange 121 | 122 | for index in 0.. Float { 131 | return powf(10.0, 0.05 * dBValue) 132 | } 133 | 134 | func valueForPower(_ power: Float) -> Float { 135 | if power < kMinDB { 136 | return 0.0 137 | } else if power >= 0.0 { 138 | return 1.0 139 | } else { 140 | let index = Int(power) * Int(scaleFactor) 141 | return meterTable[index] 142 | } 143 | } 144 | } 145 | 146 | -------------------------------------------------------------------------------- /Polaris/Views/GeneralSettings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GeneralSettings.swift 3 | // Polaris 4 | // 5 | // Created by Cyril Zakka on 10/25/24. 6 | // 7 | 8 | import SwiftUI 9 | import ScreenCaptureKit 10 | 11 | struct GeneralSettings: View { 12 | 13 | @EnvironmentObject var screenRecorder: ScreenRecorder 14 | 15 | // Key logging 16 | @AppStorage("enableKeyLogger") private var enableKeyLogging = false 17 | @AppStorage("enableMouseTracking") private var enableMouseTracking = false 18 | @AppStorage("scrollSensitivity") private var scrollSensitivity = 10 19 | 20 | // Output Path 21 | @State private var defaultPath: String = "" 22 | @State private var outputPath: String = "" 23 | @State private var outputPathSelection: OutputPathType = .default 24 | enum OutputPathType { 25 | case `default` 26 | case custom 27 | } 28 | 29 | var body: some View { 30 | Form { 31 | Section("Display") { 32 | Picker("Selected display", selection: $screenRecorder.selectedDisplay) { 33 | ForEach(screenRecorder.availableDisplays, id: \.self) { display in 34 | Text(display.displayName) 35 | .tag(SCDisplay?.some(display)) 36 | } 37 | } 38 | 39 | Picker("Display HDR", selection: $screenRecorder.selectedDynamicRangePreset) { 40 | Text("Default (None)") 41 | .tag(ScreenRecorder.DynamicRangePreset?.none) 42 | ForEach(ScreenRecorder.DynamicRangePreset.allCases, id: \.self) { 43 | Text($0.rawValue) 44 | .tag(ScreenRecorder.DynamicRangePreset?.some($0)) 45 | } 46 | } 47 | } 48 | Section("Audio") { 49 | Toggle("Capture audio", isOn: $screenRecorder.isAudioCaptureEnabled) 50 | Toggle("Capture microphone output", isOn: $screenRecorder.isMicCaptureEnabled) 51 | } 52 | 53 | Section (content: { 54 | Toggle("Enable mouse and key logging", isOn: $enableKeyLogging) 55 | Picker("Scroll Sensitivity", selection: $scrollSensitivity) { 56 | Text("High").tag(10) 57 | Text("Medium").tag(40) 58 | Text("Low").tag(90) 59 | } 60 | .pickerStyle(.menu) 61 | .disabled(!enableKeyLogging) 62 | }, header: { 63 | Text("Event Logging") 64 | }, footer: { 65 | Button { 66 | screenRecorder.openRecordingFolder() 67 | } label: { 68 | Text("When enabled, mouse and key events will be logged to a plain text file in the destination folder alongside the screen capture. While Polaris runs entirely locally, please be mindful of other applications that may try to access the same file.") 69 | } 70 | .buttonStyle(.plain) 71 | .font(.footnote) 72 | .frame(maxWidth: .infinity, alignment: .leading) 73 | .multilineTextAlignment(.leading) 74 | .lineLimit(nil) 75 | .foregroundColor(.secondary) 76 | }) 77 | // TODO: modify scrolling sensitivity to decrease noise as a picker 78 | // TODO: Filter backspaces from log (bool). When enabled sequence logged before backspace and backspace are not logged 79 | // 80 | // Section (content: { 81 | // VStack { 82 | // Picker("Save Directory:", selection: $outputPathSelection) { 83 | // Text("Default").tag(OutputPathType.default) 84 | // Text("Custom").tag(OutputPathType.custom) 85 | // } 86 | // if outputPathSelection == .custom { 87 | // Divider() 88 | // HStack(alignment: .center) { 89 | // Text(outputPath) 90 | // .lineLimit(1) 91 | // .foregroundStyle(.secondary) 92 | // 93 | // HStack(alignment: .center) { 94 | // Button("Reset") { 95 | // outputPathSelection = .default 96 | // outputPath = defaultPath 97 | // } 98 | // .disabled(outputPath == defaultPath) 99 | // Button("Choose...") { 100 | // 101 | // } 102 | // } 103 | // .frame(maxWidth: .infinity, alignment: .trailing) 104 | // } 105 | // 106 | // } 107 | // } 108 | // 109 | // }, header: { 110 | // Text("Output") 111 | // }, footer: { 112 | // Button { 113 | // screenRecorder.openRecordingFolder() 114 | // } label: { 115 | // Text("By default, recordings and associated metadata are saved in the application's home directory.") + Text(" 􀁽") 116 | // } 117 | // .buttonStyle(.plain) 118 | // .font(.footnote) 119 | // .frame(maxWidth: .infinity, alignment: .leading) 120 | // .multilineTextAlignment(.leading) 121 | // .lineLimit(nil) 122 | // .foregroundColor(.secondary) 123 | // }) 124 | } 125 | .formStyle(.grouped) 126 | .task { 127 | await screenRecorder.monitorAvailableContent() 128 | } 129 | .onAppear { 130 | if let placeholder = screenRecorder.recordingOutputPath { 131 | defaultPath = placeholder 132 | outputPath = placeholder 133 | } 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /Polaris/ScreenCapture/ScreenCaptureEngine.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScreenCaptureEngine.swift 3 | // Polaris 4 | // 5 | // Created by Cyril Zakka on 10/25/24. 6 | // 7 | 8 | import Foundation 9 | import AVFAudio 10 | import ScreenCaptureKit 11 | import OSLog 12 | import Combine 13 | 14 | /// A structure that contains the video data to render. 15 | struct CapturedFrame: @unchecked Sendable { 16 | static var invalid: CapturedFrame { 17 | CapturedFrame(surface: nil, contentRect: .zero, contentScale: 0, scaleFactor: 0) 18 | } 19 | 20 | let surface: IOSurface? 21 | let contentRect: CGRect 22 | let contentScale: CGFloat 23 | let scaleFactor: CGFloat 24 | var size: CGSize { contentRect.size } 25 | } 26 | 27 | /// An object that wraps an instance of `SCStream`, and returns its results as an `AsyncThrowingStream`. 28 | class CaptureEngine: NSObject, @unchecked Sendable { 29 | 30 | private let logger = Logger() 31 | 32 | private(set) var stream: SCStream? 33 | private var streamOutput: CaptureEngineStreamOutput? 34 | private let videoSampleBufferQueue = DispatchQueue(label: "com.cyrilzakka.Polaris.VideoSampleBufferQueue") 35 | private let audioSampleBufferQueue = DispatchQueue(label: "com.cyrilzakka.Polaris.AudioSampleBufferQueue") 36 | private let micSampleBufferQueue = DispatchQueue(label: "com.cyrilzakka.Polaris.MicSampleBufferQueue") 37 | 38 | // Performs average and peak power calculations on the audio samples. 39 | private let powerMeter = PowerMeter() 40 | var audioLevels: AudioLevels { powerMeter.levels } 41 | 42 | // Store the the startCapture continuation, so that you can cancel it when you call stopCapture(). 43 | private var continuation: AsyncThrowingStream.Continuation? 44 | 45 | /// - Tag: StartCapture 46 | func startCapture(configuration: SCStreamConfiguration, filter: SCContentFilter) -> AsyncThrowingStream { 47 | AsyncThrowingStream { continuation in 48 | // The stream output object. Avoid reassigning it to a new object every time startCapture is called. 49 | let streamOutput = CaptureEngineStreamOutput(continuation: continuation) 50 | self.streamOutput = streamOutput 51 | streamOutput.capturedFrameHandler = { continuation.yield($0) } 52 | streamOutput.pcmBufferHandler = { self.powerMeter.process(buffer: $0) } 53 | 54 | do { 55 | stream = SCStream(filter: filter, configuration: configuration, delegate: streamOutput) 56 | 57 | // Add a stream output to capture screen content. 58 | try stream?.addStreamOutput(streamOutput, type: .screen, sampleHandlerQueue: videoSampleBufferQueue) 59 | try stream?.addStreamOutput(streamOutput, type: .audio, sampleHandlerQueue: audioSampleBufferQueue) 60 | try stream?.addStreamOutput(streamOutput, type: .microphone, sampleHandlerQueue: micSampleBufferQueue) 61 | stream?.startCapture() 62 | } catch { 63 | continuation.finish(throwing: error) 64 | } 65 | } 66 | } 67 | 68 | func stopCapture() async { 69 | do { 70 | try await stream?.stopCapture() 71 | continuation?.finish() 72 | } catch { 73 | continuation?.finish(throwing: error) 74 | } 75 | powerMeter.processSilence() 76 | } 77 | 78 | /// - Tag: UpdateStreamConfiguration 79 | func update(configuration: SCStreamConfiguration, filter: SCContentFilter) async { 80 | do { 81 | try await stream?.updateConfiguration(configuration) 82 | try await stream?.updateContentFilter(filter) 83 | } catch { 84 | logger.error("Failed to update the stream session: \(String(describing: error))") 85 | } 86 | } 87 | 88 | /// - Tag: Recording Output 89 | func addRecordOutputToStream(_ recordingOutput: SCRecordingOutput) async throws { 90 | try self.stream?.addRecordingOutput(recordingOutput) 91 | } 92 | 93 | func stopRecordingOutputForStream(_ recordingOutput: SCRecordingOutput) throws { 94 | try self.stream?.removeRecordingOutput(recordingOutput) 95 | } 96 | } 97 | 98 | /// A class that handles output from an SCStream, and handles stream errors. 99 | private class CaptureEngineStreamOutput: NSObject, SCStreamOutput, SCStreamDelegate { 100 | 101 | var pcmBufferHandler: ((AVAudioPCMBuffer) -> Void)? 102 | var capturedFrameHandler: ((CapturedFrame) -> Void)? 103 | 104 | // Store the startCapture continuation, so you can cancel it if an error occurs. 105 | private var continuation: AsyncThrowingStream.Continuation? 106 | 107 | init(continuation: AsyncThrowingStream.Continuation?) { 108 | self.continuation = continuation 109 | } 110 | 111 | /// - Tag: DidOutputSampleBuffer 112 | func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) { 113 | 114 | // Return early if the sample buffer is invalid. 115 | guard sampleBuffer.isValid else { return } 116 | 117 | // Determine which type of data the sample buffer contains. 118 | switch outputType { 119 | case .screen: 120 | // Create a CapturedFrame structure for a video sample buffer. 121 | guard let frame = createFrame(for: sampleBuffer) else { return } 122 | capturedFrameHandler?(frame) 123 | case .audio: 124 | // Process audio as an AVAudioPCMBuffer for level calculation. 125 | handleAudio(for: sampleBuffer) 126 | case .microphone: 127 | handleAudio(for: sampleBuffer) 128 | @unknown default: 129 | fatalError("Encountered unknown stream output type: \(outputType)") 130 | } 131 | } 132 | 133 | /// Create a `CapturedFrame` for the video sample buffer. 134 | private func createFrame(for sampleBuffer: CMSampleBuffer) -> CapturedFrame? { 135 | 136 | // Retrieve the array of metadata attachments from the sample buffer. 137 | guard let attachmentsArray = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, 138 | createIfNecessary: false) as? [[SCStreamFrameInfo: Any]], 139 | let attachments = attachmentsArray.first else { return nil } 140 | 141 | // Validate the status of the frame. If it isn't `.complete`, return nil. 142 | guard let statusRawValue = attachments[SCStreamFrameInfo.status] as? Int, 143 | let status = SCFrameStatus(rawValue: statusRawValue), 144 | status == .complete else { return nil } 145 | 146 | // Get the pixel buffer that contains the image data. 147 | guard let pixelBuffer = sampleBuffer.imageBuffer else { return nil } 148 | 149 | // Get the backing IOSurface. 150 | guard let surfaceRef = CVPixelBufferGetIOSurface(pixelBuffer)?.takeUnretainedValue() else { return nil } 151 | let surface = unsafeBitCast(surfaceRef, to: IOSurface.self) 152 | 153 | // Retrieve the content rectangle, scale, and scale factor. 154 | guard let contentRectDict = attachments[.contentRect], 155 | let contentRect = CGRect(dictionaryRepresentation: contentRectDict as! CFDictionary), 156 | let contentScale = attachments[.contentScale] as? CGFloat, 157 | let scaleFactor = attachments[.scaleFactor] as? CGFloat else { return nil } 158 | 159 | // Create a new frame with the relevant data. 160 | let frame = CapturedFrame(surface: surface, 161 | contentRect: contentRect, 162 | contentScale: contentScale, 163 | scaleFactor: scaleFactor) 164 | return frame 165 | } 166 | 167 | private func handleAudio(for buffer: CMSampleBuffer) -> Void? { 168 | // Create an AVAudioPCMBuffer from an audio sample buffer. 169 | try? buffer.withAudioBufferList { audioBufferList, blockBuffer in 170 | guard let description = buffer.formatDescription?.audioStreamBasicDescription, 171 | let format = AVAudioFormat(standardFormatWithSampleRate: description.mSampleRate, channels: description.mChannelsPerFrame), 172 | let samples = AVAudioPCMBuffer(pcmFormat: format, bufferListNoCopy: audioBufferList.unsafePointer) 173 | else { return } 174 | pcmBufferHandler?(samples) 175 | } 176 | } 177 | 178 | func stream(_ stream: SCStream, didStopWithError error: Error) { 179 | continuation?.finish(throwing: error) 180 | } 181 | } 182 | 183 | -------------------------------------------------------------------------------- /Polaris/KeyLogging/Keylogger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Keylogger.swift 3 | // Keylogger 4 | // 5 | // Created by Cyril Zakka on 10/25/24. 6 | // 7 | // Adapted and modified from: https://github.com/SkrewEverything/Swift-Keylogger 8 | 9 | import Foundation 10 | import IOKit.hid 11 | import Cocoa 12 | 13 | class Keylogger { 14 | var manager: IOHIDManager 15 | var deviceList = NSArray() 16 | var appName = "" 17 | let documentsPath: String 18 | let outputFile: URL 19 | 20 | init() { 21 | // Get documents directory path and create timestamped filename 22 | let dateFormatter = DateFormatter() 23 | dateFormatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" 24 | let timestamp = dateFormatter.string(from: Date()) 25 | let filename = "recorded_output_\(timestamp).txt" 26 | 27 | if let path = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).last { 28 | documentsPath = path 29 | outputFile = URL(fileURLWithPath: path).appendingPathComponent(filename) 30 | } else { 31 | fatalError("Could not access documents directory") 32 | } 33 | 34 | manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) 35 | 36 | // Create output file if it doesn't exist 37 | if !FileManager.default.fileExists(atPath: outputFile.path) { 38 | FileManager.default.createFile(atPath: outputFile.path, contents: nil, attributes: nil) 39 | } 40 | 41 | if (CFGetTypeID(manager) != IOHIDManagerGetTypeID()) { 42 | print("Can't create manager") 43 | exit(1) 44 | } 45 | 46 | // Add keyboard devices 47 | deviceList = deviceList.adding(CreateDeviceMatchingDictionary(inUsagePage: kHIDPage_GenericDesktop, inUsage: kHIDUsage_GD_Keyboard)) as NSArray 48 | deviceList = deviceList.adding(CreateDeviceMatchingDictionary(inUsagePage: kHIDPage_GenericDesktop, inUsage: kHIDUsage_GD_Keypad)) as NSArray 49 | // Add mouse device 50 | deviceList = deviceList.adding(CreateDeviceMatchingDictionary(inUsagePage: kHIDPage_GenericDesktop, inUsage: kHIDUsage_GD_Mouse)) as NSArray 51 | // Add pointer device (for some mice/trackpads) 52 | deviceList = deviceList.adding(CreateDeviceMatchingDictionary(inUsagePage: kHIDPage_GenericDesktop, inUsage: kHIDUsage_GD_Pointer)) as NSArray 53 | 54 | IOHIDManagerSetDeviceMatchingMultiple(manager, deviceList as CFArray) 55 | 56 | let observer = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) 57 | 58 | /* App switching notification*/ 59 | NSWorkspace.shared.notificationCenter.addObserver(self, 60 | selector: #selector(activatedApp), 61 | name: NSWorkspace.didActivateApplicationNotification, 62 | object: nil) 63 | 64 | /* Connected and Disconnected Call Backs */ 65 | IOHIDManagerRegisterDeviceMatchingCallback(manager, CallBackFunctions.Handle_DeviceMatchingCallback, observer) 66 | IOHIDManagerRegisterDeviceRemovalCallback(manager, CallBackFunctions.Handle_DeviceRemovalCallback, observer) 67 | 68 | /* Input value Call Backs */ 69 | IOHIDManagerRegisterInputValueCallback(manager, CallBackFunctions.Handle_IOHIDInputValueCallback, observer) 70 | 71 | /* Open HID Manager */ 72 | let ioreturn: IOReturn = openHIDManager() 73 | if ioreturn != kIOReturnSuccess { 74 | print("Can't open HID!") 75 | } 76 | 77 | // Write initial header to the file 78 | let header = "Recording started at: \(Date().description(with: Locale.current))\n\n" 79 | writeToLog(header) 80 | } 81 | 82 | func writeToLog(_ text: String) { 83 | if let handle = FileHandle(forWritingAtPath: outputFile.path) { 84 | handle.seekToEndOfFile() 85 | handle.write(text.data(using: .utf8)!) 86 | handle.closeFile() 87 | } 88 | } 89 | 90 | @objc dynamic func activatedApp(notification: NSNotification) { 91 | if let info = notification.userInfo, 92 | let app = info[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, 93 | let name = app.localizedName { 94 | self.appName = name 95 | let timeStamp = "\n[" + Date().description(with: Locale.current) + "] Switched to: " + self.appName + "\n" 96 | writeToLog(timeStamp) 97 | } 98 | } 99 | 100 | /* For Keyboard */ 101 | func CreateDeviceMatchingDictionary(inUsagePage: Int ,inUsage: Int ) -> CFMutableDictionary { 102 | /* // note: the usage is only valid if the usage page is also defined */ 103 | 104 | let resultAsSwiftDic = [kIOHIDDeviceUsagePageKey: inUsagePage, kIOHIDDeviceUsageKey : inUsage] 105 | let resultAsCFDic: CFMutableDictionary = resultAsSwiftDic as! CFMutableDictionary 106 | return resultAsCFDic 107 | } 108 | 109 | func openHIDManager() -> IOReturn { 110 | return IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)); 111 | } 112 | 113 | /* Scheduling the HID Loop */ 114 | func start() { 115 | IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue) 116 | } 117 | 118 | /* Un-scheduling the HID Loop */ 119 | func stop() { 120 | IOHIDManagerUnscheduleFromRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue); 121 | } 122 | 123 | 124 | var keyMap: [UInt32:[String]] { 125 | var map = [UInt32:[String]]() 126 | map[4] = ["a","A"] 127 | map[5] = ["b","B"] 128 | map[6] = ["c","C"] 129 | map[7] = ["d","D"] 130 | map[8] = ["e","E"] 131 | map[9] = ["f","F"] 132 | map[10] = ["g","G"] 133 | map[11] = ["h","H"] 134 | map[12] = ["i","I"] 135 | map[13] = ["j","J"] 136 | map[14] = ["k","K"] 137 | map[15] = ["l","L"] 138 | map[16] = ["m","M"] 139 | map[17] = ["n","N"] 140 | map[18] = ["o","O"] 141 | map[19] = ["p","P"] 142 | map[20] = ["q","Q"] 143 | map[21] = ["r","R"] 144 | map[22] = ["s","S"] 145 | map[23] = ["t","T"] 146 | map[24] = ["u","U"] 147 | map[25] = ["v","V"] 148 | map[26] = ["w","W"] 149 | map[27] = ["x","X"] 150 | map[28] = ["y","Y"] 151 | map[29] = ["z","Z"] 152 | map[30] = ["1","!"] 153 | map[31] = ["2","@"] 154 | map[32] = ["3","#"] 155 | map[33] = ["4","$"] 156 | map[34] = ["5","%"] 157 | map[35] = ["6","^"] 158 | map[36] = ["7","&"] 159 | map[37] = ["8","*"] 160 | map[38] = ["9","("] 161 | map[39] = ["0",")"] 162 | map[40] = ["\n","\n"] 163 | map[41] = ["\\ESCAPE","\\ESCAPE"] 164 | map[42] = ["\\DELETE|BACKSPACE","\\DELETE|BACKSPACE"] // 165 | map[43] = ["\\TAB","\\TAB"] 166 | map[44] = [" "," "] 167 | map[45] = ["-","_"] 168 | map[46] = ["=","+"] 169 | map[47] = ["[","{"] 170 | map[48] = ["]","}"] 171 | map[49] = ["\\","|"] 172 | map[50] = ["",""] // Keyboard Non-US# and ~2 173 | map[51] = [";",":"] 174 | map[52] = ["'","\""] 175 | map[53] = ["`","~"] 176 | map[54] = [",","<"] 177 | map[55] = [".",">"] 178 | map[56] = ["/","?"] 179 | map[57] = ["\\CAPSLOCK","\\CAPSLOCK"] 180 | map[58] = ["\\F1","\\F1"] 181 | map[59] = ["\\F2","\\F2"] 182 | map[60] = ["\\F3","\\F3"] 183 | map[61] = ["\\F4","\\F4"] 184 | map[62] = ["\\F5","\\F5"] 185 | map[63] = ["\\F6","\\F6"] 186 | map[64] = ["\\F7","\\F7"] 187 | map[65] = ["\\F8","\\F8"] 188 | map[66] = ["\\F9","\\F9"] 189 | map[67] = ["\\F10","\\F10"] 190 | map[68] = ["\\F11","\\F11"] 191 | map[69] = ["\\F12","\\F12"] 192 | map[70] = ["\\PRINTSCREEN","\\PRINTSCREEN"] 193 | map[71] = ["\\SCROLL-LOCK","\\SCROLL-LOCK"] 194 | map[72] = ["\\PAUSE","\\PAUSE"] 195 | map[73] = ["\\INSERT","\\INSERT"] 196 | map[74] = ["\\HOME","\\HOME"] 197 | map[75] = ["\\PAGEUP","\\PAGEUP"] 198 | map[76] = ["\\DELETE-FORWARD","\\DELETE-FORWARD"] // 199 | map[77] = ["\\END","\\END"] 200 | map[78] = ["\\PAGEDOWN","\\PAGEDOWN"] 201 | map[79] = ["\\RIGHTARROW","\\RIGHTARROW"] 202 | map[80] = ["\\LEFTARROW","\\LEFTARROW"] 203 | map[81] = ["\\DOWNARROW","\\DOWNARROW"] 204 | map[82] = ["\\UPARROW","\\UPARROW"] 205 | map[83] = ["\\NUMLOCK","\\CLEAR"] 206 | // Keypads 207 | map[84] = ["/","/"] 208 | map[85] = ["*","*"] 209 | map[86] = ["-","-"] 210 | map[87] = ["+","+"] 211 | map[88] = ["\\ENTER","\\ENTER"] 212 | map[89] = ["1","\\END"] 213 | map[90] = ["2","\\DOWNARROW"] 214 | map[91] = ["3","\\PAGEDOWN"] 215 | map[92] = ["4","\\LEFTARROW"] 216 | map[93] = ["5","5"] 217 | map[94] = ["6","\\RIGHTARROW"] 218 | map[95] = ["7","\\HOME"] 219 | map[96] = ["8","\\UPARROW"] 220 | map[97] = ["9","\\PAGEUP"] 221 | map[98] = ["0","\\INSERT"] 222 | map[99] = [".","\\DELETE"] 223 | map[100] = ["",""] // 224 | ///// 225 | map[224] = ["\\LC","\\LC"] // left control 226 | map[225] = ["\\LS","\\LS"] // left shift 227 | map[226] = ["\\LA","\\LA"] // left alt 228 | map[227] = ["\\LCMD","\\LCMD"] // left cmd 229 | map[228] = ["\\RC","\\RC"] // right control 230 | map[229] = ["\\RS","\\RS"] // right shift 231 | map[230] = ["\\RA","\\RA"] // right alt 232 | map[231] = ["\\RCMD","\\RCMD"] // right cmd 233 | return map 234 | } 235 | 236 | } 237 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2022 Hugging Face SAS. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Polaris.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXFileReference section */ 10 | F17D89682CCC09C900DA353C /* Polaris.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Polaris.app; sourceTree = BUILT_PRODUCTS_DIR; }; 11 | /* End PBXFileReference section */ 12 | 13 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 14 | F17D896A2CCC09C900DA353C /* Polaris */ = { 15 | isa = PBXFileSystemSynchronizedRootGroup; 16 | path = Polaris; 17 | sourceTree = ""; 18 | }; 19 | /* End PBXFileSystemSynchronizedRootGroup section */ 20 | 21 | /* Begin PBXFrameworksBuildPhase section */ 22 | F17D89652CCC09C900DA353C /* Frameworks */ = { 23 | isa = PBXFrameworksBuildPhase; 24 | buildActionMask = 2147483647; 25 | files = ( 26 | ); 27 | runOnlyForDeploymentPostprocessing = 0; 28 | }; 29 | /* End PBXFrameworksBuildPhase section */ 30 | 31 | /* Begin PBXGroup section */ 32 | F17D895F2CCC09C900DA353C = { 33 | isa = PBXGroup; 34 | children = ( 35 | F17D896A2CCC09C900DA353C /* Polaris */, 36 | F17D89692CCC09C900DA353C /* Products */, 37 | ); 38 | sourceTree = ""; 39 | }; 40 | F17D89692CCC09C900DA353C /* Products */ = { 41 | isa = PBXGroup; 42 | children = ( 43 | F17D89682CCC09C900DA353C /* Polaris.app */, 44 | ); 45 | name = Products; 46 | sourceTree = ""; 47 | }; 48 | /* End PBXGroup section */ 49 | 50 | /* Begin PBXNativeTarget section */ 51 | F17D89672CCC09C900DA353C /* Polaris */ = { 52 | isa = PBXNativeTarget; 53 | buildConfigurationList = F17D89772CCC09CA00DA353C /* Build configuration list for PBXNativeTarget "Polaris" */; 54 | buildPhases = ( 55 | F17D89642CCC09C900DA353C /* Sources */, 56 | F17D89652CCC09C900DA353C /* Frameworks */, 57 | F17D89662CCC09C900DA353C /* Resources */, 58 | ); 59 | buildRules = ( 60 | ); 61 | dependencies = ( 62 | ); 63 | fileSystemSynchronizedGroups = ( 64 | F17D896A2CCC09C900DA353C /* Polaris */, 65 | ); 66 | name = Polaris; 67 | packageProductDependencies = ( 68 | ); 69 | productName = Polaris; 70 | productReference = F17D89682CCC09C900DA353C /* Polaris.app */; 71 | productType = "com.apple.product-type.application"; 72 | }; 73 | /* End PBXNativeTarget section */ 74 | 75 | /* Begin PBXProject section */ 76 | F17D89602CCC09C900DA353C /* Project object */ = { 77 | isa = PBXProject; 78 | attributes = { 79 | BuildIndependentTargetsInParallel = 1; 80 | LastSwiftUpdateCheck = 1620; 81 | LastUpgradeCheck = 1620; 82 | TargetAttributes = { 83 | F17D89672CCC09C900DA353C = { 84 | CreatedOnToolsVersion = 16.2; 85 | }; 86 | }; 87 | }; 88 | buildConfigurationList = F17D89632CCC09C900DA353C /* Build configuration list for PBXProject "Polaris" */; 89 | developmentRegion = en; 90 | hasScannedForEncodings = 0; 91 | knownRegions = ( 92 | en, 93 | Base, 94 | ); 95 | mainGroup = F17D895F2CCC09C900DA353C; 96 | minimizedProjectReferenceProxies = 1; 97 | preferredProjectObjectVersion = 77; 98 | productRefGroup = F17D89692CCC09C900DA353C /* Products */; 99 | projectDirPath = ""; 100 | projectRoot = ""; 101 | targets = ( 102 | F17D89672CCC09C900DA353C /* Polaris */, 103 | ); 104 | }; 105 | /* End PBXProject section */ 106 | 107 | /* Begin PBXResourcesBuildPhase section */ 108 | F17D89662CCC09C900DA353C /* Resources */ = { 109 | isa = PBXResourcesBuildPhase; 110 | buildActionMask = 2147483647; 111 | files = ( 112 | ); 113 | runOnlyForDeploymentPostprocessing = 0; 114 | }; 115 | /* End PBXResourcesBuildPhase section */ 116 | 117 | /* Begin PBXSourcesBuildPhase section */ 118 | F17D89642CCC09C900DA353C /* Sources */ = { 119 | isa = PBXSourcesBuildPhase; 120 | buildActionMask = 2147483647; 121 | files = ( 122 | ); 123 | runOnlyForDeploymentPostprocessing = 0; 124 | }; 125 | /* End PBXSourcesBuildPhase section */ 126 | 127 | /* Begin XCBuildConfiguration section */ 128 | F17D89752CCC09CA00DA353C /* Debug */ = { 129 | isa = XCBuildConfiguration; 130 | buildSettings = { 131 | ALWAYS_SEARCH_USER_PATHS = NO; 132 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 133 | CLANG_ANALYZER_NONNULL = YES; 134 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 135 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 136 | CLANG_ENABLE_MODULES = YES; 137 | CLANG_ENABLE_OBJC_ARC = YES; 138 | CLANG_ENABLE_OBJC_WEAK = YES; 139 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 140 | CLANG_WARN_BOOL_CONVERSION = YES; 141 | CLANG_WARN_COMMA = YES; 142 | CLANG_WARN_CONSTANT_CONVERSION = YES; 143 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 144 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 145 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 146 | CLANG_WARN_EMPTY_BODY = YES; 147 | CLANG_WARN_ENUM_CONVERSION = YES; 148 | CLANG_WARN_INFINITE_RECURSION = YES; 149 | CLANG_WARN_INT_CONVERSION = YES; 150 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 151 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 152 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 153 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 154 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 155 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 156 | CLANG_WARN_STRICT_PROTOTYPES = YES; 157 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 158 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 159 | CLANG_WARN_UNREACHABLE_CODE = YES; 160 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 161 | COPY_PHASE_STRIP = NO; 162 | DEBUG_INFORMATION_FORMAT = dwarf; 163 | ENABLE_STRICT_OBJC_MSGSEND = YES; 164 | ENABLE_TESTABILITY = YES; 165 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 166 | GCC_C_LANGUAGE_STANDARD = gnu17; 167 | GCC_DYNAMIC_NO_PIC = NO; 168 | GCC_NO_COMMON_BLOCKS = YES; 169 | GCC_OPTIMIZATION_LEVEL = 0; 170 | GCC_PREPROCESSOR_DEFINITIONS = ( 171 | "DEBUG=1", 172 | "$(inherited)", 173 | ); 174 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 175 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 176 | GCC_WARN_UNDECLARED_SELECTOR = YES; 177 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 178 | GCC_WARN_UNUSED_FUNCTION = YES; 179 | GCC_WARN_UNUSED_VARIABLE = YES; 180 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 181 | MACOSX_DEPLOYMENT_TARGET = 15.1; 182 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 183 | MTL_FAST_MATH = YES; 184 | ONLY_ACTIVE_ARCH = YES; 185 | SDKROOT = macosx; 186 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 187 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 188 | }; 189 | name = Debug; 190 | }; 191 | F17D89762CCC09CA00DA353C /* Release */ = { 192 | isa = XCBuildConfiguration; 193 | buildSettings = { 194 | ALWAYS_SEARCH_USER_PATHS = NO; 195 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 196 | CLANG_ANALYZER_NONNULL = YES; 197 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 198 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 199 | CLANG_ENABLE_MODULES = YES; 200 | CLANG_ENABLE_OBJC_ARC = YES; 201 | CLANG_ENABLE_OBJC_WEAK = YES; 202 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 203 | CLANG_WARN_BOOL_CONVERSION = YES; 204 | CLANG_WARN_COMMA = YES; 205 | CLANG_WARN_CONSTANT_CONVERSION = YES; 206 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 207 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 208 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 209 | CLANG_WARN_EMPTY_BODY = YES; 210 | CLANG_WARN_ENUM_CONVERSION = YES; 211 | CLANG_WARN_INFINITE_RECURSION = YES; 212 | CLANG_WARN_INT_CONVERSION = YES; 213 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 214 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 215 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 216 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 217 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 218 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 219 | CLANG_WARN_STRICT_PROTOTYPES = YES; 220 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 221 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 222 | CLANG_WARN_UNREACHABLE_CODE = YES; 223 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 224 | COPY_PHASE_STRIP = NO; 225 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 226 | ENABLE_NS_ASSERTIONS = NO; 227 | ENABLE_STRICT_OBJC_MSGSEND = YES; 228 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 229 | GCC_C_LANGUAGE_STANDARD = gnu17; 230 | GCC_NO_COMMON_BLOCKS = YES; 231 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 232 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 233 | GCC_WARN_UNDECLARED_SELECTOR = YES; 234 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 235 | GCC_WARN_UNUSED_FUNCTION = YES; 236 | GCC_WARN_UNUSED_VARIABLE = YES; 237 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 238 | MACOSX_DEPLOYMENT_TARGET = 15.1; 239 | MTL_ENABLE_DEBUG_INFO = NO; 240 | MTL_FAST_MATH = YES; 241 | SDKROOT = macosx; 242 | SWIFT_COMPILATION_MODE = wholemodule; 243 | }; 244 | name = Release; 245 | }; 246 | F17D89782CCC09CA00DA353C /* Debug */ = { 247 | isa = XCBuildConfiguration; 248 | buildSettings = { 249 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 250 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 251 | CODE_SIGN_ENTITLEMENTS = Polaris/Polaris.entitlements; 252 | "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; 253 | CODE_SIGN_STYLE = Automatic; 254 | COMBINE_HIDPI_IMAGES = YES; 255 | CURRENT_PROJECT_VERSION = 1; 256 | DEVELOPMENT_ASSET_PATHS = "\"Polaris/Preview Content\""; 257 | DEVELOPMENT_TEAM = AG2QJ56KLX; 258 | ENABLE_HARDENED_RUNTIME = YES; 259 | ENABLE_PREVIEWS = YES; 260 | GENERATE_INFOPLIST_FILE = YES; 261 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 262 | LD_RUNPATH_SEARCH_PATHS = ( 263 | "$(inherited)", 264 | "@executable_path/../Frameworks", 265 | ); 266 | MARKETING_VERSION = 1.0; 267 | PRODUCT_BUNDLE_IDENTIFIER = cyrilzakka.Polaris; 268 | PRODUCT_NAME = "$(TARGET_NAME)"; 269 | SWIFT_EMIT_LOC_STRINGS = YES; 270 | SWIFT_VERSION = 5.0; 271 | }; 272 | name = Debug; 273 | }; 274 | F17D89792CCC09CA00DA353C /* Release */ = { 275 | isa = XCBuildConfiguration; 276 | buildSettings = { 277 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 278 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 279 | CODE_SIGN_ENTITLEMENTS = Polaris/Polaris.entitlements; 280 | "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; 281 | CODE_SIGN_STYLE = Automatic; 282 | COMBINE_HIDPI_IMAGES = YES; 283 | CURRENT_PROJECT_VERSION = 1; 284 | DEVELOPMENT_ASSET_PATHS = "\"Polaris/Preview Content\""; 285 | DEVELOPMENT_TEAM = AG2QJ56KLX; 286 | ENABLE_HARDENED_RUNTIME = YES; 287 | ENABLE_PREVIEWS = YES; 288 | GENERATE_INFOPLIST_FILE = YES; 289 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 290 | LD_RUNPATH_SEARCH_PATHS = ( 291 | "$(inherited)", 292 | "@executable_path/../Frameworks", 293 | ); 294 | MARKETING_VERSION = 1.0; 295 | PRODUCT_BUNDLE_IDENTIFIER = cyrilzakka.Polaris; 296 | PRODUCT_NAME = "$(TARGET_NAME)"; 297 | SWIFT_EMIT_LOC_STRINGS = YES; 298 | SWIFT_VERSION = 5.0; 299 | }; 300 | name = Release; 301 | }; 302 | /* End XCBuildConfiguration section */ 303 | 304 | /* Begin XCConfigurationList section */ 305 | F17D89632CCC09C900DA353C /* Build configuration list for PBXProject "Polaris" */ = { 306 | isa = XCConfigurationList; 307 | buildConfigurations = ( 308 | F17D89752CCC09CA00DA353C /* Debug */, 309 | F17D89762CCC09CA00DA353C /* Release */, 310 | ); 311 | defaultConfigurationIsVisible = 0; 312 | defaultConfigurationName = Release; 313 | }; 314 | F17D89772CCC09CA00DA353C /* Build configuration list for PBXNativeTarget "Polaris" */ = { 315 | isa = XCConfigurationList; 316 | buildConfigurations = ( 317 | F17D89782CCC09CA00DA353C /* Debug */, 318 | F17D89792CCC09CA00DA353C /* Release */, 319 | ); 320 | defaultConfigurationIsVisible = 0; 321 | defaultConfigurationName = Release; 322 | }; 323 | /* End XCConfigurationList section */ 324 | }; 325 | rootObject = F17D89602CCC09C900DA353C /* Project object */; 326 | } 327 | -------------------------------------------------------------------------------- /Polaris/ScreenCapture/ScreenRecorder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScreenRecorder.swift 3 | // Polaris 4 | // 5 | // Created by Cyril Zakka on 10/25/24. 6 | // 7 | 8 | import Foundation 9 | @preconcurrency import ScreenCaptureKit 10 | import Combine 11 | import OSLog 12 | import SwiftUI 13 | 14 | /// A provider of audio levels from the captured samples. 15 | class AudioLevelsProvider: ObservableObject { 16 | @Published var audioLevels = AudioLevels.zero 17 | } 18 | 19 | @MainActor 20 | class ScreenRecorder: NSObject, 21 | ObservableObject, 22 | SCContentSharingPickerObserver { 23 | /// The supported capture types. 24 | enum CaptureType { 25 | case display 26 | case window 27 | } 28 | 29 | enum DynamicRangePreset: String, CaseIterable { 30 | case localDisplayHDR = "Local Display HDR" 31 | case canonicalDisplayHDR = "Canonical Display HDR" 32 | 33 | @available(macOS 15.0, *) 34 | var scDynamicRangePreset: SCStreamConfiguration.Preset? { 35 | switch self { 36 | case .localDisplayHDR: 37 | return SCStreamConfiguration.Preset.captureHDRStreamLocalDisplay 38 | case .canonicalDisplayHDR: 39 | return SCStreamConfiguration.Preset.captureHDRStreamCanonicalDisplay 40 | } 41 | } 42 | } 43 | 44 | private let logger = Logger() 45 | 46 | @Published var isRunning = false 47 | 48 | // MARK: - Video Properties 49 | @Published var captureType: CaptureType = .display { 50 | didSet { updateEngine() } 51 | } 52 | 53 | @Published var selectedDisplay: SCDisplay? { 54 | didSet { updateEngine() } 55 | } 56 | 57 | @Published var selectedWindow: SCWindow? { 58 | didSet { updateEngine() } 59 | } 60 | 61 | @Published var isAppExcluded = true { 62 | didSet { updateEngine() } 63 | } 64 | 65 | // MARK: - SCContentSharingPicker Properties 66 | @Published var maximumStreamCount = Int() { 67 | didSet { updatePickerConfiguration() } 68 | } 69 | @Published var excludedWindowIDsSelection = Set() { 70 | didSet { updatePickerConfiguration() } 71 | } 72 | 73 | @Published var excludedBundleIDsList = [String]() { 74 | didSet { updatePickerConfiguration() } 75 | } 76 | 77 | @Published var allowsRepicking = true { 78 | didSet { updatePickerConfiguration() } 79 | } 80 | 81 | @Published var allowedPickingModes = SCContentSharingPickerMode() { 82 | didSet { updatePickerConfiguration() } 83 | } 84 | 85 | // MARK: - HDR Preset 86 | @Published var selectedDynamicRangePreset: DynamicRangePreset? { 87 | didSet { updateEngine() } 88 | } 89 | @Published var contentSize = CGSize(width: 1, height: 1) 90 | private var scaleFactor: Int { Int(NSScreen.main?.backingScaleFactor ?? 2) } 91 | 92 | /// A view that renders the screen content. 93 | lazy var capturePreview: CapturePreview = { 94 | CapturePreview() 95 | }() 96 | private let screenRecorderPicker = SCContentSharingPicker.shared 97 | private var availableApps = [SCRunningApplication]() 98 | @Published private(set) var availableDisplays = [SCDisplay]() 99 | @Published private(set) var availableWindows = [SCWindow]() 100 | @Published private(set) var pickerUpdate: Bool = false // Update the running stream immediately with picker selection 101 | private var pickerContentFilter: SCContentFilter? 102 | private var shouldUsePickerFilter = false 103 | /// - Tag: TogglePicker 104 | @Published var isPickerActive = false { 105 | didSet { 106 | if isPickerActive { 107 | logger.info("Picker is active") 108 | self.initializePickerConfiguration() 109 | self.screenRecorderPicker.isActive = true 110 | self.screenRecorderPicker.add(self) 111 | } else { 112 | logger.info("Picker is inactive") 113 | self.screenRecorderPicker.isActive = false 114 | self.screenRecorderPicker.remove(self) 115 | } 116 | } 117 | } 118 | 119 | // MARK: - Audio Properties 120 | @Published var isAudioCaptureEnabled = true { 121 | didSet { 122 | updateEngine() 123 | if isAudioCaptureEnabled { 124 | startAudioMetering() 125 | } else { 126 | stopAudioMetering() 127 | } 128 | } 129 | } 130 | @Published var microphoneId: String? 131 | @Published var isMicCaptureEnabled = false { 132 | didSet { 133 | if isMicCaptureEnabled { 134 | addMicrophoneOutput() 135 | } else { 136 | removeMicrophoneOutput() 137 | } 138 | updateEngine() 139 | } 140 | } 141 | @Published var isRecordingStream = false { 142 | didSet { 143 | if isRecordingStream { 144 | try? initRecordingOutput() 145 | Task { 146 | try await startRecordingOutput() 147 | } 148 | } else { 149 | try? stopRecordingOutput() 150 | } 151 | } 152 | } 153 | @Published var isAppAudioExcluded = false { didSet { updateEngine() } } 154 | @Published private(set) var audioLevelsProvider = AudioLevelsProvider() 155 | // A value that specifies how often to retrieve calculated audio levels. 156 | private let audioLevelRefreshRate: TimeInterval = 0.1 157 | private var audioMeterCancellable: AnyCancellable? 158 | 159 | var recordingOutputPath: String? = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).last 160 | private var recordingOutput: SCRecordingOutput? 161 | 162 | // The object that manages the SCStream. 163 | private let captureEngine = CaptureEngine() 164 | 165 | private var isSetup = false 166 | 167 | // Combine subscribers. 168 | private var subscriptions = Set() 169 | 170 | var canRecord: Bool { 171 | get async { 172 | do { 173 | // If the app doesn't have screen recording permission, this call generates an exception. 174 | try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) 175 | return true 176 | } catch { 177 | return false 178 | } 179 | } 180 | } 181 | 182 | func monitorAvailableContent() async { 183 | guard !isSetup || !isPickerActive else { return } 184 | // Refresh the lists of capturable content. 185 | await self.refreshAvailableContent() 186 | Timer.publish(every: 3, on: .main, in: .common).autoconnect().sink { [weak self] _ in 187 | guard let self = self else { return } 188 | Task { 189 | await self.refreshAvailableContent() 190 | } 191 | } 192 | .store(in: &subscriptions) 193 | } 194 | 195 | /// Starts capturing screen content. 196 | func start() async { 197 | // Exit early if already running. 198 | guard !isRunning else { return } 199 | 200 | if !isSetup { 201 | // Starting polling for available screen content. 202 | await monitorAvailableContent() 203 | isSetup = true 204 | } 205 | 206 | // If the user enables audio capture, start monitoring the audio stream. 207 | if isAudioCaptureEnabled { 208 | startAudioMetering() 209 | } 210 | 211 | do { 212 | let config = streamConfiguration 213 | 214 | let filter = contentFilter 215 | 216 | // Update the running state. 217 | isRunning = true 218 | setPickerUpdate(false) 219 | // Start the stream and await new video frames. 220 | for try await frame in captureEngine.startCapture(configuration: config, filter: filter) { 221 | capturePreview.updateFrame(frame) 222 | if contentSize != frame.size { 223 | // Update the content size if it changed. 224 | contentSize = frame.size 225 | } 226 | } 227 | } catch { 228 | logger.error("\(error.localizedDescription)") 229 | // Unable to start the stream. Set the running state to false. 230 | isRunning = false 231 | } 232 | } 233 | 234 | /// Stops capturing screen content. 235 | func stop() async { 236 | guard isRunning else { return } 237 | await captureEngine.stopCapture() 238 | stopAudioMetering() 239 | try? stopRecordingOutput() 240 | removeMicrophoneOutput() 241 | isRunning = false 242 | } 243 | 244 | func openRecordingFolder() { 245 | if let recordingOutputPath = recordingOutputPath { 246 | NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: recordingOutputPath) 247 | } 248 | } 249 | 250 | private func startAudioMetering() { 251 | audioMeterCancellable = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect().sink { [weak self] _ in 252 | guard let self = self else { return } 253 | self.audioLevelsProvider.audioLevels = self.captureEngine.audioLevels 254 | } 255 | } 256 | 257 | private func stopAudioMetering() { 258 | audioMeterCancellable?.cancel() 259 | audioLevelsProvider.audioLevels = AudioLevels.zero 260 | } 261 | 262 | private func addMicrophoneOutput() { 263 | streamConfiguration.captureMicrophone = true 264 | } 265 | private func removeMicrophoneOutput() { 266 | streamConfiguration.captureMicrophone = false 267 | streamConfiguration.microphoneCaptureDeviceID = nil 268 | } 269 | 270 | private func initRecordingOutput() throws { 271 | let dateFormatter = DateFormatter() 272 | dateFormatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" 273 | let currentDateTime = dateFormatter.string(from: Date()) 274 | if let recordingOutputPath = recordingOutputPath { 275 | let outputPath = "\(recordingOutputPath)/recorded_output_\(currentDateTime).mp4" 276 | let outputURL = URL(fileURLWithPath: outputPath) 277 | let recordingConfiguration = SCRecordingOutputConfiguration() 278 | recordingConfiguration.outputURL = outputURL 279 | guard let recordingOutput = (SCRecordingOutput(configuration: recordingConfiguration, delegate: self) as SCRecordingOutput?) 280 | else { 281 | throw SCScreenRecordingError.failedToStartRecording("🔴Failed to init recording output!") 282 | } 283 | logger.log("Initialized recording output with URL \(outputURL)") 284 | self.recordingOutput = recordingOutput 285 | } 286 | } 287 | 288 | private func startRecordingOutput() async throws { 289 | guard let recordingOutput = self.recordingOutput else { 290 | throw SCScreenRecordingError.failedToStartRecording("Recording output is empty!") 291 | } 292 | 293 | try? await captureEngine.addRecordOutputToStream(recordingOutput) 294 | logger.log("Added recording output \(String(describing: self.recordingOutput)) successfully to stream") 295 | recordingOutputDidStartRecording(recordingOutput) 296 | } 297 | 298 | private func stopRecordingOutput() throws { 299 | if let recordingOutput = self.recordingOutput { 300 | logger.log("Stopping recording output \(recordingOutput)") 301 | try? captureEngine.stopRecordingOutputForStream(recordingOutput) 302 | recordingOutputDidFinishRecording(recordingOutput) 303 | } 304 | self.recordingOutput = nil 305 | } 306 | 307 | /// - Tag: UpdateCaptureConfig 308 | private func updateEngine() { 309 | guard isRunning else { return } 310 | Task { 311 | let filter = contentFilter 312 | await captureEngine.update(configuration: streamConfiguration, filter: filter) 313 | setPickerUpdate(false) 314 | } 315 | } 316 | 317 | // MARK: - Content-sharing Picker 318 | private func initializePickerConfiguration() { 319 | var initialConfiguration = SCContentSharingPickerConfiguration() 320 | // Set the allowedPickerModes from the app. 321 | initialConfiguration.allowedPickerModes = [ 322 | .singleWindow, 323 | .multipleWindows, 324 | .singleApplication, 325 | .multipleApplications, 326 | .singleDisplay 327 | ] 328 | self.allowedPickingModes = initialConfiguration.allowedPickerModes 329 | } 330 | 331 | private func updatePickerConfiguration() { 332 | self.screenRecorderPicker.maximumStreamCount = maximumStreamCount 333 | // Update the default picker configuration to pass to Control Center. 334 | self.screenRecorderPicker.defaultConfiguration = pickerConfiguration 335 | } 336 | 337 | /// - Tag: HandlePicker 338 | nonisolated func contentSharingPicker(_ picker: SCContentSharingPicker, didCancelFor stream: SCStream?) { 339 | logger.info("Picker canceled for stream \(stream)") 340 | } 341 | 342 | nonisolated func contentSharingPicker(_ picker: SCContentSharingPicker, didUpdateWith filter: SCContentFilter, for stream: SCStream?) { 343 | Task { @MainActor in 344 | pickerContentFilter = filter 345 | shouldUsePickerFilter = true 346 | setPickerUpdate(true) 347 | updateEngine() 348 | } 349 | logger.info("Picker updated with filter=\(filter) for stream=\(stream)") 350 | } 351 | 352 | nonisolated func contentSharingPickerStartDidFailWithError(_ error: Error) { 353 | logger.error("Error starting picker! \(error)") 354 | } 355 | 356 | func setPickerUpdate(_ update: Bool) { 357 | Task { @MainActor in 358 | self.pickerUpdate = update 359 | } 360 | } 361 | 362 | func presentPicker() { 363 | if let stream = captureEngine.stream { 364 | SCContentSharingPicker.shared.present(for: stream) 365 | } else { 366 | SCContentSharingPicker.shared.present() 367 | } 368 | } 369 | 370 | private var pickerConfiguration: SCContentSharingPickerConfiguration { 371 | var config = SCContentSharingPickerConfiguration() 372 | config.allowedPickerModes = allowedPickingModes 373 | config.excludedWindowIDs = Array(excludedWindowIDsSelection) 374 | config.excludedBundleIDs = excludedBundleIDsList 375 | config.allowsChangingSelectedContent = allowsRepicking 376 | return config 377 | } 378 | 379 | /// - Tag: UpdateFilter 380 | private var contentFilter: SCContentFilter { 381 | var filter: SCContentFilter 382 | switch captureType { 383 | case .display: 384 | guard let display = selectedDisplay else { fatalError("No display selected.") } 385 | var excludedApps = [SCRunningApplication]() 386 | // If a user chooses to exclude the app from the stream, 387 | // exclude it by matching its bundle identifier. 388 | if isAppExcluded { 389 | excludedApps = availableApps.filter { app in 390 | Bundle.main.bundleIdentifier == app.bundleIdentifier 391 | } 392 | } 393 | // Create a content filter with excluded apps. 394 | filter = SCContentFilter(display: display, 395 | excludingApplications: excludedApps, 396 | exceptingWindows: []) 397 | case .window: 398 | guard let window = selectedWindow else { fatalError("No window selected.") } 399 | 400 | // Create a content filter that includes a single window. 401 | filter = SCContentFilter(desktopIndependentWindow: window) 402 | } 403 | // Use filter from content picker, if active. 404 | if shouldUsePickerFilter { 405 | guard let pickerFilter = pickerContentFilter else { return filter } 406 | filter = pickerFilter 407 | shouldUsePickerFilter = false 408 | } 409 | return filter 410 | } 411 | 412 | private var streamConfiguration: SCStreamConfiguration { 413 | 414 | var streamConfig = SCStreamConfiguration() 415 | 416 | if let dynamicRangePreset = selectedDynamicRangePreset?.scDynamicRangePreset { 417 | streamConfig = SCStreamConfiguration(preset: dynamicRangePreset) 418 | } 419 | 420 | // Configure audio capture. 421 | streamConfig.capturesAudio = isAudioCaptureEnabled 422 | streamConfig.excludesCurrentProcessAudio = isAppAudioExcluded 423 | streamConfig.captureMicrophone = isMicCaptureEnabled 424 | 425 | // Configure the display content width and height. 426 | if captureType == .display, let display = selectedDisplay { 427 | streamConfig.width = display.width * scaleFactor 428 | streamConfig.height = display.height * scaleFactor 429 | } 430 | 431 | // Configure the window content width and height. 432 | if captureType == .window, let window = selectedWindow { 433 | streamConfig.width = Int(window.frame.width) * 2 434 | streamConfig.height = Int(window.frame.height) * 2 435 | } 436 | 437 | // Set the capture interval at 60 fps. 438 | streamConfig.minimumFrameInterval = CMTime(value: 1, timescale: 60) 439 | 440 | // Increase the depth of the frame queue to ensure high fps at the expense of increasing 441 | // the memory footprint of WindowServer. 442 | streamConfig.queueDepth = 5 443 | 444 | return streamConfig 445 | } 446 | 447 | /// - Tag: GetAvailableContent 448 | private func refreshAvailableContent() async { 449 | do { 450 | // Retrieve the available screen content to capture. 451 | let availableContent = try await SCShareableContent.excludingDesktopWindows(false, 452 | onScreenWindowsOnly: true) 453 | availableDisplays = availableContent.displays 454 | 455 | let windows = filterWindows(availableContent.windows) 456 | if windows != availableWindows { 457 | availableWindows = windows 458 | } 459 | availableApps = availableContent.applications 460 | 461 | if selectedDisplay == nil { 462 | selectedDisplay = availableDisplays.first 463 | } 464 | if selectedWindow == nil { 465 | selectedWindow = availableWindows.first 466 | } 467 | } catch { 468 | logger.error("Failed to get the shareable content: \(error.localizedDescription)") 469 | } 470 | } 471 | 472 | private func filterWindows(_ windows: [SCWindow]) -> [SCWindow] { 473 | windows 474 | // Sort the windows by app name. 475 | .sorted { $0.owningApplication?.applicationName ?? "" < $1.owningApplication?.applicationName ?? "" } 476 | // Remove windows that don't have an associated .app bundle. 477 | .filter { $0.owningApplication != nil && $0.owningApplication?.applicationName != "" } 478 | // Remove this app's window from the list. 479 | .filter { $0.owningApplication?.bundleIdentifier != Bundle.main.bundleIdentifier } 480 | } 481 | } 482 | 483 | extension SCWindow { 484 | var displayName: String { 485 | switch (owningApplication, title) { 486 | case (.some(let application), .some(let title)): 487 | return "\(application.applicationName): \(title)" 488 | case (.none, .some(let title)): 489 | return title 490 | case (.some(let application), .none): 491 | return "\(application.applicationName): \(windowID)" 492 | default: 493 | return "" 494 | } 495 | } 496 | } 497 | 498 | extension SCDisplay { 499 | var displayName: String { 500 | "Display: \(width) x \(height)" 501 | } 502 | } 503 | 504 | extension ScreenRecorder: SCRecordingOutputDelegate { 505 | // MARK: SCRecordingOutputDelegate 506 | @available(macOS 15.0, *) 507 | nonisolated func recordingOutputDidStartRecording(_ recordingOutput: SCRecordingOutput) { 508 | logger.log("Recording output \(recordingOutput) did start recording") 509 | } 510 | 511 | @available(macOS 15.0, *) 512 | nonisolated func recordingOutputDidFinishRecording(_ recordingOutput: SCRecordingOutput) { 513 | logger.log("Recording output \(recordingOutput) did finish recording") 514 | } 515 | } 516 | 517 | enum SCScreenRecordingError: Error { 518 | case failedToStartRecording(String) 519 | } 520 | 521 | -------------------------------------------------------------------------------- /shell/polaris.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import AppKit 3 | import ScreenCaptureKit 4 | import CoreGraphics 5 | import CoreMedia 6 | import CoreImage 7 | import UniformTypeIdentifiers 8 | 9 | /// Keycode for keyboard input 10 | /// 11 | /// Example 12 | /// ```swift 13 | /// // Send ctrl + ← 14 | /// import PolarisGUI 15 | /// PolarisGUI.sendKeyShortcut([.control, .leftArrow]) 16 | /// 17 | /// // Send sound up 18 | /// PolarisGUI.keyDown(.soundUp) 19 | /// PolarisGUI.keyUp(.soundUp) 20 | /// ``` 21 | public enum Key { 22 | 23 | // normal keycode 24 | case returnKey 25 | case enter 26 | case tab 27 | case space 28 | case delete 29 | case escape 30 | case command 31 | case shift 32 | case capsLock 33 | case option 34 | case control 35 | case rightShift 36 | case rightOption 37 | case rightControl 38 | case leftArrow 39 | case rightArrow 40 | case downArrow 41 | case upArrow 42 | case volumeUp 43 | case volumeDown 44 | case mute 45 | case help 46 | case home 47 | case pageUp 48 | case forwardDelete 49 | case end 50 | case pageDown 51 | case function 52 | case f1 53 | case f2 54 | case f4 55 | case f5 56 | case f6 57 | case f7 58 | case f3 59 | case f8 60 | case f9 61 | case f10 62 | case f11 63 | case f12 64 | case f13 65 | case f14 66 | case f15 67 | case f16 68 | case f17 69 | case f18 70 | case f19 71 | case f20 72 | 73 | case a 74 | case b 75 | case c 76 | case d 77 | case e 78 | case f 79 | case g 80 | case h 81 | case i 82 | case j 83 | case k 84 | case l 85 | case m 86 | case n 87 | case o 88 | case p 89 | case q 90 | case r 91 | case s 92 | case t 93 | case u 94 | case v 95 | case w 96 | case x 97 | case y 98 | case z 99 | 100 | case zero 101 | case one 102 | case two 103 | case three 104 | case four 105 | case five 106 | case six 107 | case seven 108 | case eight 109 | case nine 110 | 111 | case equals 112 | case minus 113 | case semicolon 114 | case apostrophe 115 | case comma 116 | case period 117 | case forwardSlash 118 | case backslash 119 | case grave 120 | case leftBracket 121 | case rightBracket 122 | 123 | case keypadDecimal 124 | case keypadMultiply 125 | case keypadPlus 126 | case keypadClear 127 | case keypadDivide 128 | case keypadEnter 129 | case keypadMinus 130 | case keypadEquals 131 | case keypad0 132 | case keypad1 133 | case keypad2 134 | case keypad3 135 | case keypad4 136 | case keypad5 137 | case keypad6 138 | case keypad7 139 | case keypad8 140 | case keypad9 141 | 142 | // special keycode 143 | // TODO: add other code 144 | case soundUp 145 | case soundDown 146 | case brightnessUp 147 | case brightnessDown 148 | case numLock 149 | 150 | 151 | /// Normal keycode of the key. 152 | public var normalKeycode: CGKeyCode? { 153 | switch self { 154 | case .returnKey : return 0x24 155 | case .enter : return 0x4C 156 | case .tab : return 0x30 157 | case .space : return 0x31 158 | case .delete : return 0x33 159 | case .escape : return 0x35 160 | case .command : return 0x37 161 | case .shift : return 0x38 162 | case .capsLock : return 0x39 163 | case .option : return 0x3A 164 | case .control : return 0x3B 165 | case .rightShift : return 0x3C 166 | case .rightOption : return 0x3D 167 | case .rightControl : return 0x3E 168 | case .leftArrow : return 0x7B 169 | case .rightArrow : return 0x7C 170 | case .downArrow : return 0x7D 171 | case .upArrow : return 0x7E 172 | case .volumeUp : return 0x48 173 | case .volumeDown : return 0x49 174 | case .mute : return 0x4A 175 | case .help : return 0x72 176 | case .home : return 0x73 177 | case .pageUp : return 0x74 178 | case .forwardDelete : return 0x75 179 | case .end : return 0x77 180 | case .pageDown : return 0x79 181 | case .function : return 0x3F 182 | case .f1 : return 0x7A 183 | case .f2 : return 0x78 184 | case .f4 : return 0x76 185 | case .f5 : return 0x60 186 | case .f6 : return 0x61 187 | case .f7 : return 0x62 188 | case .f3 : return 0x63 189 | case .f8 : return 0x64 190 | case .f9 : return 0x65 191 | case .f10 : return 0x6D 192 | case .f11 : return 0x67 193 | case .f12 : return 0x6F 194 | case .f13 : return 0x69 195 | case .f14 : return 0x6B 196 | case .f15 : return 0x71 197 | case .f16 : return 0x6A 198 | case .f17 : return 0x40 199 | case .f18 : return 0x4F 200 | case .f19 : return 0x50 201 | case .f20 : return 0x5A 202 | 203 | case .a : return 0x00 204 | case .b : return 0x0B 205 | case .c : return 0x08 206 | case .d : return 0x02 207 | case .e : return 0x0E 208 | case .f : return 0x03 209 | case .g : return 0x05 210 | case .h : return 0x04 211 | case .i : return 0x22 212 | case .j : return 0x26 213 | case .k : return 0x28 214 | case .l : return 0x25 215 | case .m : return 0x2E 216 | case .n : return 0x2D 217 | case .o : return 0x1F 218 | case .p : return 0x23 219 | case .q : return 0x0C 220 | case .r : return 0x0F 221 | case .s : return 0x01 222 | case .t : return 0x11 223 | case .u : return 0x20 224 | case .v : return 0x09 225 | case .w : return 0x0D 226 | case .x : return 0x07 227 | case .y : return 0x10 228 | case .z : return 0x06 229 | 230 | case .zero : return 0x1D 231 | case .one : return 0x12 232 | case .two : return 0x13 233 | case .three : return 0x14 234 | case .four : return 0x15 235 | case .five : return 0x17 236 | case .six : return 0x16 237 | case .seven : return 0x1A 238 | case .eight : return 0x1C 239 | case .nine : return 0x19 240 | 241 | case .equals : return 0x18 242 | case .minus : return 0x1B 243 | case .semicolon : return 0x29 244 | case .apostrophe : return 0x27 245 | case .comma : return 0x2B 246 | case .period : return 0x2F 247 | case .forwardSlash : return 0x2C 248 | case .backslash : return 0x2A 249 | case .grave : return 0x32 250 | case .leftBracket : return 0x21 251 | case .rightBracket : return 0x1E 252 | 253 | case .keypadDecimal : return 0x41 254 | case .keypadMultiply : return 0x43 255 | case .keypadPlus : return 0x45 256 | case .keypadClear : return 0x47 257 | case .keypadDivide : return 0x4B 258 | case .keypadEnter : return 0x4C 259 | case .keypadMinus : return 0x4E 260 | case .keypadEquals : return 0x51 261 | case .keypad0 : return 0x52 262 | case .keypad1 : return 0x53 263 | case .keypad2 : return 0x54 264 | case .keypad3 : return 0x55 265 | case .keypad4 : return 0x56 266 | case .keypad5 : return 0x57 267 | case .keypad6 : return 0x58 268 | case .keypad7 : return 0x59 269 | case .keypad8 : return 0x5B 270 | case .keypad9 : return 0x5C 271 | default : return nil 272 | } 273 | } 274 | 275 | /// Special keycodes for media keys and other special keys 276 | public var specialKeycode: Int32? { 277 | switch self { 278 | case .soundUp : return NX_KEYTYPE_SOUND_UP 279 | case .soundDown : return NX_KEYTYPE_SOUND_DOWN 280 | case .brightnessUp : return NX_KEYTYPE_BRIGHTNESS_UP 281 | case .brightnessDown : return NX_KEYTYPE_BRIGHTNESS_DOWN 282 | case .numLock : return NX_KEYTYPE_NUM_LOCK 283 | default : return nil 284 | } 285 | } 286 | } 287 | 288 | public class PolarisGUI: NSObject { 289 | 290 | // MARK: Key Event 291 | 292 | /// Send a key shortcut 293 | /// 294 | /// - Parameter keys: The keys to be pressed. The order of the keys is the order of pressing. 295 | /// For example, if you want to press `command + c`, you should call `sendKeyShortcut([.command, .c])`. The details of Key is in ``Key`` 296 | /// 297 | /// Example 298 | /// ```swift 299 | /// // Send ctrl + ← 300 | /// import PolarisGUI 301 | /// PolarisGUI.sendKeyShortcut([.control, .leftArrow]) 302 | /// 303 | /// // Send sound up 304 | /// PolarisGUI.keyDown(.soundUp) 305 | /// PolarisGUI.keyUp(.soundUp) 306 | /// ``` 307 | 308 | private var stream: SCStream? 309 | private let semaphore = DispatchSemaphore(value: 0) 310 | private var latestImage: CGImage? 311 | 312 | 313 | @discardableResult 314 | public func takeScreenshot() async throws -> Bool { 315 | do { 316 | guard let display = try await SCShareableContent.current.displays.first else { 317 | print("No display found") 318 | return false 319 | } 320 | 321 | let filter = SCContentFilter(display: display, excludingApplications: [], exceptingWindows: []) 322 | let configuration = SCStreamConfiguration() 323 | 324 | // Set the configuration properties 325 | configuration.width = display.width * 2 // multiply by 2 for Retina displays 326 | configuration.height = display.height * 2 327 | configuration.minimumFrameInterval = CMTime(value: 1, timescale: 1) 328 | configuration.queueDepth = 1 329 | 330 | // Capture the screenshot 331 | let sample = try await SCScreenshotManager.captureImage(contentFilter: filter, 332 | configuration: configuration) 333 | 334 | // Setup save location 335 | let screenshotPath = FileManager.default.homeDirectoryForCurrentUser 336 | .appendingPathComponent("Desktop") 337 | .appendingPathComponent("Screenshots") 338 | 339 | try FileManager.default.createDirectory(at: screenshotPath, 340 | withIntermediateDirectories: true) 341 | 342 | let formatter = DateFormatter() 343 | formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" 344 | let timestamp = formatter.string(from: Date()) 345 | let destination = screenshotPath.appendingPathComponent("screenshot_\(timestamp).png") 346 | 347 | // Save the file 348 | guard let cgDestination = CGImageDestinationCreateWithURL(destination as CFURL, UTType.png.identifier as CFString, 1, nil) else { 349 | print("Failed to create image destination") 350 | return false 351 | } 352 | 353 | CGImageDestinationAddImage(cgDestination, sample, nil) 354 | let success = CGImageDestinationFinalize(cgDestination) 355 | 356 | if success { 357 | print("Screenshot taken!") 358 | } else { 359 | print("Failed to save screenshot") 360 | } 361 | 362 | return success 363 | 364 | } catch { 365 | print("Screenshot failed with error: \(error)") 366 | return false 367 | } 368 | } 369 | 370 | private func saveScreenshot(_ image: CGImage) { 371 | let dateFormatter = DateFormatter() 372 | dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" 373 | let filename = "screenshot-\(dateFormatter.string(from: Date())).png" 374 | 375 | let url = FileManager.default.currentDirectoryPath + "/" + filename 376 | let destination = CGImageDestinationCreateWithURL( 377 | URL(fileURLWithPath: url) as CFURL, 378 | UTType.png.identifier as CFString, 379 | 1, 380 | nil 381 | ) 382 | 383 | if let destination = destination { 384 | CGImageDestinationAddImage(destination, image, nil) 385 | CGImageDestinationFinalize(destination) 386 | print("Screenshot saved as: \(filename)") 387 | } 388 | } 389 | 390 | 391 | public static func sendKeyShortcut(_ keys: [Key]) { 392 | for key in keys { 393 | keyDown(key) 394 | } 395 | for key in keys.reversed() { 396 | keyUp(key) 397 | } 398 | } 399 | 400 | /// Press a key 401 | /// 402 | /// - Parameter key: The key to be pressed. The details of Key is in ``Key`` 403 | public static func keyDown(_ key: Key) { 404 | if let normalKeycode = key.normalKeycode { 405 | normalKeyEvent(normalKeycode, down: true) 406 | } else if let specialKeycode = key.specialKeycode { 407 | specialKeyEvent(specialKeycode, down: true) 408 | } 409 | } 410 | 411 | /// Release a key 412 | /// 413 | /// - Parameter key: The key to be released. 414 | public static func keyUp(_ key: Key) { 415 | if let normalKeycode = key.normalKeycode { 416 | normalKeyEvent(normalKeycode, down: false) 417 | } else if let specialKeycode = key.specialKeycode { 418 | specialKeyEvent(specialKeycode, down: false) 419 | } 420 | } 421 | 422 | /// Press a key and release it 423 | /// 424 | /// - Parameter key: The key to be pressed and released. The details of Key is in `normalKeycode` of ``Key`` 425 | private static func normalKeyEvent(_ key: CGKeyCode, down: Bool) { 426 | let source = CGEventSource(stateID: .hidSystemState) 427 | let event = CGEvent(keyboardEventSource: source, virtualKey: key, keyDown: down) 428 | event?.post(tap: .cghidEventTap) 429 | Thread.sleep(forTimeInterval: 0.01) 430 | } 431 | 432 | /// Press a special key and release it 433 | /// 434 | /// - Parameter key: The key to be pressed and released. The details of Key is in `specialKeycode` of ``Key`` 435 | private static func specialKeyEvent(_ key: Int32, down: Bool) { 436 | let modifierFlags = NSEvent.ModifierFlags(rawValue: down ? 0xA00 : 0xB00) 437 | let nsEvent = NSEvent.otherEvent( 438 | with: .systemDefined, 439 | location: NSPoint(x: 0, y: 0), 440 | modifierFlags: modifierFlags, 441 | timestamp: 0, 442 | windowNumber: 0, 443 | context: nil, 444 | subtype: 8, 445 | data1: Int((key << 16)) | ((down ? 0xA : 0xB) << 8), 446 | data2: -1 447 | ) 448 | let cgEvent = nsEvent?.cgEvent 449 | cgEvent?.post(tap: .cghidEventTap) 450 | Thread.sleep(forTimeInterval: 0.01) 451 | } 452 | 453 | // MARK: Mouse Event 454 | 455 | /// Move mouse by dx, dy from the current location 456 | /// 457 | /// - Parameters: 458 | /// - dx: The distance to move in the x-axis 459 | /// - dy: The distance to move in the y-axis 460 | public static func moveMouse(dx: CGFloat, dy: CGFloat) { 461 | var mouseLoc = NSEvent.mouseLocation 462 | mouseLoc.y = NSHeight(NSScreen.screens[0].frame) - mouseLoc.y; 463 | let newLoc = CGPoint(x: mouseLoc.x-CGFloat(dx), y: mouseLoc.y + CGFloat(dy)) 464 | CGDisplayMoveCursorToPoint(0, newLoc) 465 | Thread.sleep(forTimeInterval: 0.01) 466 | } 467 | 468 | /// Move the mouse to a specific position 469 | /// - Parameter to: This parameter is the `CGWindow` coordinate. 470 | public static func move(to: CGPoint) { 471 | CGDisplayMoveCursorToPoint(0, to) 472 | Thread.sleep(forTimeInterval: 0.01) 473 | } 474 | 475 | /// Press the left mouse button at a current position 476 | public static func leftClick() { 477 | var mouseLoc = NSEvent.mouseLocation 478 | mouseLoc = CGPoint(x: mouseLoc.x, y: NSHeight(NSScreen.screens[0].frame) - mouseLoc.y) 479 | leftClickDown(position: mouseLoc) 480 | leftClickUp(position: mouseLoc) 481 | } 482 | 483 | /// Press the left mouse button at a current position 484 | public static func rightClick() { 485 | var mouseLoc = NSEvent.mouseLocation 486 | mouseLoc = CGPoint(x: mouseLoc.x, y: NSHeight(NSScreen.screens[0].frame) - mouseLoc.y) 487 | rightClickDown(position: mouseLoc) 488 | rightClickUp(position: mouseLoc) 489 | } 490 | 491 | /// Dragg the left mouse button from a position to another position 492 | /// 493 | /// - Parameters: 494 | /// - to: The position to drag to 495 | /// - from: The position to drag from 496 | public static func leftDragged(to: CGPoint, from: CGPoint) { 497 | leftClickDown(position: from) 498 | let source = CGEventSource(stateID: CGEventSourceStateID.hidSystemState) 499 | let event = CGEvent(mouseEventSource: source, mouseType: CGEventType.leftMouseDragged, 500 | mouseCursorPosition: to, mouseButton: CGMouseButton.left) 501 | event?.post(tap: CGEventTapLocation.cghidEventTap) 502 | leftClickUp(position: to) 503 | } 504 | 505 | /// Scroll the mouse wheel vertically 506 | /// 507 | /// - Parameter clicks: The number of clicks to scroll. Positive value means scroll up, negative value means scroll down. 508 | public static func vscroll(clicks: Int) { 509 | for _ in 0...Int(abs(clicks) / 10) { 510 | let scrollEvent = CGEvent( 511 | scrollWheelEvent2Source: nil, 512 | units: .line, 513 | wheelCount: 1, 514 | wheel1: clicks >= 0 ? 10 : -10, 515 | wheel2: 0, 516 | wheel3: 0 517 | ) 518 | scrollEvent?.post(tap: .cghidEventTap) 519 | } 520 | 521 | let scrollEvent = CGEvent( 522 | scrollWheelEvent2Source: nil, 523 | units: .line, 524 | wheelCount: 1, 525 | wheel1: Int32(clicks >= 0 ? clicks % 10 : -1 * (-clicks % 10)), 526 | wheel2: 0, 527 | wheel3: 0 528 | ) 529 | scrollEvent?.post(tap: .cghidEventTap) 530 | } 531 | 532 | /// Scroll the mouse wheel horizontally 533 | /// 534 | /// - Parameter clicks: The number of clicks to scroll. Positive value means scroll left, negative value means scroll right. 535 | public static func hscroll(clicks: Int) { 536 | for _ in 0...Int(abs(clicks) / 10) { 537 | let scrollEvent = CGEvent( 538 | scrollWheelEvent2Source: nil, 539 | units: .line, 540 | wheelCount: 2, 541 | wheel1: 0, 542 | wheel2: clicks >= 0 ? 10 : -10, 543 | wheel3: 0 544 | ) 545 | scrollEvent?.post(tap: .cghidEventTap) 546 | } 547 | 548 | let scrollEvent = CGEvent( 549 | scrollWheelEvent2Source: nil, 550 | units: .line, 551 | wheelCount: 2, 552 | wheel1: 0, 553 | wheel2: Int32(clicks >= 0 ? clicks % 10 : -1 * (-clicks % 10)), 554 | wheel3: 0 555 | ) 556 | scrollEvent?.post(tap: .cghidEventTap) 557 | } 558 | 559 | private static func leftClickDown(position: CGPoint) { 560 | let source = CGEventSource(stateID: CGEventSourceStateID.hidSystemState) 561 | let event = CGEvent(mouseEventSource: source, mouseType: CGEventType.leftMouseDown, 562 | mouseCursorPosition: position, mouseButton: CGMouseButton.left) 563 | event?.post(tap: CGEventTapLocation.cghidEventTap) 564 | } 565 | 566 | private static func leftClickUp(position: CGPoint) { 567 | let source = CGEventSource(stateID: CGEventSourceStateID.hidSystemState) 568 | let event = CGEvent(mouseEventSource: source, mouseType: CGEventType.leftMouseUp, 569 | mouseCursorPosition: position, mouseButton: CGMouseButton.left) 570 | event?.post(tap: CGEventTapLocation.cghidEventTap) 571 | } 572 | 573 | private static func rightClickDown(position: CGPoint) { 574 | let source = CGEventSource(stateID: CGEventSourceStateID.hidSystemState) 575 | let event = CGEvent(mouseEventSource: source, mouseType: CGEventType.rightMouseDown, 576 | mouseCursorPosition: position, mouseButton: CGMouseButton.right) 577 | event?.post(tap: CGEventTapLocation.cghidEventTap) 578 | } 579 | 580 | private static func rightClickUp(position: CGPoint) { 581 | let source = CGEventSource(stateID: CGEventSourceStateID.hidSystemState) 582 | let event = CGEvent(mouseEventSource: source, mouseType: CGEventType.rightMouseUp, 583 | mouseCursorPosition: position, mouseButton: CGMouseButton.right) 584 | event?.post(tap: CGEventTapLocation.cghidEventTap) 585 | } 586 | } 587 | 588 | extension PolarisGUI: SCStreamOutput { 589 | public func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { 590 | print("Received stream output of type: \(type)") 591 | 592 | guard type == .screen else { 593 | print("Ignoring non-screen buffer") 594 | return 595 | } 596 | 597 | guard let imageBuffer = sampleBuffer.imageBuffer else { 598 | print("No image buffer in sample") 599 | return 600 | } 601 | 602 | print("Creating CIImage from buffer...") 603 | let ciImage = CIImage(cvImageBuffer: imageBuffer) 604 | 605 | print("Creating CGImage...") 606 | let context = CIContext() 607 | if let cgImage = context.createCGImage(ciImage, from: ciImage.extent) { 608 | print("CGImage created successfully") 609 | latestImage = cgImage 610 | semaphore.signal() 611 | } else { 612 | print("Failed to create CGImage") 613 | } 614 | } 615 | } 616 | 617 | // MARK: - Error Types 618 | enum ScreenCaptureError: Error { 619 | case failedToCreateConfiguration 620 | case failedToCreateStream 621 | case failedToTakeScreenshot 622 | } 623 | 624 | // MARK: - Timeout Helper 625 | func runAsyncCommand(_ closure: @escaping () async throws -> Void) { 626 | let semaphore = DispatchSemaphore(value: 0) 627 | 628 | Task { 629 | do { 630 | try await closure() 631 | semaphore.signal() 632 | } catch { 633 | print("Error: \(error)") 634 | semaphore.signal() 635 | } 636 | } 637 | 638 | _ = semaphore.wait(timeout: .now() + 10.0) // 10 second timeout 639 | 640 | } 641 | 642 | func withTimeout(seconds: TimeInterval, closure: @escaping () async throws -> T) async throws -> T { 643 | try await withThrowingTaskGroup(of: T.self) { group in 644 | group.addTask { 645 | try await closure() 646 | } 647 | 648 | group.addTask { 649 | try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) 650 | throw ScreenCaptureError.failedToTakeScreenshot 651 | } 652 | 653 | let result = try await group.next()! 654 | group.cancelAll() 655 | return result 656 | } 657 | } 658 | 659 | 660 | class PolarisShell { 661 | private let tool = PolarisGUI() 662 | 663 | private let primary = "" 664 | private let deepPurple = "\u{001B}[38;5;104m" // Deep purple for headers" 665 | private let purple = "\u{001B}[38;5;183m" // Light purple for headers 666 | private let cyan = "\u{001B}[38;5;81m" // Cyan for commands 667 | private let gray = "\u{001B}[38;5;245m" // Gray for descriptions 668 | // private let yellow = "\u{001B}[38;5;222m" // Yellow for parameters 669 | // private let green = "\u{001B}[38;5;114m" // Green for examples 670 | 671 | 672 | func printUsage() { 673 | print(""" 674 | \(deepPurple)Available commands:\(reset) 675 | 676 | \(deepPurple)MOUSE:\(reset) 677 | \(primary)move\(reset) \(primary) \(reset) \(gray)- Move mouse relative to current position\(reset) 678 | \(primary)moveto\(reset) \(primary) \(reset) \(gray)- Move mouse to absolute position\(reset) 679 | \(primary)click\(reset) \(gray)- Left click\(reset) 680 | \(primary)rclick\(reset) \(gray)- Right click\(reset) 681 | \(primary)drag\(reset) \(primary) \(reset) \(gray)- Drag from (x1,y1) to (x2,y2)\(reset) 682 | \(primary)scrollv\(reset) \(primary)\(reset) \(gray)- Scroll vertically (positive = up)\(reset) 683 | \(primary)scrollh\(reset) \(primary)\(reset) \(gray)- Scroll horizontally (positive = right)\(reset) 684 | \(primary)pos\(reset) \(gray)- Show current mouse position\(reset) 685 | 686 | \(deepPurple)KEYBOARD:\(reset) 687 | \(primary)key\(reset) \(primary)\(reset) \(gray)- Press a single key\(reset) 688 | \(primary)keys\(reset) \(primary) \(reset) \(gray)- Press key combination\(reset) 689 | 690 | \(deepPurple)SCREENSHOT:\(reset) 691 | \(primary)shot\(reset) \(gray)- Take a screenshot\(reset) 692 | 693 | \(deepPurple)OTHER:\(reset) 694 | \(primary)help\(reset) \(gray)- Show this help\(reset) 695 | \(primary)exit\(reset) \(gray)- Exit the program\(reset) 696 | 697 | \(deepPurple)Examples:\(reset) 698 | \(primary)> moveto 500 500\(reset) \(gray)(Move mouse to x=500, y=500)\(reset) 699 | \(primary)> click\(reset) \(gray)(Perform left click)\(reset) 700 | \(primary)> keys command c\(reset) \(gray)(Copy)\(reset) 701 | \(primary)> keys command v\(reset) \(gray)(Paste)\(reset) 702 | """) 703 | } 704 | 705 | private let colors = [ 706 | "\u{001B}[38;5;183m", // Light purple 707 | "\u{001B}[38;5;147m", // Lighter purple 708 | "\u{001B}[38;5;146m", // Medium purple 709 | "\u{001B}[38;5;104m", // Deeper purple 710 | "\u{001B}[38;5;98m", // Dark purple 711 | "\u{001B}[38;5;92m" // Indigo 712 | ] 713 | private let reset = "\u{001B}[0m" 714 | 715 | private func printBanner() { 716 | let banner = [ 717 | #" ______ _ _"#, 718 | #"(_____ \ | | (_)"#, 719 | #" _____) )__ | | _____ ____ _ ___"#, 720 | #"| ____/ _ \| |(____ |/ ___) |/___) "#, 721 | #"| | | |_| | |/ ___ | | | |___ |"#, 722 | #"|_| \___/ \_)_____|_| |_(___/"# 723 | ] 724 | 725 | for (index, line) in banner.enumerated() { 726 | print(colors[index] + line + reset) 727 | } 728 | } 729 | 730 | func start() { 731 | printBanner() 732 | print("\nWelcome to Polaris Interactive Shell!") 733 | print("Type 'help' for available commands or 'exit' to quit.") 734 | 735 | while true { 736 | print("\n\(purple)polaris>\(reset) ", terminator: "") 737 | guard let input = readLine()?.trimmingCharacters(in: .whitespaces) else { exit(0) } 738 | 739 | let components = input.split(separator: " ").map(String.init) 740 | guard let command = components.first else { continue } 741 | 742 | switch command { 743 | case "exit", "quit": 744 | return 745 | 746 | case "help": 747 | printUsage() 748 | 749 | case "move": 750 | guard components.count == 3, 751 | let dx = Double(components[1]), 752 | let dy = Double(components[2]) else { 753 | print("Usage: move ") 754 | continue 755 | } 756 | PolarisGUI.moveMouse(dx: CGFloat(dx), dy: CGFloat(dy)) 757 | print("Moved by (\(dx), \(dy))") 758 | 759 | case "moveto": 760 | guard components.count == 3, 761 | let x = Double(components[1]), 762 | let y = Double(components[2]) else { 763 | print("Usage: moveto ") 764 | continue 765 | } 766 | PolarisGUI.move(to: CGPoint(x: x, y: y)) 767 | print("Moved to (\(x), \(y))") 768 | 769 | case "click": 770 | PolarisGUI.leftClick() 771 | print("Left click") 772 | 773 | case "rclick": 774 | PolarisGUI.rightClick() 775 | print("Right click") 776 | 777 | case "drag": 778 | guard components.count == 5, 779 | let x1 = Double(components[1]), 780 | let y1 = Double(components[2]), 781 | let x2 = Double(components[3]), 782 | let y2 = Double(components[4]) else { 783 | print("Usage: drag ") 784 | continue 785 | } 786 | PolarisGUI.leftDragged( 787 | to: CGPoint(x: x2, y: y2), 788 | from: CGPoint(x: x1, y: y1) 789 | ) 790 | print("Dragged from (\(x1), \(y1)) to (\(x2), \(y2))") 791 | 792 | case "scrollv": 793 | guard components.count == 2, 794 | let clicks = Int(components[1]) else { 795 | print("Usage: scrollv ") 796 | continue 797 | } 798 | PolarisGUI.vscroll(clicks: clicks) 799 | print("Scrolled vertically by \(clicks)") 800 | 801 | case "scrollh": 802 | guard components.count == 2, 803 | let clicks = Int(components[1]) else { 804 | print("Usage: scrollh ") 805 | continue 806 | } 807 | PolarisGUI.hscroll(clicks: clicks) 808 | print("Scrolled horizontally by \(clicks)") 809 | 810 | case "pos": 811 | var mouseLoc = NSEvent.mouseLocation 812 | mouseLoc.y = NSHeight(NSScreen.screens[0].frame) - mouseLoc.y 813 | print("Mouse position: (\(Int(mouseLoc.x)), \(Int(mouseLoc.y)))") 814 | 815 | case "key": 816 | guard components.count == 2 else { 817 | print("Usage: key ") 818 | continue 819 | } 820 | guard let key = keyMapping[components[1].lowercased()] else { 821 | print("Unknown key: \(components[1])") 822 | print("Available keys: \(Array(keyMapping.keys).sorted().joined(separator: ", "))") 823 | continue 824 | } 825 | PolarisGUI.keyDown(key) 826 | PolarisGUI.keyUp(key) 827 | print("Pressed: \(components[1])") 828 | 829 | case "keys": 830 | guard components.count >= 3 else { 831 | print("Usage: keys ...") 832 | continue 833 | } 834 | let keys = components[1...].compactMap { keyMapping[$0.lowercased()] } 835 | if keys.count != components.count - 1 { 836 | print("Some keys were not recognized") 837 | continue 838 | } 839 | PolarisGUI.sendKeyShortcut(Array(keys)) 840 | print("Pressed: \(components[1...].joined(separator: " + "))") 841 | 842 | case "shot": 843 | if #available(macOS 14.0, *) { 844 | print("Taking screenshot...") 845 | runAsyncCommand { [self] in 846 | if try await self.tool.takeScreenshot() { 847 | print("Screenshot completed successfully") 848 | } else { 849 | print("Screenshot failed") 850 | } 851 | } 852 | } else { 853 | print("Screenshot requires macOS 12.3 or later") 854 | } 855 | 856 | default: 857 | print("Unknown command: \(command)") 858 | print("Type 'help' for available commands") 859 | } 860 | } 861 | } 862 | } 863 | 864 | let keyMapping: [String: Key] = [ 865 | "a": .a, "b": .b, "c": .c, "d": .d, "e": .e, 866 | "f": .f, "g": .g, "h": .h, "i": .i, "j": .j, 867 | "k": .k, "l": .l, "m": .m, "n": .n, "o": .o, 868 | "p": .p, "q": .q, "r": .r, "s": .s, "t": .t, 869 | "u": .u, "v": .v, "w": .w, "x": .x, "y": .y, 870 | "z": .z, 871 | "command": .command, "shift": .shift, "control": .control, 872 | "option": .option, "space": .space, "enter": .enter, 873 | "tab": .tab, "escape": .escape, "delete": .delete, 874 | "return": .returnKey, 875 | "left": .leftArrow, "right": .rightArrow, 876 | "up": .upArrow, "down": .downArrow, 877 | "0": .zero, "1": .one, "2": .two, "3": .three, 878 | "4": .four, "5": .five, "6": .six, "7": .seven, 879 | "8": .eight, "9": .nine 880 | ] 881 | 882 | // Create and start the shell 883 | let shell = PolarisShell() 884 | shell.start() --------------------------------------------------------------------------------