├── 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 |
3 |
4 | Polaris
5 |
6 | 
7 | [](https://github.com/apple/swift)
8 | [](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 |
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()
--------------------------------------------------------------------------------