├── default.profraw ├── DockAnchor ├── .DS_Store ├── Assets.xcassets │ ├── Contents.json │ ├── AppIcon.appiconset │ │ ├── AppIcon_1024x1024x32.png │ │ ├── AppIcon_256x256x32 1.png │ │ ├── AppIcon_256x256x32.png │ │ ├── AppIcon_512x512x32 1.png │ │ ├── AppIcon_512x512x32.png │ │ └── Contents.json │ └── AccentColor.colorset │ │ └── Contents.json ├── DockAnchor.xcdatamodeld │ ├── .xccurrentversion │ └── DockAnchor.xcdatamodel │ │ └── contents ├── DockAnchor.entitlements ├── Info.plist ├── Persistence.swift ├── AppSettings.swift ├── UpdateChecker.swift ├── ContentView.swift ├── DockAnchorApp.swift └── DockMonitor.swift ├── images ├── main_ui.png ├── menu_bar.png ├── settings.png ├── settings_ui.png └── monitor_and_block.png ├── DockAnchor.xcodeproj ├── project.xcworkspace │ └── contents.xcworkspacedata └── project.pbxproj ├── ExportOptions.plist ├── DockAnchorTests └── DockAnchorTests.swift ├── DockAnchorUITests ├── DockAnchorUITestsLaunchTests.swift └── DockAnchorUITests.swift ├── LICENSE ├── .gitignore └── README.md /default.profraw: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /DockAnchor/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bwya77/DockAnchor/HEAD/DockAnchor/.DS_Store -------------------------------------------------------------------------------- /images/main_ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bwya77/DockAnchor/HEAD/images/main_ui.png -------------------------------------------------------------------------------- /images/menu_bar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bwya77/DockAnchor/HEAD/images/menu_bar.png -------------------------------------------------------------------------------- /images/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bwya77/DockAnchor/HEAD/images/settings.png -------------------------------------------------------------------------------- /images/settings_ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bwya77/DockAnchor/HEAD/images/settings_ui.png -------------------------------------------------------------------------------- /images/monitor_and_block.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bwya77/DockAnchor/HEAD/images/monitor_and_block.png -------------------------------------------------------------------------------- /DockAnchor/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /DockAnchor/Assets.xcassets/AppIcon.appiconset/AppIcon_1024x1024x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bwya77/DockAnchor/HEAD/DockAnchor/Assets.xcassets/AppIcon.appiconset/AppIcon_1024x1024x32.png -------------------------------------------------------------------------------- /DockAnchor/Assets.xcassets/AppIcon.appiconset/AppIcon_256x256x32 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bwya77/DockAnchor/HEAD/DockAnchor/Assets.xcassets/AppIcon.appiconset/AppIcon_256x256x32 1.png -------------------------------------------------------------------------------- /DockAnchor/Assets.xcassets/AppIcon.appiconset/AppIcon_256x256x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bwya77/DockAnchor/HEAD/DockAnchor/Assets.xcassets/AppIcon.appiconset/AppIcon_256x256x32.png -------------------------------------------------------------------------------- /DockAnchor/Assets.xcassets/AppIcon.appiconset/AppIcon_512x512x32 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bwya77/DockAnchor/HEAD/DockAnchor/Assets.xcassets/AppIcon.appiconset/AppIcon_512x512x32 1.png -------------------------------------------------------------------------------- /DockAnchor/Assets.xcassets/AppIcon.appiconset/AppIcon_512x512x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bwya77/DockAnchor/HEAD/DockAnchor/Assets.xcassets/AppIcon.appiconset/AppIcon_512x512x32.png -------------------------------------------------------------------------------- /DockAnchor.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /DockAnchor/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 | -------------------------------------------------------------------------------- /DockAnchor/DockAnchor.xcdatamodeld/.xccurrentversion: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | _XCCurrentVersionName 6 | DockAnchor.xcdatamodel 7 | 8 | 9 | -------------------------------------------------------------------------------- /ExportOptions.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | method 6 | mac-application 7 | destination 8 | export 9 | 10 | 11 | -------------------------------------------------------------------------------- /DockAnchorTests/DockAnchorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DockAnchorTests.swift 3 | // DockAnchorTests 4 | // 5 | // Created by Bradley Wyatt on 7/2/25. 6 | // 7 | 8 | import Testing 9 | @testable import DockAnchor 10 | 11 | struct DockAnchorTests { 12 | 13 | @Test func example() async throws { 14 | // Write your test here and use APIs like `#expect(...)` to check expected conditions. 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /DockAnchor/DockAnchor.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.automation.apple-events 8 | 9 | com.apple.security.device.camera 10 | 11 | com.apple.security.device.microphone 12 | 13 | com.apple.security.files.user-selected.read-only 14 | 15 | com.apple.security.files.user-selected.read-write 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /DockAnchor/DockAnchor.xcdatamodeld/DockAnchor.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /DockAnchorUITests/DockAnchorUITestsLaunchTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DockAnchorUITestsLaunchTests.swift 3 | // DockAnchorUITests 4 | // 5 | // Created by Bradley Wyatt on 7/2/25. 6 | // 7 | 8 | import XCTest 9 | 10 | final class DockAnchorUITestsLaunchTests: XCTestCase { 11 | 12 | override class var runsForEachTargetApplicationUIConfiguration: Bool { 13 | true 14 | } 15 | 16 | override func setUpWithError() throws { 17 | continueAfterFailure = false 18 | } 19 | 20 | @MainActor 21 | func testLaunch() throws { 22 | let app = XCUIApplication() 23 | app.launch() 24 | 25 | // Insert steps here to perform after app launch but before taking a screenshot, 26 | // such as logging into a test account or navigating somewhere in the app 27 | 28 | let attachment = XCTAttachment(screenshot: app.screenshot()) 29 | attachment.name = "Launch Screen" 30 | attachment.lifetime = .keepAlways 31 | add(attachment) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Bradley Wyatt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /DockAnchor/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 | "filename" : "AppIcon_256x256x32.png", 30 | "idiom" : "mac", 31 | "scale" : "2x", 32 | "size" : "128x128" 33 | }, 34 | { 35 | "filename" : "AppIcon_256x256x32 1.png", 36 | "idiom" : "mac", 37 | "scale" : "1x", 38 | "size" : "256x256" 39 | }, 40 | { 41 | "filename" : "AppIcon_512x512x32.png", 42 | "idiom" : "mac", 43 | "scale" : "2x", 44 | "size" : "256x256" 45 | }, 46 | { 47 | "filename" : "AppIcon_512x512x32 1.png", 48 | "idiom" : "mac", 49 | "scale" : "1x", 50 | "size" : "512x512" 51 | }, 52 | { 53 | "filename" : "AppIcon_1024x1024x32.png", 54 | "idiom" : "mac", 55 | "scale" : "2x", 56 | "size" : "512x512" 57 | } 58 | ], 59 | "info" : { 60 | "author" : "xcode", 61 | "version" : 1 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /DockAnchorUITests/DockAnchorUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DockAnchorUITests.swift 3 | // DockAnchorUITests 4 | // 5 | // Created by Bradley Wyatt on 7/2/25. 6 | // 7 | 8 | import XCTest 9 | 10 | final class DockAnchorUITests: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | 15 | // In UI tests it is usually best to stop immediately when a failure occurs. 16 | continueAfterFailure = false 17 | 18 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 19 | } 20 | 21 | override func tearDownWithError() throws { 22 | // Put teardown code here. This method is called after the invocation of each test method in the class. 23 | } 24 | 25 | @MainActor 26 | func testExample() throws { 27 | // UI tests must launch the application that they test. 28 | let app = XCUIApplication() 29 | app.launch() 30 | 31 | // Use XCTAssert and related functions to verify your tests produce the correct results. 32 | } 33 | 34 | @MainActor 35 | func testLaunchPerformance() throws { 36 | // This measures how long it takes to launch your application. 37 | measure(metrics: [XCTApplicationLaunchMetric()]) { 38 | XCUIApplication().launch() 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /DockAnchor/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | 1.3 21 | CFBundleVersion 22 | 3 23 | LSMinimumSystemVersion 24 | $(MACOSX_DEPLOYMENT_TARGET) 25 | LSUIElement 26 | 27 | NSPrincipalClass 28 | NSApplication 29 | NSSupportsAutomaticTermination 30 | 31 | NSSupportsSuddenTermination 32 | 33 | NSAppleEventsUsageDescription 34 | DockAnchor needs to send Apple Events to control dock positioning and prevent unwanted movement between displays. 35 | NSSystemExtensionUsageDescription 36 | DockAnchor requires system-level access to monitor mouse events and prevent the dock from moving between displays. 37 | 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## Build generated 9 | build/ 10 | DerivedData/ 11 | 12 | ## Obj-C/Swift specific 13 | *.hmap 14 | 15 | ## App packaging 16 | *.ipa 17 | *.dSYM.zip 18 | *.dSYM 19 | 20 | ## Playgrounds 21 | timeline.xctimeline 22 | playground.xcworkspace 23 | 24 | # Swift Package Manager 25 | # 26 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 27 | # Packages/ 28 | # Package.pins 29 | # Package.resolved 30 | # *.xcodeproj 31 | # 32 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 33 | # hence it is not needed unless you have added a package configuration file to your project 34 | # .swiftpm 35 | 36 | .build/ 37 | 38 | # CocoaPods 39 | # 40 | # We recommend against adding the Pods directory to your .gitignore. However 41 | # you should judge for yourself, the pros and cons are mentioned at: 42 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 43 | # 44 | # Pods/ 45 | # 46 | # Add this line if you want to avoid checking in source code from the Xcode workspace 47 | # *.xcworkspace 48 | 49 | # Carthage 50 | # 51 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 52 | # Carthage/Checkouts 53 | 54 | Carthage/Build/ 55 | 56 | # fastlane 57 | # 58 | # It is recommended to not store the screenshots in the git repo. 59 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 60 | # For more information about the recommended setup visit: 61 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 62 | 63 | fastlane/report.xml 64 | fastlane/Preview.html 65 | fastlane/screenshots/**/*.png 66 | fastlane/test_output 67 | 68 | 69 | #macOS files 70 | .DS_STORE 71 | 72 | #cursor 73 | .cursor/ 74 | -------------------------------------------------------------------------------- /DockAnchor/Persistence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Persistence.swift 3 | // DockAnchor 4 | // 5 | // Created by Bradley Wyatt on 7/2/25. 6 | // 7 | 8 | import CoreData 9 | 10 | struct PersistenceController { 11 | static let shared = PersistenceController() 12 | 13 | @MainActor 14 | static let preview: PersistenceController = { 15 | let result = PersistenceController(inMemory: true) 16 | let viewContext = result.container.viewContext 17 | for _ in 0..<10 { 18 | let newItem = Item(context: viewContext) 19 | newItem.timestamp = Date() 20 | } 21 | do { 22 | try viewContext.save() 23 | } catch { 24 | // Replace this implementation with code to handle the error appropriately. 25 | // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. 26 | let nsError = error as NSError 27 | fatalError("Unresolved error \(nsError), \(nsError.userInfo)") 28 | } 29 | return result 30 | }() 31 | 32 | let container: NSPersistentContainer 33 | 34 | init(inMemory: Bool = false) { 35 | container = NSPersistentContainer(name: "DockAnchor") 36 | if inMemory { 37 | container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") 38 | } 39 | container.loadPersistentStores(completionHandler: { (storeDescription, error) in 40 | if let error = error as NSError? { 41 | // Replace this implementation with code to handle the error appropriately. 42 | // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. 43 | 44 | /* 45 | Typical reasons for an error here include: 46 | * The parent directory does not exist, cannot be created, or disallows writing. 47 | * The persistent store is not accessible, due to permissions or data protection when the device is locked. 48 | * The device is out of space. 49 | * The store could not be migrated to the current model version. 50 | Check the error message to determine what the actual problem was. 51 | */ 52 | fatalError("Unresolved error \(error), \(error.userInfo)") 53 | } 54 | }) 55 | container.viewContext.automaticallyMergesChangesFromParent = true 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /DockAnchor/AppSettings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppSettings.swift 3 | // DockAnchor 4 | // 5 | // Created by Bradley Wyatt on 7/2/25. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import ServiceManagement 11 | 12 | class AppSettings: ObservableObject { 13 | @Published var startAtLogin: Bool { 14 | didSet { 15 | UserDefaults.standard.set(startAtLogin, forKey: "startAtLogin") 16 | updateLoginItem() 17 | } 18 | } 19 | 20 | @Published var runInBackground: Bool { 21 | didSet { 22 | UserDefaults.standard.set(runInBackground, forKey: "runInBackground") 23 | } 24 | } 25 | 26 | @Published var showStatusIcon: Bool { 27 | didSet { 28 | UserDefaults.standard.set(showStatusIcon, forKey: "showStatusIcon") 29 | NotificationCenter.default.post(name: .statusIconVisibilityChanged, object: showStatusIcon) 30 | } 31 | } 32 | 33 | @Published var hideFromDock: Bool { 34 | didSet { 35 | UserDefaults.standard.set(hideFromDock, forKey: "hideFromDock") 36 | if oldValue != hideFromDock { 37 | // Notify the app to update activation policy 38 | NotificationCenter.default.post(name: .dockVisibilityChanged, object: hideFromDock) 39 | } 40 | } 41 | } 42 | 43 | 44 | 45 | @Published var selectedDisplayID: CGDirectDisplayID { 46 | didSet { 47 | UserDefaults.standard.set(Int(selectedDisplayID), forKey: "selectedDisplayID") 48 | NotificationCenter.default.post(name: .anchorDisplayChanged, object: selectedDisplayID) 49 | } 50 | } 51 | 52 | init() { 53 | // Check actual login item status from system 54 | let actualLoginStatus = SMAppService.mainApp.status == .enabled 55 | self.startAtLogin = actualLoginStatus 56 | 57 | self.runInBackground = UserDefaults.standard.object(forKey: "runInBackground") as? Bool ?? true 58 | self.showStatusIcon = UserDefaults.standard.object(forKey: "showStatusIcon") as? Bool ?? true 59 | self.hideFromDock = UserDefaults.standard.object(forKey: "hideFromDock") as? Bool ?? false 60 | 61 | 62 | // Get saved display ID or default to main display 63 | let savedDisplayID = UserDefaults.standard.object(forKey: "selectedDisplayID") as? Int ?? Int(CGMainDisplayID()) 64 | self.selectedDisplayID = CGDirectDisplayID(savedDisplayID) 65 | 66 | // Sync UserDefaults with actual system state 67 | UserDefaults.standard.set(actualLoginStatus, forKey: "startAtLogin") 68 | } 69 | 70 | private func updateLoginItem() { 71 | do { 72 | if startAtLogin { 73 | try SMAppService.mainApp.register() 74 | } else { 75 | try SMAppService.mainApp.unregister() 76 | } 77 | } catch { 78 | print("Failed to update login item: \(error)") 79 | } 80 | } 81 | 82 | 83 | 84 | 85 | } 86 | 87 | extension Notification.Name { 88 | static let statusIconVisibilityChanged = Notification.Name("statusIconVisibilityChanged") 89 | static let anchorDisplayChanged = Notification.Name("anchorDisplayChanged") 90 | static let dockVisibilityChanged = Notification.Name("dockVisibilityChanged") 91 | } -------------------------------------------------------------------------------- /DockAnchor/UpdateChecker.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | struct GitHubRelease: Codable { 5 | let tag_name: String 6 | let name: String 7 | let html_url: String 8 | let body: String 9 | let published_at: String 10 | } 11 | 12 | class UpdateChecker: ObservableObject { 13 | @Published var isLoading = false 14 | @Published var lastChecked: Date? 15 | 16 | private let currentVersion: String 17 | private let githubURL = "https://api.github.com/repos/bwya77/DockAnchor/releases/latest" 18 | private var isManualCheck = false 19 | 20 | init() { 21 | // Get current app version from the same source as the settings display 22 | if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String { 23 | self.currentVersion = version 24 | } else { 25 | self.currentVersion = "1.2" // Fallback 26 | } 27 | } 28 | 29 | func checkForUpdates(isManual: Bool = false) { 30 | isLoading = true 31 | self.isManualCheck = isManual 32 | 33 | guard let url = URL(string: githubURL) else { 34 | isLoading = false 35 | return 36 | } 37 | 38 | var request = URLRequest(url: url) 39 | request.setValue("application/vnd.github.v3+json", forHTTPHeaderField: "Accept") 40 | 41 | URLSession.shared.dataTask(with: request) { [weak self] data, response, error in 42 | DispatchQueue.main.async { 43 | self?.isLoading = false 44 | 45 | if let error = error { 46 | print("Update check failed: \(error)") 47 | return 48 | } 49 | 50 | guard let data = data else { 51 | print("No data received from GitHub API") 52 | return 53 | } 54 | 55 | do { 56 | let release = try JSONDecoder().decode(GitHubRelease.self, from: data) 57 | self?.processRelease(release) 58 | } catch { 59 | print("Failed to decode GitHub release: \(error)") 60 | } 61 | } 62 | }.resume() 63 | } 64 | 65 | private func processRelease(_ release: GitHubRelease) { 66 | let latestVersion = release.tag_name.replacingOccurrences(of: "v", with: "") 67 | 68 | if isNewerVersion(latestVersion) { 69 | DispatchQueue.main.async { 70 | self.showUpdateNotification(latestVersion: latestVersion, url: release.html_url) 71 | } 72 | } else if self.isManualCheck { 73 | DispatchQueue.main.async { 74 | self.showNoUpdateNotification() 75 | } 76 | } 77 | 78 | self.lastChecked = Date() 79 | } 80 | 81 | private func isNewerVersion(_ latestVersion: String) -> Bool { 82 | let currentComponents = currentVersion.split(separator: ".").compactMap { Int($0) } 83 | let latestComponents = latestVersion.split(separator: ".").compactMap { Int($0) } 84 | 85 | // Ensure both versions have at least major and minor components 86 | guard currentComponents.count >= 2, latestComponents.count >= 2 else { 87 | return false 88 | } 89 | 90 | // Compare major version 91 | if latestComponents[0] > currentComponents[0] { 92 | return true 93 | } else if latestComponents[0] < currentComponents[0] { 94 | return false 95 | } 96 | 97 | // Compare minor version 98 | if latestComponents[1] > currentComponents[1] { 99 | return true 100 | } else if latestComponents[1] < currentComponents[1] { 101 | return false 102 | } 103 | 104 | // Compare patch version if available 105 | if currentComponents.count >= 3 && latestComponents.count >= 3 { 106 | return latestComponents[2] > currentComponents[2] 107 | } 108 | 109 | return false 110 | } 111 | 112 | private func showUpdateNotification(latestVersion: String, url: String) { 113 | let alert = NSAlert() 114 | alert.messageText = "Update Available" 115 | alert.informativeText = "Version \(latestVersion) is available. You are running \(currentVersion)." 116 | alert.alertStyle = .informational 117 | alert.addButton(withTitle: "Download Update") 118 | alert.addButton(withTitle: "Cancel") 119 | let response = alert.runModal() 120 | if response == .alertFirstButtonReturn, let updateURL = URL(string: url) { 121 | NSWorkspace.shared.open(updateURL) 122 | } 123 | } 124 | 125 | private func showNoUpdateNotification() { 126 | let alert = NSAlert() 127 | alert.messageText = "No Updates Available" 128 | alert.informativeText = "You are already running the latest version of DockAnchor (\(currentVersion))." 129 | alert.alertStyle = .informational 130 | alert.addButton(withTitle: "OK") 131 | alert.runModal() 132 | } 133 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DockAnchor 2 | 3 | A macOS app that prevents the dock from moving between displays in multi-monitor setups. 4 | 5 | DockAnchor - stop the macOS dock from moving between displays | Product Hunt 6 | 7 | ![DockAnchor](images/main_ui.png) 8 | 9 | ## Problem 10 | 11 | In macOS with multiple monitors, the dock automatically moves to whichever display your cursor approaches at the bottom edge. This can be distracting and interfere with workflow, especially when you want the dock to stay on your primary display. 12 | 13 | ## Solution 14 | 15 | DockAnchor intercepts mouse movement events and blocks the dock from moving to secondary displays. Unlike scripts that kill and restart the dock (causing flashing and animations), DockAnchor prevents the movement entirely at the system level. 16 | 17 | ## Features 18 | ![DockAnchor](images/monitor_and_block.png) 19 | 20 | - **Start app at login**: Automatically launch DockAnchor when you log in 21 | - **Run in background**: Keep protection active even when the main window is closed 22 | - **Dock Icon**: Displays a status icon in the macOS dock 23 | - **Menu Bar Icon**: Shows app icon in the menu bar for quick access 24 | - **Display Selection**: Choose which display the dock should stay on 25 | - **Real-time Display Detection**: Detects when monitors are connected or disconnected 26 | - **Auto Fallback**: Automatic fallback to Primary display when selected anchor display is removed 27 | - **Friendly Display Names**: Automatically detects and displays connected monitors with user-friendly names 28 | - **Check for Updates**: Automatically checks for new releases and updates 29 | - **Show DockAnchor**: Provides a simple way to access the app's main window 30 | - **Anchor Display**: Select which display the dock should remain on 31 | - **Status Monitoring**: Real-time feedback on protection status 32 | - **Primary Display Identification**: Displays the primary display in the settings 33 | 34 | ### Settings 35 | 36 | ![DockAnchor](images/settings_ui.png) 37 | 38 | ### Menu Bar Menu 39 | 40 | ![DockAnchor](images/menu_bar.png) 41 | 42 | ## Installation 43 | 44 | ### Building from Source 45 | 1. Clone this repository 46 | 2. Open `DockAnchor.xcodeproj` in Xcode 47 | 3. Build and run the project 48 | 4. **Grant accessibility permissions when prompted** (required for functionality) 49 | 5. Optionally enable "Start at Login" in settings 50 | 51 | ### Download Pre-Built App 52 | - Download the latest release from the releases page 53 | 54 | ## Required Permissions 55 | 56 | ### Accessibility Permissions (Required) 57 | DockAnchor requires accessibility permissions to function properly. This allows the app to: 58 | - Monitor mouse movement events across all displays 59 | - Intercept dock trigger events on secondary displays 60 | - Provide seamless dock movement prevention 61 | 62 | **To grant permissions:** 63 | 1. When first launched, DockAnchor will prompt for accessibility access 64 | 2. Go to **System Preferences → Security & Privacy → Privacy → Accessibility** 65 | 3. Click the lock icon to make changes 66 | 4. Add DockAnchor to the list and check the box 67 | 5. Restart DockAnchor if needed 68 | 69 | **Note:** Without accessibility permissions, DockAnchor cannot prevent dock movement between displays. 70 | 71 | ## Usage 72 | 73 | ### First Launch 74 | 1. Launch DockAnchor 75 | 2. Click "Start Protection" to begin monitoring 76 | 3. **Grant accessibility permissions when prompted** (see above) 77 | 4. The dock will now be anchored to your selected display 78 | 79 | ### Menu Bar Icon 80 | - Shows protection status (green = active, red = inactive) 81 | - Right-click for quick access to controls 82 | - Left-click to open the main window 83 | - Displays current anchor display and protection status 84 | 85 | ### Settings 86 | 87 | #### Startup & Background 88 | - **Start at Login**: Automatically launch DockAnchor when you log in 89 | - **Run in Background**: Keep protection active even when window is closed 90 | 91 | #### Interface 92 | - **Show Menu Bar Icon**: Display status icon in menu bar 93 | - **Hide from Dock**: Hide the app from the dock when running (access via menu bar only) 94 | 95 | #### Display Selection 96 | - **Anchor Display**: Choose which display the dock should stay on 97 | - **Display Detection**: Automatically detects all connected displays with friendly names 98 | - **Primary Display Support**: Special handling for primary display designation 99 | 100 | ## How It Works 101 | 102 | DockAnchor works by: 103 | 104 | 1. **Event Monitoring**: Creates a low-level event tap to monitor mouse movements 105 | 2. **Zone Detection**: Calculates dock trigger zones on secondary displays 106 | 3. **Event Blocking**: Prevents mouse events from reaching the dock when in trigger zones 107 | 4. **Status Tracking**: Provides real-time feedback on protection status 108 | 5. **Display Management**: Uses system APIs to get actual display names and positions 109 | 110 | This approach is superior to dock-killing scripts because: 111 | - No visual flashing or animations 112 | - No interruption to running applications 113 | - No dock restart delays 114 | - Seamless user experience 115 | 116 | ## Technical Details 117 | 118 | ### System Requirements 119 | - macOS 10.15 (Catalina) or later 120 | - **Accessibility permissions (required)** 121 | - Multiple displays (for the feature to be useful) 122 | 123 | ### Permissions Required 124 | - **Accessibility**: Required to monitor mouse events and control system behavior 125 | - **Apple Events**: Used to force dock positioning when needed 126 | 127 | ### Architecture 128 | - **SwiftUI**: Modern declarative UI framework 129 | - **Core Data**: Settings and preferences storage 130 | - **Accessibility APIs**: System-level event monitoring 131 | - **Menu Bar Integration**: Background operation support 132 | - **Display Detection**: System profiler integration for display names 133 | 134 | ## Privacy & Security 135 | 136 | DockAnchor: 137 | - Only monitors mouse movement events 138 | - Does not collect or transmit any personal data. none. 139 | - Runs entirely locally on your machine 140 | - Source code is open and auditable 141 | - **Requires accessibility permissions for core functionality** 142 | 143 | ## Development 144 | 145 | ### Building from Source 146 | ```bash 147 | git clone https://github.com/yourusername/DockAnchor.git 148 | cd DockAnchor 149 | open DockAnchor.xcodeproj 150 | ``` 151 | 152 | ### Contributing 153 | 1. Fork the repository 154 | 2. Create a feature branch 155 | 3. Make your changes 156 | 4. Test thoroughly on multiple monitor setups 157 | 5. **Ensure accessibility permissions work correctly** 158 | 6. Submit a pull request 159 | 160 | ## License 161 | 162 | MIT License - see LICENSE file for details. 163 | 164 | ## Support 165 | 166 | For issues, feature requests, or questions: 167 | - Open an issue on GitHub 168 | - Provide system information (macOS version, monitor setup) 169 | - **Include accessibility permission status** 170 | - Include steps to reproduce any problems 171 | - Check the troubleshooting section above first 172 | 173 | 174 | If you find my work helpful, please consider: 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | -------------------------------------------------------------------------------- /DockAnchor/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // DockAnchor 4 | // 5 | // Created by Bradley Wyatt on 7/2/25. 6 | // 7 | 8 | import SwiftUI 9 | import CoreData 10 | 11 | private func getAppVersion() -> String { 12 | if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String { 13 | return version 14 | } 15 | return "1.3" 16 | } 17 | 18 | struct ContentView: View { 19 | @Environment(\.managedObjectContext) private var viewContext 20 | @EnvironmentObject var dockMonitor: DockMonitor 21 | @EnvironmentObject var appSettings: AppSettings 22 | @State private var showingSettings = false 23 | 24 | var body: some View { 25 | VStack(spacing: 20) { 26 | // Header 27 | VStack(spacing: 8) { 28 | Image(systemName: "dock.rectangle") 29 | .font(.system(size: 48)) 30 | .foregroundColor(.accentColor) 31 | Text("DockAnchor") 32 | .font(.largeTitle) 33 | .fontWeight(.bold) 34 | Text("Keep your dock anchored to one display") 35 | .font(.subheadline) 36 | .foregroundColor(.secondary) 37 | } 38 | .padding(.top) 39 | Divider() 40 | 41 | // Status Section 42 | VStack(spacing: 12) { 43 | HStack { 44 | Circle() 45 | .fill(dockMonitor.isActive ? Color.green : Color.red) 46 | .frame(width: 12, height: 12) 47 | 48 | Text(dockMonitor.statusMessage) 49 | .font(.headline) 50 | .foregroundColor(dockMonitor.isActive ? .green : .primary) 51 | 52 | Spacer() 53 | } 54 | 55 | if !dockMonitor.isActive { 56 | HStack { 57 | Image(systemName: "exclamationmark.triangle") 58 | .foregroundColor(.orange) 59 | Text("Dock movement protection is disabled") 60 | .font(.caption) 61 | .foregroundColor(.secondary) 62 | Spacer() 63 | } 64 | } 65 | } 66 | .padding() 67 | .background(Color(NSColor.controlBackgroundColor)) 68 | .cornerRadius(8) 69 | 70 | // Control Buttons 71 | HStack(spacing: 16) { 72 | Button(action: { 73 | if dockMonitor.isActive { 74 | dockMonitor.stopMonitoring() 75 | } else { 76 | dockMonitor.startMonitoring() 77 | } 78 | }) { 79 | HStack { 80 | Image(systemName: dockMonitor.isActive ? "stop.circle" : "play.circle") 81 | Text(dockMonitor.isActive ? "Stop Protection" : "Start Protection") 82 | } 83 | .frame(maxWidth: .infinity) 84 | } 85 | .buttonStyle(.borderedProminent) 86 | .controlSize(.large) 87 | 88 | Button("Settings") { 89 | showingSettings = true 90 | } 91 | .buttonStyle(.bordered) 92 | .controlSize(.large) 93 | } 94 | 95 | Divider() 96 | 97 | // Display Information & Selection 98 | VStack(alignment: .leading, spacing: 12) { 99 | Text("Display Settings") 100 | .font(.headline) 101 | 102 | VStack(alignment: .leading, spacing: 8) { 103 | HStack { 104 | Text("Current Anchor:") 105 | Spacer() 106 | Text(dockMonitor.anchoredDisplay) 107 | .fontWeight(.medium) 108 | .foregroundColor(.accentColor) 109 | } 110 | 111 | HStack { 112 | Text("Dock Position:") 113 | Spacer() 114 | Text(getCurrentDockPosition()) 115 | .fontWeight(.medium) 116 | } 117 | 118 | Divider() 119 | 120 | VStack(alignment: .leading, spacing: 4) { 121 | Text("Anchor Display:") 122 | .font(.subheadline) 123 | .fontWeight(.medium) 124 | 125 | Picker("Anchor Display", selection: Binding( 126 | get: { appSettings.selectedDisplayID }, 127 | set: { appSettings.selectedDisplayID = $0 } 128 | )) { 129 | ForEach(dockMonitor.availableDisplays, id: \.id) { display in 130 | Text(display.name) // Don't add (Primary) here since it's already in display.name 131 | .tag(display.id) 132 | } 133 | } 134 | .pickerStyle(.menu) 135 | .frame(maxWidth: .infinity, alignment: .leading) 136 | 137 | Text("Select which display the dock should stay on") 138 | .font(.caption) 139 | .foregroundColor(.secondary) 140 | } 141 | } 142 | .font(.subheadline) 143 | } 144 | .padding() 145 | .background(Color(NSColor.controlBackgroundColor)) 146 | .cornerRadius(8) 147 | Spacer() 148 | } 149 | .padding() 150 | .frame(width: 420, height: 520) 151 | .sheet(isPresented: $showingSettings) { 152 | SettingsView() 153 | } 154 | .onAppear { 155 | // Request permissions on startup 156 | _ = dockMonitor.requestAccessibilityPermissions() 157 | // Update available displays 158 | dockMonitor.updateAvailableDisplays() 159 | // Set the anchor display from settings 160 | dockMonitor.changeAnchorDisplay(to: appSettings.selectedDisplayID) 161 | } 162 | .onChange(of: appSettings.selectedDisplayID) { oldValue, newValue in 163 | dockMonitor.changeAnchorDisplay(to: newValue) 164 | } 165 | } 166 | 167 | private func getCurrentDockPosition() -> String { 168 | let orientation = UserDefaults.standard.string(forKey: "com.apple.dock.orientation") ?? "bottom" 169 | switch orientation { 170 | case "left": 171 | return "Left" 172 | case "right": 173 | return "Right" 174 | default: 175 | return "Bottom" 176 | } 177 | } 178 | } 179 | 180 | struct SettingsView: View { 181 | @EnvironmentObject var appSettings: AppSettings 182 | @EnvironmentObject var dockMonitor: DockMonitor 183 | @EnvironmentObject var updateChecker: UpdateChecker 184 | @Environment(\.dismiss) var dismiss 185 | 186 | var body: some View { 187 | VStack(spacing: 0) { 188 | // Header 189 | HStack { 190 | Text("Settings") 191 | .font(.title2) 192 | .fontWeight(.bold) 193 | Spacer() 194 | Button("Done") { 195 | dismiss() 196 | } 197 | .buttonStyle(.bordered) 198 | } 199 | .padding() 200 | .background(Color(NSColor.controlBackgroundColor)) 201 | 202 | Divider() 203 | 204 | // Content 205 | VStack(spacing: 20) { 206 | VStack(alignment: .leading, spacing: 16) { 207 | VStack(alignment: .leading, spacing: 8) { 208 | Text("Startup & Background") 209 | .font(.headline) 210 | 211 | Toggle("Start at Login", isOn: $appSettings.startAtLogin) 212 | Toggle("Run in Background", isOn: $appSettings.runInBackground) 213 | 214 | Text("When 'Run in Background' is enabled, the app continues protecting even when the window is closed.") 215 | .font(.caption) 216 | .foregroundColor(.secondary) 217 | } 218 | 219 | VStack(alignment: .leading, spacing: 8) { 220 | Text("Interface") 221 | .font(.headline) 222 | 223 | Toggle("Show Menu Bar Icon", isOn: $appSettings.showStatusIcon) 224 | Toggle("Hide from Dock", isOn: $appSettings.hideFromDock) 225 | 226 | Text("The menu bar icon provides quick access to controls and shows protection status.") 227 | .font(.caption) 228 | .foregroundColor(.secondary) 229 | 230 | Text("When 'Hide from Dock' is enabled, the app will only appear in the menu bar and won't show in the dock.") 231 | .font(.caption) 232 | .foregroundColor(.secondary) 233 | } 234 | 235 | VStack(alignment: .leading, spacing: 8) { 236 | Text("Display Info") 237 | .font(.headline) 238 | 239 | VStack(alignment: .leading, spacing: 4) { 240 | Text("Available Displays: \(dockMonitor.availableDisplays.count)") 241 | 242 | ForEach(dockMonitor.availableDisplays, id: \.id) { display in 243 | HStack { 244 | Circle() 245 | .fill(display.id == appSettings.selectedDisplayID ? Color.green : Color.gray) 246 | .frame(width: 8, height: 8) 247 | Text(display.name) // Don't add (Primary) here since it's already in display.name 248 | Spacer() 249 | } 250 | .font(.caption) 251 | } 252 | } 253 | .padding(.leading, 8) 254 | } 255 | } 256 | .padding() 257 | 258 | Spacer(minLength: 20) 259 | 260 | VStack(spacing: 4) { 261 | Text("Version \(getAppVersion())") 262 | .font(.caption2) 263 | .foregroundColor(.secondary) 264 | } 265 | .padding(.bottom) 266 | } 267 | } 268 | .frame(minWidth: 480, minHeight: 400) 269 | .onAppear { 270 | dockMonitor.updateAvailableDisplays() 271 | } 272 | } 273 | } 274 | 275 | #Preview { 276 | ContentView() 277 | .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) 278 | .environmentObject(AppSettings()) 279 | .environmentObject(DockMonitor()) 280 | .environmentObject(UpdateChecker()) 281 | } 282 | 283 | -------------------------------------------------------------------------------- /DockAnchor/DockAnchorApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DockAnchorApp.swift 3 | // DockAnchor 4 | // 5 | // Created by Bradley Wyatt on 7/2/25. 6 | // 7 | 8 | import SwiftUI 9 | import Cocoa 10 | import Combine 11 | 12 | class WindowHiderDelegate: NSObject, NSWindowDelegate { 13 | private var appSettings: AppSettings? 14 | 15 | func setup(appSettings: AppSettings) { 16 | self.appSettings = appSettings 17 | } 18 | 19 | func windowShouldClose(_ sender: NSWindow) -> Bool { 20 | sender.orderOut(nil) 21 | 22 | // If the setting is enabled, hide the app from dock when window is closed 23 | if appSettings?.hideFromDock == true { 24 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 25 | NSApp.setActivationPolicy(.accessory) 26 | 27 | // Force a dock refresh 28 | DistributedNotificationCenter.default().post( 29 | name: NSNotification.Name("com.apple.dock.refresh"), 30 | object: nil 31 | ) 32 | } 33 | } 34 | 35 | return false 36 | } 37 | } 38 | 39 | @main 40 | struct DockAnchorApp: App { 41 | let persistenceController = PersistenceController.shared 42 | @StateObject private var appSettings = AppSettings() 43 | @StateObject private var dockMonitor = DockMonitor() 44 | @StateObject private var menuBarManager = MenuBarManager() 45 | @StateObject private var updateChecker = UpdateChecker() 46 | 47 | @NSApplicationDelegateAdaptor(ApplicationDelegate.self) var appDelegate 48 | private let windowHiderDelegate = WindowHiderDelegate() 49 | 50 | var body: some Scene { 51 | WindowGroup("DockAnchor") { 52 | ContentView() 53 | .environment(\.managedObjectContext, persistenceController.container.viewContext) 54 | .environmentObject(appSettings) 55 | .environmentObject(dockMonitor) 56 | .environmentObject(updateChecker) 57 | .onAppear { 58 | setupApp() 59 | } 60 | .background(WindowAccessor { window in 61 | window?.delegate = windowHiderDelegate 62 | }) 63 | .handlesExternalEvents(preferring: Set(arrayLiteral: "main"), allowing: Set(arrayLiteral: "*")) 64 | } 65 | .windowStyle(.hiddenTitleBar) 66 | .windowResizability(.contentSize) 67 | .handlesExternalEvents(matching: Set(arrayLiteral: "main")) 68 | .commands { 69 | CommandGroup(after: .appInfo) { 70 | Button("Show DockAnchor") { 71 | menuBarManager.showMainWindow() 72 | } 73 | .keyboardShortcut("d", modifiers: [.command, .option]) 74 | 75 | Divider() 76 | 77 | Button(dockMonitor.isActive ? "Stop Protection" : "Start Protection") { 78 | if dockMonitor.isActive { 79 | dockMonitor.stopMonitoring() 80 | } else { 81 | dockMonitor.startMonitoring() 82 | } 83 | } 84 | .keyboardShortcut("p", modifiers: [.command, .option]) 85 | } 86 | } 87 | } 88 | 89 | private func setupApp() { 90 | // Set up app delegate references 91 | appDelegate.setup(appSettings: appSettings, dockMonitor: dockMonitor, menuBarManager: menuBarManager) 92 | 93 | // Initialize the menu bar with current settings 94 | menuBarManager.setup(appSettings: appSettings, dockMonitor: dockMonitor, updateChecker: updateChecker) 95 | 96 | // Set up window delegate 97 | windowHiderDelegate.setup(appSettings: appSettings) 98 | 99 | // Set the anchor display from settings 100 | dockMonitor.changeAnchorDisplay(to: appSettings.selectedDisplayID) 101 | 102 | // Ensure main window is visible on launch 103 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 104 | // Make sure the main window is visible 105 | for window in NSApp.windows { 106 | if window.title == "DockAnchor" || window.contentViewController != nil { 107 | window.makeKeyAndOrderFront(nil) 108 | break 109 | } 110 | } 111 | } 112 | 113 | // Auto-start monitoring if enabled 114 | if appSettings.runInBackground { 115 | DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { 116 | dockMonitor.startMonitoring() 117 | } 118 | } 119 | 120 | // Check for updates after a short delay 121 | DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { 122 | updateChecker.checkForUpdates() 123 | } 124 | } 125 | } 126 | 127 | class ApplicationDelegate: NSObject, NSApplicationDelegate, ObservableObject { 128 | private var appSettings: AppSettings? 129 | private var dockMonitor: DockMonitor? 130 | private var menuBarManager: MenuBarManager? 131 | 132 | func setup(appSettings: AppSettings, dockMonitor: DockMonitor, menuBarManager: MenuBarManager) { 133 | self.appSettings = appSettings 134 | self.dockMonitor = dockMonitor 135 | self.menuBarManager = menuBarManager 136 | 137 | // Listen for dock visibility changes 138 | NotificationCenter.default.addObserver( 139 | self, 140 | selector: #selector(updateDockVisibility), 141 | name: .dockVisibilityChanged, 142 | object: nil 143 | ) 144 | } 145 | 146 | func applicationDidFinishLaunching(_ notification: Notification) { 147 | // Set initial activation policy based on settings 148 | updateActivationPolicy() 149 | } 150 | 151 | func applicationShouldTerminateAfterLastWindowClosed(_ app: NSApplication) -> Bool { 152 | // Don't terminate when the main window is closed 153 | return false 154 | } 155 | 156 | func applicationShouldHandleReopen(_ app: NSApplication, hasVisibleWindows flag: Bool) -> Bool { 157 | // Always bring the main window to front when dock icon is clicked 158 | // This prevents opening multiple instances 159 | menuBarManager?.showMainWindow() 160 | return false // Don't let the system handle reopening 161 | } 162 | 163 | func applicationWillTerminate(_ notification: Notification) { 164 | // Clean up when app is actually quitting 165 | dockMonitor?.stopMonitoring() 166 | NotificationCenter.default.removeObserver(self) 167 | } 168 | 169 | @objc private func updateDockVisibility() { 170 | updateActivationPolicy() 171 | } 172 | 173 | private func updateActivationPolicy() { 174 | // Only hide from dock if explicitly requested (not on initial launch) 175 | // The app will start with regular activation policy and only change when window is closed 176 | let newPolicy: NSApplication.ActivationPolicy = .regular 177 | 178 | // Set the activation policy - this will show the app in dock 179 | NSApp.setActivationPolicy(newPolicy) 180 | 181 | // Force the change to take effect immediately 182 | DispatchQueue.main.async { 183 | // Activate the app to trigger the policy change 184 | NSApp.activate(ignoringOtherApps: false) 185 | 186 | // Ensure menu bar is visible 187 | self.menuBarManager?.ensureStatusBarVisible() 188 | 189 | // Force a dock refresh by sending a notification 190 | DistributedNotificationCenter.default().post( 191 | name: NSNotification.Name("com.apple.dock.refresh"), 192 | object: nil 193 | ) 194 | } 195 | } 196 | } 197 | 198 | class MenuBarManager: NSObject, ObservableObject { 199 | private var statusItem: NSStatusItem? 200 | private var appSettings: AppSettings? 201 | private var dockMonitor: DockMonitor? 202 | private var updateChecker: UpdateChecker? 203 | private var cancellables = Set() 204 | 205 | deinit { 206 | removeStatusBar() 207 | cancellables.removeAll() 208 | } 209 | 210 | func setup(appSettings: AppSettings, dockMonitor: DockMonitor, updateChecker: UpdateChecker) { 211 | self.appSettings = appSettings 212 | self.dockMonitor = dockMonitor 213 | self.updateChecker = updateChecker 214 | 215 | // Always setup the status bar initially (since default is true) 216 | setupStatusBar() 217 | 218 | // Listen for settings changes 219 | appSettings.$showStatusIcon 220 | .receive(on: DispatchQueue.main) 221 | .sink { [weak self] showIcon in 222 | if showIcon { 223 | self?.setupStatusBar() 224 | } else { 225 | self?.removeStatusBar() 226 | } 227 | } 228 | .store(in: &cancellables) 229 | } 230 | 231 | private func setupStatusBar() { 232 | // Remove existing status item first 233 | removeStatusBar() 234 | 235 | statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) 236 | 237 | if let button = statusItem?.button { 238 | button.image = NSImage(systemSymbolName: "dock.rectangle", accessibilityDescription: "DockAnchor") 239 | button.toolTip = "DockAnchor - Click to open" 240 | button.action = #selector(statusItemClicked) 241 | button.target = self 242 | } 243 | 244 | setupStatusMenu() 245 | } 246 | 247 | private func removeStatusBar() { 248 | if let statusItem = statusItem { 249 | NSStatusBar.system.removeStatusItem(statusItem) 250 | self.statusItem = nil 251 | } 252 | } 253 | 254 | private func setupStatusMenu() { 255 | guard let dockMonitor = dockMonitor, let appSettings = appSettings else { return } 256 | 257 | let menu = NSMenu() 258 | 259 | // Status indicator 260 | let statusMenuItem = NSMenuItem() 261 | updateStatusMenuItem(statusMenuItem, isActive: dockMonitor.isActive) 262 | statusMenuItem.isEnabled = false 263 | menu.addItem(statusMenuItem) 264 | 265 | // Current anchor display 266 | let anchorMenuItem = NSMenuItem() 267 | anchorMenuItem.title = "📍 \(dockMonitor.anchoredDisplay)" 268 | anchorMenuItem.isEnabled = false 269 | menu.addItem(anchorMenuItem) 270 | 271 | menu.addItem(NSMenuItem.separator()) 272 | 273 | // Toggle protection 274 | let toggleMenuItem = NSMenuItem( 275 | title: dockMonitor.isActive ? "Stop Protection" : "Start Protection", 276 | action: #selector(toggleProtection), 277 | keyEquivalent: "" 278 | ) 279 | toggleMenuItem.target = self 280 | menu.addItem(toggleMenuItem) 281 | 282 | menu.addItem(NSMenuItem.separator()) 283 | 284 | // Quick display selection submenu 285 | let displaySubmenu = NSMenu() 286 | for display in dockMonitor.availableDisplays { 287 | let displayItem = NSMenuItem( 288 | title: display.name, // Don't add (Primary) here since it's already in display.name 289 | action: #selector(selectDisplay(_:)), 290 | keyEquivalent: "" 291 | ) 292 | displayItem.target = self 293 | displayItem.representedObject = display.id 294 | displayItem.state = display.id == appSettings.selectedDisplayID ? .on : .off 295 | displaySubmenu.addItem(displayItem) 296 | } 297 | 298 | let displayMenuItem = NSMenuItem(title: "Anchor to Display", action: nil, keyEquivalent: "") 299 | displayMenuItem.submenu = displaySubmenu 300 | menu.addItem(displayMenuItem) 301 | 302 | menu.addItem(NSMenuItem.separator()) 303 | 304 | // Check for updates 305 | let updateMenuItem = NSMenuItem( 306 | title: "Check for Updates", 307 | action: #selector(checkForUpdates), 308 | keyEquivalent: "" 309 | ) 310 | updateMenuItem.target = self 311 | menu.addItem(updateMenuItem) 312 | 313 | // Show main window 314 | let showMenuItem = NSMenuItem( 315 | title: "Show DockAnchor", 316 | action: #selector(showMainWindow), 317 | keyEquivalent: "" 318 | ) 319 | showMenuItem.target = self 320 | menu.addItem(showMenuItem) 321 | 322 | menu.addItem(NSMenuItem.separator()) 323 | 324 | // Quit 325 | let quitMenuItem = NSMenuItem( 326 | title: "Quit DockAnchor", 327 | action: #selector(quitApp), 328 | keyEquivalent: "q" 329 | ) 330 | quitMenuItem.target = self 331 | menu.addItem(quitMenuItem) 332 | 333 | statusItem?.menu = menu 334 | 335 | // Update menu when monitor status changes 336 | dockMonitor.$isActive 337 | .receive(on: DispatchQueue.main) 338 | .sink { [weak self] isActive in 339 | self?.updateStatusMenuItem(statusMenuItem, isActive: isActive) 340 | toggleMenuItem.title = isActive ? "Stop Protection" : "Start Protection" 341 | } 342 | .store(in: &cancellables) 343 | 344 | // Update anchor display in menu 345 | dockMonitor.$anchoredDisplay 346 | .receive(on: DispatchQueue.main) 347 | .sink { [weak self] displayName in 348 | anchorMenuItem.title = "📍 \(displayName)" 349 | self?.refreshDisplaySubmenu() 350 | } 351 | .store(in: &cancellables) 352 | 353 | // Update tooltip with status 354 | dockMonitor.$statusMessage 355 | .receive(on: DispatchQueue.main) 356 | .sink { [weak self] message in 357 | self?.statusItem?.button?.toolTip = "DockAnchor - \(message)" 358 | } 359 | .store(in: &cancellables) 360 | 361 | // Update display submenu when available displays change 362 | dockMonitor.$availableDisplays 363 | .receive(on: DispatchQueue.main) 364 | .sink { [weak self] _ in 365 | self?.updateDisplaySubmenu() 366 | } 367 | .store(in: &cancellables) 368 | } 369 | 370 | private func updateStatusMenuItem(_ item: NSMenuItem, isActive: Bool) { 371 | item.title = isActive ? "🟢 Protection Active" : "🔴 Protection Inactive" 372 | } 373 | 374 | private func refreshDisplaySubmenu() { 375 | guard let menu = statusItem?.menu, 376 | let displayMenuItem = menu.item(withTitle: "Anchor to Display"), 377 | let submenu = displayMenuItem.submenu else { return } 378 | 379 | // Update checkmarks 380 | for item in submenu.items { 381 | if let displayID = item.representedObject as? CGDirectDisplayID { 382 | item.state = displayID == appSettings?.selectedDisplayID ? .on : .off 383 | } 384 | } 385 | } 386 | 387 | private func updateDisplaySubmenu() { 388 | guard let menu = statusItem?.menu, 389 | let displayMenuItem = menu.item(withTitle: "Anchor to Display"), 390 | let dockMonitor = dockMonitor, 391 | let appSettings = appSettings else { return } 392 | 393 | // Create new submenu with updated displays 394 | let newSubmenu = NSMenu() 395 | for display in dockMonitor.availableDisplays { 396 | let displayItem = NSMenuItem( 397 | title: display.name, // Don't add (Primary) here since it's already in display.name 398 | action: #selector(selectDisplay(_:)), 399 | keyEquivalent: "" 400 | ) 401 | displayItem.target = self 402 | displayItem.representedObject = display.id 403 | displayItem.state = display.id == appSettings.selectedDisplayID ? .on : .off 404 | newSubmenu.addItem(displayItem) 405 | } 406 | 407 | // Replace the submenu 408 | displayMenuItem.submenu = newSubmenu 409 | } 410 | 411 | @objc private func statusItemClicked() { 412 | showMainWindow() 413 | } 414 | 415 | @objc private func toggleProtection() { 416 | guard let dockMonitor = dockMonitor else { return } 417 | if dockMonitor.isActive { 418 | dockMonitor.stopMonitoring() 419 | } else { 420 | dockMonitor.startMonitoring() 421 | } 422 | } 423 | 424 | @objc private func selectDisplay(_ sender: NSMenuItem) { 425 | guard let displayID = sender.representedObject as? CGDirectDisplayID else { return } 426 | appSettings?.selectedDisplayID = displayID 427 | } 428 | 429 | @objc private func checkForUpdates() { 430 | updateChecker?.checkForUpdates(isManual: true) 431 | } 432 | 433 | @objc func showMainWindow() { 434 | NSApp.activate(ignoringOtherApps: true) 435 | 436 | // Always restore the dock icon when showing the window 437 | NSApp.setActivationPolicy(.regular) 438 | 439 | // Force a dock refresh 440 | DistributedNotificationCenter.default().post( 441 | name: NSNotification.Name("com.apple.dock.refresh"), 442 | object: nil 443 | ) 444 | 445 | // Find and show the main window 446 | for window in NSApp.windows { 447 | if window.title == "DockAnchor" || window.contentViewController != nil { 448 | window.makeKeyAndOrderFront(nil) 449 | return 450 | } 451 | } 452 | 453 | // If no window found, try to open a new one 454 | if let url = URL(string: "dockanchor://main") { 455 | NSWorkspace.shared.open(url) 456 | } 457 | } 458 | 459 | @objc private func quitApp() { 460 | dockMonitor?.stopMonitoring() 461 | NSApp.terminate(nil) 462 | } 463 | 464 | func ensureStatusBarVisible() { 465 | // Ensure the status bar is visible when hiding from dock 466 | if statusItem == nil && (appSettings?.showStatusIcon ?? true) { 467 | setupStatusBar() 468 | } 469 | } 470 | } 471 | 472 | // Helper to access the NSWindow from SwiftUI 473 | struct WindowAccessor: NSViewRepresentable { 474 | var callback: (NSWindow?) -> Void 475 | func makeNSView(context: Context) -> NSView { 476 | let view = NSView() 477 | DispatchQueue.main.async { 478 | self.callback(view.window) 479 | } 480 | return view 481 | } 482 | func updateNSView(_ nsView: NSView, context: Context) {} 483 | } 484 | -------------------------------------------------------------------------------- /DockAnchor.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXContainerItemProxy section */ 10 | 49C873272E15BF5300F73B78 /* PBXContainerItemProxy */ = { 11 | isa = PBXContainerItemProxy; 12 | containerPortal = 49C8730B2E15BF5100F73B78 /* Project object */; 13 | proxyType = 1; 14 | remoteGlobalIDString = 49C873122E15BF5100F73B78; 15 | remoteInfo = DockAnchor; 16 | }; 17 | 49C873312E15BF5300F73B78 /* PBXContainerItemProxy */ = { 18 | isa = PBXContainerItemProxy; 19 | containerPortal = 49C8730B2E15BF5100F73B78 /* Project object */; 20 | proxyType = 1; 21 | remoteGlobalIDString = 49C873122E15BF5100F73B78; 22 | remoteInfo = DockAnchor; 23 | }; 24 | /* End PBXContainerItemProxy section */ 25 | 26 | /* Begin PBXFileReference section */ 27 | 49C873132E15BF5100F73B78 /* DockAnchor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DockAnchor.app; sourceTree = BUILT_PRODUCTS_DIR; }; 28 | 49C873262E15BF5300F73B78 /* DockAnchorTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DockAnchorTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 29 | 49C873302E15BF5300F73B78 /* DockAnchorUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DockAnchorUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 30 | /* End PBXFileReference section */ 31 | 32 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 33 | 49C873152E15BF5100F73B78 /* DockAnchor */ = { 34 | isa = PBXFileSystemSynchronizedRootGroup; 35 | path = DockAnchor; 36 | sourceTree = ""; 37 | }; 38 | 49C873292E15BF5300F73B78 /* DockAnchorTests */ = { 39 | isa = PBXFileSystemSynchronizedRootGroup; 40 | path = DockAnchorTests; 41 | sourceTree = ""; 42 | }; 43 | 49C873332E15BF5300F73B78 /* DockAnchorUITests */ = { 44 | isa = PBXFileSystemSynchronizedRootGroup; 45 | path = DockAnchorUITests; 46 | sourceTree = ""; 47 | }; 48 | /* End PBXFileSystemSynchronizedRootGroup section */ 49 | 50 | /* Begin PBXFrameworksBuildPhase section */ 51 | 49C873102E15BF5100F73B78 /* Frameworks */ = { 52 | isa = PBXFrameworksBuildPhase; 53 | buildActionMask = 2147483647; 54 | files = ( 55 | ); 56 | runOnlyForDeploymentPostprocessing = 0; 57 | }; 58 | 49C873232E15BF5300F73B78 /* Frameworks */ = { 59 | isa = PBXFrameworksBuildPhase; 60 | buildActionMask = 2147483647; 61 | files = ( 62 | ); 63 | runOnlyForDeploymentPostprocessing = 0; 64 | }; 65 | 49C8732D2E15BF5300F73B78 /* Frameworks */ = { 66 | isa = PBXFrameworksBuildPhase; 67 | buildActionMask = 2147483647; 68 | files = ( 69 | ); 70 | runOnlyForDeploymentPostprocessing = 0; 71 | }; 72 | /* End PBXFrameworksBuildPhase section */ 73 | 74 | /* Begin PBXGroup section */ 75 | 49C8730A2E15BF5100F73B78 = { 76 | isa = PBXGroup; 77 | children = ( 78 | 49C873152E15BF5100F73B78 /* DockAnchor */, 79 | 49C873292E15BF5300F73B78 /* DockAnchorTests */, 80 | 49C873332E15BF5300F73B78 /* DockAnchorUITests */, 81 | 49C873142E15BF5100F73B78 /* Products */, 82 | ); 83 | sourceTree = ""; 84 | }; 85 | 49C873142E15BF5100F73B78 /* Products */ = { 86 | isa = PBXGroup; 87 | children = ( 88 | 49C873132E15BF5100F73B78 /* DockAnchor.app */, 89 | 49C873262E15BF5300F73B78 /* DockAnchorTests.xctest */, 90 | 49C873302E15BF5300F73B78 /* DockAnchorUITests.xctest */, 91 | ); 92 | name = Products; 93 | sourceTree = ""; 94 | }; 95 | /* End PBXGroup section */ 96 | 97 | /* Begin PBXNativeTarget section */ 98 | 49C873122E15BF5100F73B78 /* DockAnchor */ = { 99 | isa = PBXNativeTarget; 100 | buildConfigurationList = 49C8733A2E15BF5300F73B78 /* Build configuration list for PBXNativeTarget "DockAnchor" */; 101 | buildPhases = ( 102 | 49C8730F2E15BF5100F73B78 /* Sources */, 103 | 49C873102E15BF5100F73B78 /* Frameworks */, 104 | 49C873112E15BF5100F73B78 /* Resources */, 105 | ); 106 | buildRules = ( 107 | ); 108 | dependencies = ( 109 | ); 110 | fileSystemSynchronizedGroups = ( 111 | 49C873152E15BF5100F73B78 /* DockAnchor */, 112 | ); 113 | name = DockAnchor; 114 | packageProductDependencies = ( 115 | ); 116 | productName = DockAnchor; 117 | productReference = 49C873132E15BF5100F73B78 /* DockAnchor.app */; 118 | productType = "com.apple.product-type.application"; 119 | }; 120 | 49C873252E15BF5300F73B78 /* DockAnchorTests */ = { 121 | isa = PBXNativeTarget; 122 | buildConfigurationList = 49C8733D2E15BF5300F73B78 /* Build configuration list for PBXNativeTarget "DockAnchorTests" */; 123 | buildPhases = ( 124 | 49C873222E15BF5300F73B78 /* Sources */, 125 | 49C873232E15BF5300F73B78 /* Frameworks */, 126 | 49C873242E15BF5300F73B78 /* Resources */, 127 | ); 128 | buildRules = ( 129 | ); 130 | dependencies = ( 131 | 49C873282E15BF5300F73B78 /* PBXTargetDependency */, 132 | ); 133 | fileSystemSynchronizedGroups = ( 134 | 49C873292E15BF5300F73B78 /* DockAnchorTests */, 135 | ); 136 | name = DockAnchorTests; 137 | packageProductDependencies = ( 138 | ); 139 | productName = DockAnchorTests; 140 | productReference = 49C873262E15BF5300F73B78 /* DockAnchorTests.xctest */; 141 | productType = "com.apple.product-type.bundle.unit-test"; 142 | }; 143 | 49C8732F2E15BF5300F73B78 /* DockAnchorUITests */ = { 144 | isa = PBXNativeTarget; 145 | buildConfigurationList = 49C873402E15BF5300F73B78 /* Build configuration list for PBXNativeTarget "DockAnchorUITests" */; 146 | buildPhases = ( 147 | 49C8732C2E15BF5300F73B78 /* Sources */, 148 | 49C8732D2E15BF5300F73B78 /* Frameworks */, 149 | 49C8732E2E15BF5300F73B78 /* Resources */, 150 | ); 151 | buildRules = ( 152 | ); 153 | dependencies = ( 154 | 49C873322E15BF5300F73B78 /* PBXTargetDependency */, 155 | ); 156 | fileSystemSynchronizedGroups = ( 157 | 49C873332E15BF5300F73B78 /* DockAnchorUITests */, 158 | ); 159 | name = DockAnchorUITests; 160 | packageProductDependencies = ( 161 | ); 162 | productName = DockAnchorUITests; 163 | productReference = 49C873302E15BF5300F73B78 /* DockAnchorUITests.xctest */; 164 | productType = "com.apple.product-type.bundle.ui-testing"; 165 | }; 166 | /* End PBXNativeTarget section */ 167 | 168 | /* Begin PBXProject section */ 169 | 49C8730B2E15BF5100F73B78 /* Project object */ = { 170 | isa = PBXProject; 171 | attributes = { 172 | BuildIndependentTargetsInParallel = 1; 173 | LastSwiftUpdateCheck = 1640; 174 | LastUpgradeCheck = 1640; 175 | TargetAttributes = { 176 | 49C873122E15BF5100F73B78 = { 177 | CreatedOnToolsVersion = 16.4; 178 | }; 179 | 49C873252E15BF5300F73B78 = { 180 | CreatedOnToolsVersion = 16.4; 181 | TestTargetID = 49C873122E15BF5100F73B78; 182 | }; 183 | 49C8732F2E15BF5300F73B78 = { 184 | CreatedOnToolsVersion = 16.4; 185 | TestTargetID = 49C873122E15BF5100F73B78; 186 | }; 187 | }; 188 | }; 189 | buildConfigurationList = 49C8730E2E15BF5100F73B78 /* Build configuration list for PBXProject "DockAnchor" */; 190 | developmentRegion = en; 191 | hasScannedForEncodings = 0; 192 | knownRegions = ( 193 | en, 194 | Base, 195 | ); 196 | mainGroup = 49C8730A2E15BF5100F73B78; 197 | minimizedProjectReferenceProxies = 1; 198 | preferredProjectObjectVersion = 77; 199 | productRefGroup = 49C873142E15BF5100F73B78 /* Products */; 200 | projectDirPath = ""; 201 | projectRoot = ""; 202 | targets = ( 203 | 49C873122E15BF5100F73B78 /* DockAnchor */, 204 | 49C873252E15BF5300F73B78 /* DockAnchorTests */, 205 | 49C8732F2E15BF5300F73B78 /* DockAnchorUITests */, 206 | ); 207 | }; 208 | /* End PBXProject section */ 209 | 210 | /* Begin PBXResourcesBuildPhase section */ 211 | 49C873112E15BF5100F73B78 /* Resources */ = { 212 | isa = PBXResourcesBuildPhase; 213 | buildActionMask = 2147483647; 214 | files = ( 215 | ); 216 | runOnlyForDeploymentPostprocessing = 0; 217 | }; 218 | 49C873242E15BF5300F73B78 /* Resources */ = { 219 | isa = PBXResourcesBuildPhase; 220 | buildActionMask = 2147483647; 221 | files = ( 222 | ); 223 | runOnlyForDeploymentPostprocessing = 0; 224 | }; 225 | 49C8732E2E15BF5300F73B78 /* Resources */ = { 226 | isa = PBXResourcesBuildPhase; 227 | buildActionMask = 2147483647; 228 | files = ( 229 | ); 230 | runOnlyForDeploymentPostprocessing = 0; 231 | }; 232 | /* End PBXResourcesBuildPhase section */ 233 | 234 | /* Begin PBXSourcesBuildPhase section */ 235 | 49C8730F2E15BF5100F73B78 /* Sources */ = { 236 | isa = PBXSourcesBuildPhase; 237 | buildActionMask = 2147483647; 238 | files = ( 239 | ); 240 | runOnlyForDeploymentPostprocessing = 0; 241 | }; 242 | 49C873222E15BF5300F73B78 /* Sources */ = { 243 | isa = PBXSourcesBuildPhase; 244 | buildActionMask = 2147483647; 245 | files = ( 246 | ); 247 | runOnlyForDeploymentPostprocessing = 0; 248 | }; 249 | 49C8732C2E15BF5300F73B78 /* Sources */ = { 250 | isa = PBXSourcesBuildPhase; 251 | buildActionMask = 2147483647; 252 | files = ( 253 | ); 254 | runOnlyForDeploymentPostprocessing = 0; 255 | }; 256 | /* End PBXSourcesBuildPhase section */ 257 | 258 | /* Begin PBXTargetDependency section */ 259 | 49C873282E15BF5300F73B78 /* PBXTargetDependency */ = { 260 | isa = PBXTargetDependency; 261 | target = 49C873122E15BF5100F73B78 /* DockAnchor */; 262 | targetProxy = 49C873272E15BF5300F73B78 /* PBXContainerItemProxy */; 263 | }; 264 | 49C873322E15BF5300F73B78 /* PBXTargetDependency */ = { 265 | isa = PBXTargetDependency; 266 | target = 49C873122E15BF5100F73B78 /* DockAnchor */; 267 | targetProxy = 49C873312E15BF5300F73B78 /* PBXContainerItemProxy */; 268 | }; 269 | /* End PBXTargetDependency section */ 270 | 271 | /* Begin XCBuildConfiguration section */ 272 | 49C873382E15BF5300F73B78 /* Debug */ = { 273 | isa = XCBuildConfiguration; 274 | buildSettings = { 275 | ALWAYS_SEARCH_USER_PATHS = NO; 276 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 277 | BUILT_PRODUCTS_DIR = build; 278 | CLANG_ANALYZER_NONNULL = YES; 279 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 280 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 281 | CLANG_ENABLE_MODULES = YES; 282 | CLANG_ENABLE_OBJC_ARC = YES; 283 | CLANG_ENABLE_OBJC_WEAK = YES; 284 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 285 | CLANG_WARN_BOOL_CONVERSION = YES; 286 | CLANG_WARN_COMMA = YES; 287 | CLANG_WARN_CONSTANT_CONVERSION = YES; 288 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 289 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 290 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 291 | CLANG_WARN_EMPTY_BODY = YES; 292 | CLANG_WARN_ENUM_CONVERSION = YES; 293 | CLANG_WARN_INFINITE_RECURSION = YES; 294 | CLANG_WARN_INT_CONVERSION = YES; 295 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 296 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 297 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 298 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 299 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 300 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 301 | CLANG_WARN_STRICT_PROTOTYPES = YES; 302 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 303 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 304 | CLANG_WARN_UNREACHABLE_CODE = YES; 305 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 306 | COPY_PHASE_STRIP = NO; 307 | DEBUG_INFORMATION_FORMAT = dwarf; 308 | DEVELOPMENT_TEAM = 8772WA9289; 309 | ENABLE_STRICT_OBJC_MSGSEND = YES; 310 | ENABLE_TESTABILITY = YES; 311 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 312 | GCC_C_LANGUAGE_STANDARD = gnu17; 313 | GCC_DYNAMIC_NO_PIC = NO; 314 | GCC_NO_COMMON_BLOCKS = YES; 315 | GCC_OPTIMIZATION_LEVEL = 0; 316 | GCC_PREPROCESSOR_DEFINITIONS = ( 317 | "DEBUG=1", 318 | "$(inherited)", 319 | ); 320 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 321 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 322 | GCC_WARN_UNDECLARED_SELECTOR = YES; 323 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 324 | GCC_WARN_UNUSED_FUNCTION = YES; 325 | GCC_WARN_UNUSED_VARIABLE = YES; 326 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 327 | MACOSX_DEPLOYMENT_TARGET = 15.4; 328 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 329 | MTL_FAST_MATH = YES; 330 | ONLY_ACTIVE_ARCH = YES; 331 | SDKROOT = macosx; 332 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 333 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 334 | }; 335 | name = Debug; 336 | }; 337 | 49C873392E15BF5300F73B78 /* Release */ = { 338 | isa = XCBuildConfiguration; 339 | buildSettings = { 340 | ALWAYS_SEARCH_USER_PATHS = NO; 341 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 342 | BUILT_PRODUCTS_DIR = build; 343 | CLANG_ANALYZER_NONNULL = YES; 344 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 345 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 346 | CLANG_ENABLE_MODULES = YES; 347 | CLANG_ENABLE_OBJC_ARC = YES; 348 | CLANG_ENABLE_OBJC_WEAK = YES; 349 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 350 | CLANG_WARN_BOOL_CONVERSION = YES; 351 | CLANG_WARN_COMMA = YES; 352 | CLANG_WARN_CONSTANT_CONVERSION = YES; 353 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 354 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 355 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 356 | CLANG_WARN_EMPTY_BODY = YES; 357 | CLANG_WARN_ENUM_CONVERSION = YES; 358 | CLANG_WARN_INFINITE_RECURSION = YES; 359 | CLANG_WARN_INT_CONVERSION = YES; 360 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 361 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 362 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 363 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 364 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 365 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 366 | CLANG_WARN_STRICT_PROTOTYPES = YES; 367 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 368 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 369 | CLANG_WARN_UNREACHABLE_CODE = YES; 370 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 371 | COPY_PHASE_STRIP = NO; 372 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 373 | DEVELOPMENT_TEAM = 8772WA9289; 374 | ENABLE_NS_ASSERTIONS = NO; 375 | ENABLE_STRICT_OBJC_MSGSEND = YES; 376 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 377 | GCC_C_LANGUAGE_STANDARD = gnu17; 378 | GCC_NO_COMMON_BLOCKS = YES; 379 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 380 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 381 | GCC_WARN_UNDECLARED_SELECTOR = YES; 382 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 383 | GCC_WARN_UNUSED_FUNCTION = YES; 384 | GCC_WARN_UNUSED_VARIABLE = YES; 385 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 386 | MACOSX_DEPLOYMENT_TARGET = 15.4; 387 | MTL_ENABLE_DEBUG_INFO = NO; 388 | MTL_FAST_MATH = YES; 389 | SDKROOT = macosx; 390 | SWIFT_COMPILATION_MODE = wholemodule; 391 | }; 392 | name = Release; 393 | }; 394 | 49C8733B2E15BF5300F73B78 /* Debug */ = { 395 | isa = XCBuildConfiguration; 396 | buildSettings = { 397 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 398 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 399 | ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; 400 | BUILT_PRODUCTS_DIR = build; 401 | CODE_SIGN_ENTITLEMENTS = DockAnchor/DockAnchor.entitlements; 402 | CODE_SIGN_STYLE = Automatic; 403 | COMBINE_HIDPI_IMAGES = YES; 404 | CONFIGURATION_BUILD_DIR = build; 405 | CURRENT_PROJECT_VERSION = 1.5; 406 | DEVELOPMENT_TEAM = 8772WA9289; 407 | ENABLE_HARDENED_RUNTIME = YES; 408 | ENABLE_PREVIEWS = YES; 409 | GENERATE_INFOPLIST_FILE = YES; 410 | INFOPLIST_KEY_CFBundleDisplayName = DockAnchor; 411 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; 412 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 413 | LD_RUNPATH_SEARCH_PATHS = ( 414 | "$(inherited)", 415 | "@executable_path/../Frameworks", 416 | ); 417 | MARKETING_VERSION = 1.5; 418 | PRODUCT_BUNDLE_IDENTIFIER = bwyatt.DockAnchor; 419 | PRODUCT_NAME = "$(TARGET_NAME)"; 420 | REGISTER_APP_GROUPS = YES; 421 | SWIFT_EMIT_LOC_STRINGS = YES; 422 | SWIFT_VERSION = 5.0; 423 | }; 424 | name = Debug; 425 | }; 426 | 49C8733C2E15BF5300F73B78 /* Release */ = { 427 | isa = XCBuildConfiguration; 428 | buildSettings = { 429 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 430 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 431 | ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; 432 | BUILT_PRODUCTS_DIR = build; 433 | CODE_SIGN_ENTITLEMENTS = DockAnchor/DockAnchor.entitlements; 434 | CODE_SIGN_STYLE = Automatic; 435 | COMBINE_HIDPI_IMAGES = YES; 436 | CONFIGURATION_BUILD_DIR = build; 437 | CURRENT_PROJECT_VERSION = 1.5; 438 | DEVELOPMENT_TEAM = 8772WA9289; 439 | ENABLE_HARDENED_RUNTIME = YES; 440 | ENABLE_PREVIEWS = YES; 441 | GENERATE_INFOPLIST_FILE = YES; 442 | INFOPLIST_KEY_CFBundleDisplayName = DockAnchor; 443 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; 444 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 445 | LD_RUNPATH_SEARCH_PATHS = ( 446 | "$(inherited)", 447 | "@executable_path/../Frameworks", 448 | ); 449 | MARKETING_VERSION = 1.5; 450 | PRODUCT_BUNDLE_IDENTIFIER = bwyatt.DockAnchor; 451 | PRODUCT_NAME = "$(TARGET_NAME)"; 452 | REGISTER_APP_GROUPS = YES; 453 | SWIFT_EMIT_LOC_STRINGS = YES; 454 | SWIFT_VERSION = 5.0; 455 | }; 456 | name = Release; 457 | }; 458 | 49C8733E2E15BF5300F73B78 /* Debug */ = { 459 | isa = XCBuildConfiguration; 460 | buildSettings = { 461 | BUNDLE_LOADER = "$(TEST_HOST)"; 462 | CODE_SIGN_STYLE = Automatic; 463 | CURRENT_PROJECT_VERSION = 1; 464 | DEVELOPMENT_TEAM = 8772WA9289; 465 | GENERATE_INFOPLIST_FILE = YES; 466 | MACOSX_DEPLOYMENT_TARGET = 15.4; 467 | MARKETING_VERSION = 1.0; 468 | PRODUCT_BUNDLE_IDENTIFIER = bwyatt.DockAnchorTests; 469 | PRODUCT_NAME = "$(TARGET_NAME)"; 470 | SWIFT_EMIT_LOC_STRINGS = NO; 471 | SWIFT_VERSION = 5.0; 472 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/DockAnchor.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/DockAnchor"; 473 | }; 474 | name = Debug; 475 | }; 476 | 49C8733F2E15BF5300F73B78 /* Release */ = { 477 | isa = XCBuildConfiguration; 478 | buildSettings = { 479 | BUNDLE_LOADER = "$(TEST_HOST)"; 480 | CODE_SIGN_STYLE = Automatic; 481 | CURRENT_PROJECT_VERSION = 1; 482 | DEVELOPMENT_TEAM = 8772WA9289; 483 | GENERATE_INFOPLIST_FILE = YES; 484 | MACOSX_DEPLOYMENT_TARGET = 15.4; 485 | MARKETING_VERSION = 1.0; 486 | PRODUCT_BUNDLE_IDENTIFIER = bwyatt.DockAnchorTests; 487 | PRODUCT_NAME = "$(TARGET_NAME)"; 488 | SWIFT_EMIT_LOC_STRINGS = NO; 489 | SWIFT_VERSION = 5.0; 490 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/DockAnchor.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/DockAnchor"; 491 | }; 492 | name = Release; 493 | }; 494 | 49C873412E15BF5300F73B78 /* Debug */ = { 495 | isa = XCBuildConfiguration; 496 | buildSettings = { 497 | CODE_SIGN_STYLE = Automatic; 498 | CURRENT_PROJECT_VERSION = 1; 499 | DEVELOPMENT_TEAM = 8772WA9289; 500 | GENERATE_INFOPLIST_FILE = YES; 501 | MARKETING_VERSION = 1.0; 502 | PRODUCT_BUNDLE_IDENTIFIER = bwyatt.DockAnchorUITests; 503 | PRODUCT_NAME = "$(TARGET_NAME)"; 504 | SWIFT_EMIT_LOC_STRINGS = NO; 505 | SWIFT_VERSION = 5.0; 506 | TEST_TARGET_NAME = DockAnchor; 507 | }; 508 | name = Debug; 509 | }; 510 | 49C873422E15BF5300F73B78 /* Release */ = { 511 | isa = XCBuildConfiguration; 512 | buildSettings = { 513 | CODE_SIGN_STYLE = Automatic; 514 | CURRENT_PROJECT_VERSION = 1; 515 | DEVELOPMENT_TEAM = 8772WA9289; 516 | GENERATE_INFOPLIST_FILE = YES; 517 | MARKETING_VERSION = 1.0; 518 | PRODUCT_BUNDLE_IDENTIFIER = bwyatt.DockAnchorUITests; 519 | PRODUCT_NAME = "$(TARGET_NAME)"; 520 | SWIFT_EMIT_LOC_STRINGS = NO; 521 | SWIFT_VERSION = 5.0; 522 | TEST_TARGET_NAME = DockAnchor; 523 | }; 524 | name = Release; 525 | }; 526 | /* End XCBuildConfiguration section */ 527 | 528 | /* Begin XCConfigurationList section */ 529 | 49C8730E2E15BF5100F73B78 /* Build configuration list for PBXProject "DockAnchor" */ = { 530 | isa = XCConfigurationList; 531 | buildConfigurations = ( 532 | 49C873382E15BF5300F73B78 /* Debug */, 533 | 49C873392E15BF5300F73B78 /* Release */, 534 | ); 535 | defaultConfigurationIsVisible = 0; 536 | defaultConfigurationName = Release; 537 | }; 538 | 49C8733A2E15BF5300F73B78 /* Build configuration list for PBXNativeTarget "DockAnchor" */ = { 539 | isa = XCConfigurationList; 540 | buildConfigurations = ( 541 | 49C8733B2E15BF5300F73B78 /* Debug */, 542 | 49C8733C2E15BF5300F73B78 /* Release */, 543 | ); 544 | defaultConfigurationIsVisible = 0; 545 | defaultConfigurationName = Release; 546 | }; 547 | 49C8733D2E15BF5300F73B78 /* Build configuration list for PBXNativeTarget "DockAnchorTests" */ = { 548 | isa = XCConfigurationList; 549 | buildConfigurations = ( 550 | 49C8733E2E15BF5300F73B78 /* Debug */, 551 | 49C8733F2E15BF5300F73B78 /* Release */, 552 | ); 553 | defaultConfigurationIsVisible = 0; 554 | defaultConfigurationName = Release; 555 | }; 556 | 49C873402E15BF5300F73B78 /* Build configuration list for PBXNativeTarget "DockAnchorUITests" */ = { 557 | isa = XCConfigurationList; 558 | buildConfigurations = ( 559 | 49C873412E15BF5300F73B78 /* Debug */, 560 | 49C873422E15BF5300F73B78 /* Release */, 561 | ); 562 | defaultConfigurationIsVisible = 0; 563 | defaultConfigurationName = Release; 564 | }; 565 | /* End XCConfigurationList section */ 566 | }; 567 | rootObject = 49C8730B2E15BF5100F73B78 /* Project object */; 568 | } 569 | -------------------------------------------------------------------------------- /DockAnchor/DockMonitor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DockMonitor.swift 3 | // DockAnchor 4 | // 5 | // Created by Bradley Wyatt on 7/2/25. 6 | // 7 | 8 | import Foundation 9 | import Cocoa 10 | import ApplicationServices 11 | import Carbon 12 | import CoreGraphics 13 | import Combine 14 | 15 | class DockMonitor: NSObject, ObservableObject { 16 | @Published var isActive = false 17 | @Published var anchoredDisplay: String = "Primary" 18 | @Published var statusMessage = "Dock Anchor Ready" 19 | @Published var availableDisplays: [DisplayInfo] = [] 20 | 21 | private var eventTap: CFMachPort? 22 | private var runLoopSource: CFRunLoopSource? 23 | private var isMonitoring = false 24 | private var anchorDisplayID: CGDirectDisplayID = 0 25 | private var dockPosition: DockPosition = .bottom 26 | private var cancellables = Set() 27 | 28 | enum DockPosition { 29 | case bottom, left, right 30 | } 31 | 32 | struct DisplayInfo: Identifiable, Hashable { 33 | let id: CGDirectDisplayID 34 | let frame: CGRect 35 | let name: String 36 | let isPrimary: Bool 37 | 38 | func hash(into hasher: inout Hasher) { 39 | hasher.combine(id) 40 | } 41 | 42 | static func == (lhs: DisplayInfo, rhs: DisplayInfo) -> Bool { 43 | return lhs.id == rhs.id 44 | } 45 | } 46 | 47 | override init() { 48 | super.init() 49 | setupInitialState() 50 | setupNotificationObservers() 51 | } 52 | 53 | private func setupInitialState() { 54 | anchorDisplayID = CGMainDisplayID() 55 | updateAvailableDisplays() 56 | detectCurrentDockPosition() 57 | setupDisplayConfigurationMonitoring() 58 | _ = requestAccessibilityPermissions() 59 | } 60 | 61 | private func setupNotificationObservers() { 62 | NotificationCenter.default.publisher(for: .anchorDisplayChanged) 63 | .compactMap { $0.object as? CGDirectDisplayID } 64 | .receive(on: DispatchQueue.main) 65 | .sink { [weak self] newDisplayID in 66 | self?.changeAnchorDisplay(to: newDisplayID) 67 | } 68 | .store(in: &cancellables) 69 | } 70 | 71 | func updateAvailableDisplays() { 72 | availableDisplays = getAllDisplays() 73 | validateCurrentAnchorDisplay() 74 | updateAnchoredDisplayName() 75 | } 76 | 77 | private func validateCurrentAnchorDisplay() { 78 | // Check if the current anchor display is still available 79 | let isAnchorDisplayAvailable = availableDisplays.contains { $0.id == anchorDisplayID } 80 | 81 | if !isAnchorDisplayAvailable { 82 | // Anchor display is no longer available, switch to primary 83 | anchorDisplayID = CGMainDisplayID() 84 | statusMessage = "Selected display no longer available - switched to Primary" 85 | 86 | // Update the settings to reflect the change 87 | NotificationCenter.default.post(name: .anchorDisplayChanged, object: anchorDisplayID) 88 | 89 | // Reset status message after 3 seconds 90 | DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { [weak self] in 91 | guard let self = self else { return } 92 | if self.isActive { 93 | self.statusMessage = "Dock Anchor Active - Monitoring mouse movement" 94 | } else { 95 | self.statusMessage = "Dock Anchor Ready" 96 | } 97 | } 98 | } 99 | } 100 | 101 | private func updateAnchoredDisplayName() { 102 | if let display = availableDisplays.first(where: { $0.id == anchorDisplayID }) { 103 | anchoredDisplay = display.name 104 | } 105 | } 106 | 107 | func changeAnchorDisplay(to displayID: CGDirectDisplayID) { 108 | // Validate that the requested display is available 109 | let isDisplayAvailable = availableDisplays.contains { $0.id == displayID } 110 | 111 | if isDisplayAvailable { 112 | anchorDisplayID = displayID 113 | updateAnchoredDisplayName() 114 | statusMessage = "Anchor changed to \(anchoredDisplay)" 115 | } else { 116 | // Requested display is not available, use primary instead 117 | anchorDisplayID = CGMainDisplayID() 118 | updateAnchoredDisplayName() 119 | statusMessage = "Requested display not available - using Primary" 120 | 121 | // Update the settings to reflect the actual change 122 | NotificationCenter.default.post(name: .anchorDisplayChanged, object: anchorDisplayID) 123 | } 124 | 125 | // Reset status message after 3 seconds 126 | DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { [weak self] in 127 | guard let self = self else { return } 128 | if self.isActive { 129 | self.statusMessage = "Dock Anchor Active - Monitoring mouse movement" 130 | } else { 131 | self.statusMessage = "Dock Anchor Ready" 132 | } 133 | } 134 | } 135 | 136 | private func detectCurrentDockPosition() { 137 | let orientation = UserDefaults.standard.string(forKey: "com.apple.dock.orientation") ?? "bottom" 138 | switch orientation { 139 | case "left": 140 | dockPosition = .left 141 | case "right": 142 | dockPosition = .right 143 | default: 144 | dockPosition = .bottom 145 | } 146 | } 147 | 148 | func requestAccessibilityPermissions() -> Bool { 149 | let options = [kAXTrustedCheckOptionPrompt.takeRetainedValue(): true] 150 | let trusted = AXIsProcessTrustedWithOptions(options as CFDictionary) 151 | 152 | if !trusted { 153 | DispatchQueue.main.async { [weak self] in 154 | self?.statusMessage = "Accessibility permissions required" 155 | } 156 | } 157 | 158 | return trusted 159 | } 160 | 161 | func startMonitoring() { 162 | guard requestAccessibilityPermissions() else { 163 | statusMessage = "Please grant accessibility permissions in System Preferences" 164 | return 165 | } 166 | 167 | guard !isMonitoring else { return } 168 | 169 | updateAvailableDisplays() 170 | 171 | let eventMask = CGEventMask(1 << CGEventType.mouseMoved.rawValue) 172 | 173 | eventTap = CGEvent.tapCreate( 174 | tap: .cgSessionEventTap, 175 | place: .headInsertEventTap, 176 | options: .defaultTap, 177 | eventsOfInterest: eventMask, 178 | callback: { (proxy, type, event, refcon) -> Unmanaged? in 179 | let monitor = Unmanaged.fromOpaque(refcon!).takeUnretainedValue() 180 | return monitor.handleMouseEvent(proxy: proxy, type: type, event: event) 181 | }, 182 | userInfo: Unmanaged.passUnretained(self).toOpaque() 183 | ) 184 | 185 | guard let eventTap = eventTap else { 186 | statusMessage = "Failed to create event tap" 187 | return 188 | } 189 | 190 | runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) 191 | CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) 192 | CGEvent.tapEnable(tap: eventTap, enable: true) 193 | 194 | isMonitoring = true 195 | DispatchQueue.main.async { [weak self] in 196 | self?.isActive = true 197 | self?.statusMessage = "Dock Anchor Active - Monitoring mouse movement" 198 | } 199 | } 200 | 201 | func stopMonitoring() { 202 | guard isMonitoring else { return } 203 | 204 | isMonitoring = false 205 | 206 | // Safely disable and clean up event tap 207 | if let eventTap = eventTap { 208 | CGEvent.tapEnable(tap: eventTap, enable: false) 209 | self.eventTap = nil 210 | } 211 | 212 | // Safely remove run loop source 213 | if let runLoopSource = runLoopSource { 214 | CFRunLoopRemoveSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) 215 | self.runLoopSource = nil 216 | } 217 | 218 | DispatchQueue.main.async { [weak self] in 219 | self?.isActive = false 220 | self?.statusMessage = "Dock Anchor Stopped" 221 | } 222 | } 223 | 224 | private func handleMouseEvent(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent) -> Unmanaged? { 225 | guard type == .mouseMoved else { 226 | return Unmanaged.passUnretained(event) 227 | } 228 | 229 | let location = event.location 230 | 231 | // Check if mouse is approaching dock trigger zone on non-anchor displays 232 | if shouldBlockDockMovement(at: location) { 233 | // Block the event by not passing it through 234 | return nil 235 | } 236 | 237 | return Unmanaged.passUnretained(event) 238 | } 239 | 240 | private func shouldBlockDockMovement(at location: CGPoint) -> Bool { 241 | // Check if mouse is in dock trigger zone of non-anchor displays 242 | for display in availableDisplays { 243 | if display.id == anchorDisplayID { continue } 244 | 245 | let triggerZone = getDockTriggerZone(for: display) 246 | if triggerZone.contains(location) { 247 | DispatchQueue.main.async { [weak self] in 248 | self?.statusMessage = "Blocked dock movement attempt to \(display.name)" 249 | 250 | // Reset status message after 2 seconds 251 | DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in 252 | guard let self = self else { return } 253 | self.statusMessage = "Dock Anchor Active - Monitoring mouse movement" 254 | } 255 | } 256 | return true 257 | } 258 | } 259 | 260 | return false 261 | } 262 | 263 | private func getDockTriggerZone(for display: DisplayInfo) -> CGRect { 264 | switch dockPosition { 265 | case .bottom: 266 | return CGRect( 267 | x: display.frame.minX, 268 | y: display.frame.maxY - 10, 269 | width: display.frame.width, 270 | height: 10 271 | ) 272 | case .left: 273 | return CGRect( 274 | x: display.frame.minX, 275 | y: display.frame.minY, 276 | width: 10, 277 | height: display.frame.height 278 | ) 279 | case .right: 280 | return CGRect( 281 | x: display.frame.maxX - 10, 282 | y: display.frame.minY, 283 | width: 10, 284 | height: display.frame.height 285 | ) 286 | } 287 | } 288 | 289 | private func getAllDisplays() -> [DisplayInfo] { 290 | var displays: [DisplayInfo] = [] 291 | 292 | let maxDisplays: UInt32 = 16 293 | var displayIDs = [CGDirectDisplayID](repeating: 0, count: Int(maxDisplays)) 294 | var displayCount: UInt32 = 0 295 | 296 | let result = CGGetActiveDisplayList(maxDisplays, &displayIDs, &displayCount) 297 | 298 | guard result == .success else { return displays } 299 | 300 | // Get system display information once 301 | let systemDisplays = getSystemDisplaysInfo() 302 | let mainDisplayID = CGMainDisplayID() 303 | 304 | for i in 0.. [(name: String, info: [String: String])] { 320 | let task = Process() 321 | task.launchPath = "/usr/sbin/system_profiler" 322 | task.arguments = ["SPDisplaysDataType"] 323 | 324 | let pipe = Pipe() 325 | task.standardOutput = pipe 326 | 327 | do { 328 | try task.run() 329 | task.waitUntilExit() 330 | 331 | let data = pipe.fileHandleForReading.readDataToEndOfFile() 332 | let output = String(data: data, encoding: .utf8) ?? "" 333 | 334 | // Parse the output to find display names 335 | let lines = output.components(separatedBy: .newlines) 336 | var displays: [(name: String, info: [String: String])] = [] 337 | var currentDisplayName: String? 338 | var currentDisplayInfo: [String: String] = [:] 339 | 340 | for line in lines { 341 | let trimmedLine = line.trimmingCharacters(in: .whitespaces) 342 | 343 | // Look for display names (lines ending with ":" that are indented) 344 | if trimmedLine.hasSuffix(":") && line.hasPrefix(" ") && !line.hasPrefix(" ") { 345 | let displayName = String(trimmedLine.dropLast()) // Remove the ":" 346 | 347 | // Save the previous display if we have one 348 | if let prevDisplayName = currentDisplayName { 349 | displays.append((name: prevDisplayName, info: currentDisplayInfo)) 350 | } 351 | 352 | // Start new display 353 | currentDisplayName = displayName 354 | currentDisplayInfo = [:] 355 | } 356 | 357 | // Collect display properties 358 | if line.hasPrefix(" ") && currentDisplayName != nil { 359 | let propertyLine = line.trimmingCharacters(in: .whitespaces) 360 | if propertyLine.contains(":") { 361 | let components = propertyLine.components(separatedBy: ":") 362 | if components.count >= 2 { 363 | let key = components[0].trimmingCharacters(in: .whitespaces) 364 | let value = components[1].trimmingCharacters(in: .whitespaces) 365 | currentDisplayInfo[key] = value 366 | } 367 | } 368 | } 369 | } 370 | 371 | // Add the last display 372 | if let lastDisplayName = currentDisplayName { 373 | displays.append((name: lastDisplayName, info: currentDisplayInfo)) 374 | } 375 | 376 | // Debug: print all found displays 377 | print("📱 Found \(displays.count) displays:") 378 | for display in displays { 379 | print(" - \(display.name): \(display.info)") 380 | } 381 | 382 | return displays 383 | 384 | } catch { 385 | print("Error getting system display info: \(error)") 386 | return [] 387 | } 388 | } 389 | 390 | private func getDisplayName(for displayID: CGDirectDisplayID, systemDisplays: [(name: String, info: [String: String])]) -> String { 391 | let mainDisplayID = CGMainDisplayID() 392 | 393 | // Get the actual display name from the system 394 | if let displayName = findBestDisplayMatch(displayID: displayID, displays: systemDisplays) { 395 | let isPrimary = displayID == mainDisplayID 396 | return isPrimary ? "\(displayName) (Primary)" : displayName 397 | } 398 | 399 | // Fallback to generic names if we can't get the system name 400 | if displayID == mainDisplayID { 401 | return "Primary Display" 402 | } else { 403 | // Try to get a more descriptive name based on position 404 | let frame = CGDisplayBounds(displayID) 405 | let mainFrame = CGDisplayBounds(mainDisplayID) 406 | 407 | if frame.minX > mainFrame.maxX { 408 | return "Right Display" 409 | } else if frame.maxX < mainFrame.minX { 410 | return "Left Display" 411 | } else if frame.minY > mainFrame.maxY { 412 | return "Bottom Display" 413 | } else if frame.maxY < mainFrame.minY { 414 | return "Top Display" 415 | } else { 416 | return "Secondary Display" 417 | } 418 | } 419 | } 420 | 421 | 422 | 423 | private func findBestDisplayMatch(displayID: CGDirectDisplayID, displays: [(name: String, info: [String: String])]) -> String? { 424 | let frame = CGDisplayBounds(displayID) 425 | let actualResolution = "\(Int(frame.width)) x \(Int(frame.height))" 426 | let mainDisplayID = CGMainDisplayID() 427 | let isMainDisplay = displayID == mainDisplayID 428 | 429 | print("🔍 Matching display ID \(displayID) with resolution \(actualResolution), isMain: \(isMainDisplay)") 430 | print("🔍 Available displays in system_profiler: \(displays.map { $0.name })") 431 | 432 | // First priority: Check for Virtual Device/AirPlay for Sidecar displays WITH resolution match 433 | for display in displays { 434 | // Check if this is a Sidecar display by name first AND resolution matches 435 | if display.name.contains("Sidecar") { 436 | if resolution_matches_exactly(actualResolution, display.info["Resolution"]) || 437 | resolution_matches_approximately(actualResolution, display.info["Resolution"]) { 438 | print("✅ Found Sidecar display by name with matching resolution: \(display.name)") 439 | return "Sidecar" 440 | } 441 | } 442 | 443 | // Only check for Virtual Device + AirPlay combination for Sidecar WITH resolution match 444 | if let virtualDevice = display.info["Virtual Device"], virtualDevice.contains("Yes"), 445 | let connectionType = display.info["Connection Type"], connectionType.contains("AirPlay") { 446 | if resolution_matches_exactly(actualResolution, display.info["Resolution"]) || 447 | resolution_matches_approximately(actualResolution, display.info["Resolution"]) { 448 | print("✅ Found Sidecar device match with matching resolution: \(display.name)") 449 | return "Sidecar" 450 | } 451 | } 452 | } 453 | 454 | // Second priority: Check for Built-in displays by Display Type or Connection Type 455 | for display in displays { 456 | if let displayType = display.info["Display Type"], displayType.contains("Built-in") { 457 | print("🔍 Found built-in display type: \(display.name) with resolution \(display.info["Resolution"] ?? "unknown")") 458 | if resolution_matches_exactly(actualResolution, display.info["Resolution"]) { 459 | print("✅ Found built-in display match: \(display.name)") 460 | return display.name.contains("Color LCD") ? "Built-in Display" : display.name 461 | } else { 462 | print("❌ Built-in display resolution doesn't match: actual=\(actualResolution), reported=\(display.info["Resolution"] ?? "unknown")") 463 | } 464 | } 465 | if let connectionType = display.info["Connection Type"], connectionType.contains("Internal") { 466 | print("🔍 Found internal connection type: \(display.name) with resolution \(display.info["Resolution"] ?? "unknown")") 467 | if resolution_matches_exactly(actualResolution, display.info["Resolution"]) { 468 | print("✅ Found internal display match: \(display.name)") 469 | return display.name.contains("Color LCD") ? "Built-in Display" : display.name 470 | } else { 471 | print("❌ Internal display resolution doesn't match: actual=\(actualResolution), reported=\(display.info["Resolution"] ?? "unknown")") 472 | } 473 | } 474 | } 475 | 476 | // Third priority: Check for external displays with exact resolution match 477 | for display in displays { 478 | if let resolution = display.info["Resolution"] { 479 | if resolution_matches_exactly(actualResolution, resolution) { 480 | // Skip displays we've already handled 481 | if let connectionType = display.info["Connection Type"] { 482 | if connectionType.contains("Internal") || connectionType.contains("AirPlay") { 483 | continue 484 | } 485 | } 486 | if let virtualDevice = display.info["Virtual Device"], virtualDevice.contains("Yes") { 487 | continue 488 | } 489 | 490 | print("✅ Found exact resolution match for external display: \(display.name) - \(resolution)") 491 | return display.name 492 | } 493 | } 494 | } 495 | 496 | // Fourth priority: Check by Main Display flag with resolution confirmation 497 | if isMainDisplay { 498 | for display in displays { 499 | if let mainDisplayFlag = display.info["Main Display"], mainDisplayFlag.contains("Yes") { 500 | if let resolution = display.info["Resolution"], resolution_matches_exactly(actualResolution, resolution) { 501 | print("✅ Found main display flag match: \(display.name) - \(resolution)") 502 | return display.name.contains("Color LCD") ? "Built-in Display" : display.name 503 | } 504 | } 505 | } 506 | } 507 | 508 | // Fifth priority: Approximate resolution matching as fallback 509 | for display in displays { 510 | if let resolution = display.info["Resolution"] { 511 | if resolution_matches_approximately(actualResolution, resolution) { 512 | print("✅ Found approximate resolution match: \(display.name) - \(resolution)") 513 | if display.name.contains("Color LCD") || display.name.contains("Built-in") { 514 | return "Built-in Display" 515 | } else if display.name.contains("Sidecar") { 516 | return "Sidecar" 517 | } else { 518 | return display.name 519 | } 520 | } 521 | } 522 | } 523 | 524 | print("❌ No match found for display ID \(displayID)") 525 | return nil 526 | } 527 | 528 | private func resolution_matches_exactly(_ actual: String, _ reported: String?) -> Bool { 529 | guard let reported = reported else { return false } 530 | 531 | // Extract width and height from actual resolution (e.g., "3840 x 1600") 532 | let actualComponents = actual.components(separatedBy: " x ") 533 | guard actualComponents.count == 2, 534 | let actualWidth = Int(actualComponents[0]), 535 | let actualHeight = Int(actualComponents[1]) else { 536 | return false 537 | } 538 | 539 | // Extract width and height from reported resolution (e.g., "3840 x 1600 (Ultra-wide 4K)") 540 | let reportedNumbers = reported.components(separatedBy: CharacterSet.decimalDigits.inverted).filter { !$0.isEmpty } 541 | guard reportedNumbers.count >= 2, 542 | let reportedWidth = Int(reportedNumbers[0]), 543 | let reportedHeight = Int(reportedNumbers[1]) else { 544 | return false 545 | } 546 | 547 | print("🔍 Comparing actual \(actualWidth)x\(actualHeight) with reported \(reportedWidth)x\(reportedHeight)") 548 | 549 | // Check exact match 550 | if actualWidth == reportedWidth && actualHeight == reportedHeight { 551 | return true 552 | } 553 | 554 | // Check for common scaling scenarios (e.g., Retina displays) 555 | // For Retina displays, the actual resolution is often scaled 556 | if (actualWidth == reportedWidth / 2 && actualHeight == reportedHeight / 2) || 557 | (actualWidth * 2 == reportedWidth && actualHeight * 2 == reportedHeight) { 558 | print("🔍 Found scaled resolution match (2x scaling)") 559 | return true 560 | } 561 | 562 | // For some Retina displays, the scaling might be different 563 | // Check if the aspect ratio matches and if one is a reasonable scale of the other 564 | let actualAspectRatio = Double(actualWidth) / Double(actualHeight) 565 | let reportedAspectRatio = Double(reportedWidth) / Double(reportedHeight) 566 | 567 | // If aspect ratios are close (within 5% tolerance) and one is a scale of the other 568 | if abs(actualAspectRatio - reportedAspectRatio) < 0.05 { 569 | let scaleX = Double(reportedWidth) / Double(actualWidth) 570 | let scaleY = Double(reportedHeight) / Double(actualHeight) 571 | 572 | // Check if both scales are similar (within 10% tolerance) and reasonable (between 1.2 and 3.0) 573 | if abs(scaleX - scaleY) < 0.1 && scaleX > 1.2 && scaleX < 3.0 { 574 | print("🔍 Found scaled resolution match (scale factor: \(scaleX))") 575 | return true 576 | } 577 | } 578 | 579 | return false 580 | } 581 | 582 | private func resolution_matches_approximately(_ actual: String, _ reported: String?) -> Bool { 583 | guard let reported = reported else { return false } 584 | 585 | // Extract numbers from resolution strings 586 | let actualComponents = actual.components(separatedBy: " x ") 587 | let reportedNumbers = reported.components(separatedBy: CharacterSet.decimalDigits.inverted).filter { !$0.isEmpty } 588 | 589 | if actualComponents.count == 2 && reportedNumbers.count >= 2 { 590 | let actualWidth = Int(actualComponents[0]) ?? 0 591 | let actualHeight = Int(actualComponents[1]) ?? 0 592 | let reportedWidth = Int(reportedNumbers[0]) ?? 0 593 | let reportedHeight = Int(reportedNumbers[1]) ?? 0 594 | 595 | return actualWidth == reportedWidth && actualHeight == reportedHeight 596 | } 597 | 598 | return false 599 | } 600 | 601 | func refreshDisplays() { 602 | let maxDisplays: UInt32 = 16 603 | var displayIDs = [CGDirectDisplayID](repeating: 0, count: Int(maxDisplays)) 604 | var displayCount: UInt32 = 0 605 | 606 | let result = CGGetActiveDisplayList(maxDisplays, &displayIDs, &displayCount) 607 | 608 | guard result == .success else { 609 | print("Failed to get display list: \(result)") 610 | return 611 | } 612 | 613 | // Get system display information once 614 | let systemDisplays = getSystemDisplaysInfo() 615 | 616 | var newDisplays: [DisplayInfo] = [] 617 | 618 | for i in 0.. DockPosition { 668 | // Get the current dock position from system preferences 669 | let task = Process() 670 | task.launchPath = "/usr/bin/defaults" 671 | task.arguments = ["read", "com.apple.dock", "orientation"] 672 | 673 | let pipe = Pipe() 674 | task.standardOutput = pipe 675 | 676 | do { 677 | try task.run() 678 | task.waitUntilExit() 679 | 680 | let data = pipe.fileHandleForReading.readDataToEndOfFile() 681 | let output = String(data: data, encoding: .utf8) ?? "" 682 | let orientation = output.trimmingCharacters(in: .whitespacesAndNewlines) 683 | 684 | switch orientation { 685 | case "left": 686 | return .left 687 | case "right": 688 | return .right 689 | default: 690 | return .bottom 691 | } 692 | } catch { 693 | return .bottom 694 | } 695 | } 696 | 697 | private func getDisplayForDockPosition(_ position: DockPosition) -> CGDirectDisplayID { 698 | // For bottom dock, find which display the dock is currently on 699 | if position == .bottom { 700 | // Get the current mouse position to determine which display the dock is on 701 | let mouseLocation = NSEvent.mouseLocation 702 | let screen = NSScreen.screens.first { screen in 703 | let frame = screen.frame 704 | return mouseLocation.x >= frame.minX && mouseLocation.x <= frame.maxX && 705 | mouseLocation.y >= frame.minY && mouseLocation.y <= frame.maxY 706 | } 707 | 708 | if let screen = screen { 709 | return CGDirectDisplayID(screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? UInt32 ?? 0) 710 | } 711 | } 712 | 713 | // Fallback to main display 714 | return CGMainDisplayID() 715 | } 716 | 717 | private func setupDisplayConfigurationMonitoring() { 718 | // Register for display configuration changes 719 | CGDisplayRegisterReconfigurationCallback({ (displayID, flags, userInfo) in 720 | guard let userInfo = userInfo else { return } 721 | let monitor = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() 722 | monitor.handleDisplayConfigurationChange(displayID: displayID, flags: flags) 723 | }, Unmanaged.passUnretained(self).toOpaque()) 724 | } 725 | 726 | private func handleDisplayConfigurationChange(displayID: CGDirectDisplayID, flags: CGDisplayChangeSummaryFlags) { 727 | // Handle display configuration changes 728 | DispatchQueue.main.async { [weak self] in 729 | guard let self = self else { return } 730 | 731 | if flags.contains(.addFlag) { 732 | self.statusMessage = "New display detected - updating available displays" 733 | self.updateAvailableDisplays() 734 | 735 | // Reset status message after 3 seconds 736 | DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { [weak self] in 737 | guard let self = self else { return } 738 | if self.isActive { 739 | self.statusMessage = "Dock Anchor Active - Monitoring mouse movement" 740 | } else { 741 | self.statusMessage = "Dock Anchor Ready" 742 | } 743 | } 744 | } else if flags.contains(.removeFlag) { 745 | self.statusMessage = "Display removed - updating available displays" 746 | self.updateAvailableDisplays() 747 | 748 | // Check if the anchor display was removed 749 | if displayID == self.anchorDisplayID { 750 | self.anchorDisplayID = CGMainDisplayID() 751 | self.updateAnchoredDisplayName() 752 | self.statusMessage = "Anchor display removed - switched to Primary" 753 | 754 | // Update the settings to reflect the change 755 | NotificationCenter.default.post(name: .anchorDisplayChanged, object: self.anchorDisplayID) 756 | } 757 | 758 | // Reset status message after 3 seconds 759 | DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { [weak self] in 760 | guard let self = self else { return } 761 | if self.isActive { 762 | self.statusMessage = "Dock Anchor Active - Monitoring mouse movement" 763 | } else { 764 | self.statusMessage = "Dock Anchor Ready" 765 | } 766 | } 767 | } else if flags.contains(.enabledFlag) || flags.contains(.disabledFlag) { 768 | self.updateAvailableDisplays() 769 | } else if flags.contains(.desktopShapeChangedFlag) { 770 | // Desktop shape changed - this can indicate primary display change 771 | self.updateAvailableDisplays() 772 | self.statusMessage = "Display configuration changed - updating displays" 773 | 774 | // Reset status message after 2 seconds 775 | DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in 776 | guard let self = self else { return } 777 | if self.isActive { 778 | self.statusMessage = "Dock Anchor Active - Monitoring mouse movement" 779 | } else { 780 | self.statusMessage = "Dock Anchor Ready" 781 | } 782 | } 783 | } 784 | } 785 | } 786 | 787 | deinit { 788 | // Ensure we're on the main thread for cleanup 789 | if Thread.isMainThread { 790 | stopMonitoring() 791 | } else { 792 | DispatchQueue.main.sync { [weak self] in 793 | self?.stopMonitoring() 794 | } 795 | } 796 | 797 | // Remove display configuration callback 798 | CGDisplayRemoveReconfigurationCallback({ (displayID, flags, userInfo) in 799 | guard let userInfo = userInfo else { return } 800 | let monitor = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() 801 | monitor.handleDisplayConfigurationChange(displayID: displayID, flags: flags) 802 | }, Unmanaged.passUnretained(self).toOpaque()) 803 | 804 | cancellables.removeAll() 805 | NotificationCenter.default.removeObserver(self) 806 | } 807 | } --------------------------------------------------------------------------------