├── 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 |
6 |
7 | 
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 | 
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 | 
37 |
38 | ### Menu Bar Menu
39 |
40 | 
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 | }
--------------------------------------------------------------------------------