dmux 5.3.0 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/README.md +2 -1
  2. package/dist/DmuxApp.d.ts.map +1 -1
  3. package/dist/DmuxApp.js +226 -51
  4. package/dist/DmuxApp.js.map +1 -1
  5. package/dist/FileBrowserApp.d.ts +4 -0
  6. package/dist/FileBrowserApp.d.ts.map +1 -0
  7. package/dist/FileBrowserApp.js +693 -0
  8. package/dist/FileBrowserApp.js.map +1 -0
  9. package/dist/actions/implementations/closeAction.d.ts.map +1 -1
  10. package/dist/actions/implementations/closeAction.js +47 -5
  11. package/dist/actions/implementations/closeAction.js.map +1 -1
  12. package/dist/actions/implementations/mergeAction.d.ts.map +1 -1
  13. package/dist/actions/implementations/mergeAction.js +51 -16
  14. package/dist/actions/implementations/mergeAction.js.map +1 -1
  15. package/dist/actions/implementations/viewAction.d.ts.map +1 -1
  16. package/dist/actions/implementations/viewAction.js +7 -0
  17. package/dist/actions/implementations/viewAction.js.map +1 -1
  18. package/dist/actions/index.d.ts.map +1 -1
  19. package/dist/actions/index.js +24 -0
  20. package/dist/actions/index.js.map +1 -1
  21. package/dist/actions/merge/multiMergeOrchestrator.js +1 -1
  22. package/dist/actions/merge/multiMergeOrchestrator.js.map +1 -1
  23. package/dist/actions/types.d.ts +19 -4
  24. package/dist/actions/types.d.ts.map +1 -1
  25. package/dist/actions/types.js +91 -0
  26. package/dist/actions/types.js.map +1 -1
  27. package/dist/components/indicators/Spinner.d.ts +2 -0
  28. package/dist/components/indicators/Spinner.d.ts.map +1 -1
  29. package/dist/components/indicators/Spinner.js +4 -4
  30. package/dist/components/indicators/Spinner.js.map +1 -1
  31. package/dist/components/panes/KebabMenu.d.ts +2 -2
  32. package/dist/components/panes/KebabMenu.d.ts.map +1 -1
  33. package/dist/components/panes/KebabMenu.js +9 -4
  34. package/dist/components/panes/KebabMenu.js.map +1 -1
  35. package/dist/components/panes/PaneCard.d.ts.map +1 -1
  36. package/dist/components/panes/PaneCard.js +20 -4
  37. package/dist/components/panes/PaneCard.js.map +1 -1
  38. package/dist/components/panes/PanesGrid.d.ts +1 -0
  39. package/dist/components/panes/PanesGrid.d.ts.map +1 -1
  40. package/dist/components/panes/PanesGrid.js +11 -3
  41. package/dist/components/panes/PanesGrid.js.map +1 -1
  42. package/dist/components/popups/agentChoicePopup.js +29 -24
  43. package/dist/components/popups/agentChoicePopup.js.map +1 -1
  44. package/dist/components/popups/agentChoiceSelection.d.ts +8 -0
  45. package/dist/components/popups/agentChoiceSelection.d.ts.map +1 -0
  46. package/dist/components/popups/agentChoiceSelection.js +14 -0
  47. package/dist/components/popups/agentChoiceSelection.js.map +1 -0
  48. package/dist/components/popups/kebabMenuPopup.js +9 -4
  49. package/dist/components/popups/kebabMenuPopup.js.map +1 -1
  50. package/dist/components/popups/notificationSoundsPopup.d.ts +25 -0
  51. package/dist/components/popups/notificationSoundsPopup.d.ts.map +1 -0
  52. package/dist/components/popups/notificationSoundsPopup.js +165 -0
  53. package/dist/components/popups/notificationSoundsPopup.js.map +1 -0
  54. package/dist/components/popups/settingsPopup.js +361 -26
  55. package/dist/components/popups/settingsPopup.js.map +1 -1
  56. package/dist/components/popups/shortcutsPopup.js +11 -5
  57. package/dist/components/popups/shortcutsPopup.js.map +1 -1
  58. package/dist/constants/layout.d.ts +9 -0
  59. package/dist/constants/layout.d.ts.map +1 -0
  60. package/dist/constants/layout.js +9 -0
  61. package/dist/constants/layout.js.map +1 -0
  62. package/dist/hooks/useActionSystem.d.ts +9 -5
  63. package/dist/hooks/useActionSystem.d.ts.map +1 -1
  64. package/dist/hooks/useActionSystem.js +21 -19
  65. package/dist/hooks/useActionSystem.js.map +1 -1
  66. package/dist/hooks/useInputHandling.d.ts +1 -0
  67. package/dist/hooks/useInputHandling.d.ts.map +1 -1
  68. package/dist/hooks/useInputHandling.js +499 -79
  69. package/dist/hooks/useInputHandling.js.map +1 -1
  70. package/dist/hooks/usePaneCreation.d.ts +4 -2
  71. package/dist/hooks/usePaneCreation.d.ts.map +1 -1
  72. package/dist/hooks/usePaneCreation.js +6 -0
  73. package/dist/hooks/usePaneCreation.js.map +1 -1
  74. package/dist/hooks/usePaneLoading.d.ts +1 -0
  75. package/dist/hooks/usePaneLoading.d.ts.map +1 -1
  76. package/dist/hooks/usePaneLoading.js +10 -7
  77. package/dist/hooks/usePaneLoading.js.map +1 -1
  78. package/dist/hooks/usePaneSync.js +2 -2
  79. package/dist/hooks/usePaneSync.js.map +1 -1
  80. package/dist/hooks/usePanes.d.ts.map +1 -1
  81. package/dist/hooks/usePanes.js +18 -4
  82. package/dist/hooks/usePanes.js.map +1 -1
  83. package/dist/hooks/useProjectActivity.d.ts +7 -0
  84. package/dist/hooks/useProjectActivity.d.ts.map +1 -0
  85. package/dist/hooks/useProjectActivity.js +79 -0
  86. package/dist/hooks/useProjectActivity.js.map +1 -0
  87. package/dist/hooks/useServices.d.ts +3 -0
  88. package/dist/hooks/useServices.d.ts.map +1 -1
  89. package/dist/hooks/useServices.js +4 -0
  90. package/dist/hooks/useServices.js.map +1 -1
  91. package/dist/index.js +59 -15
  92. package/dist/index.js.map +1 -1
  93. package/dist/layout/LayoutCalculator.d.ts.map +1 -1
  94. package/dist/layout/LayoutCalculator.js +4 -1
  95. package/dist/layout/LayoutCalculator.js.map +1 -1
  96. package/dist/services/DmuxAttentionService.d.ts +33 -0
  97. package/dist/services/DmuxAttentionService.d.ts.map +1 -0
  98. package/dist/services/DmuxAttentionService.js +172 -0
  99. package/dist/services/DmuxAttentionService.js.map +1 -0
  100. package/dist/services/DmuxFocusService.d.ts +58 -0
  101. package/dist/services/DmuxFocusService.d.ts.map +1 -0
  102. package/dist/services/DmuxFocusService.js +671 -0
  103. package/dist/services/DmuxFocusService.js.map +1 -0
  104. package/dist/services/PaneAnalyzer.d.ts +11 -2
  105. package/dist/services/PaneAnalyzer.d.ts.map +1 -1
  106. package/dist/services/PaneAnalyzer.js +88 -22
  107. package/dist/services/PaneAnalyzer.js.map +1 -1
  108. package/dist/services/PopupManager.d.ts +31 -14
  109. package/dist/services/PopupManager.d.ts.map +1 -1
  110. package/dist/services/PopupManager.js +147 -68
  111. package/dist/services/PopupManager.js.map +1 -1
  112. package/dist/services/StatusDetector.d.ts +14 -0
  113. package/dist/services/StatusDetector.d.ts.map +1 -1
  114. package/dist/services/StatusDetector.js +60 -12
  115. package/dist/services/StatusDetector.js.map +1 -1
  116. package/dist/services/TmuxHookManager.d.ts.map +1 -1
  117. package/dist/services/TmuxHookManager.js +4 -2
  118. package/dist/services/TmuxHookManager.js.map +1 -1
  119. package/dist/services/TmuxService.d.ts +37 -2
  120. package/dist/services/TmuxService.d.ts.map +1 -1
  121. package/dist/services/TmuxService.js +138 -16
  122. package/dist/services/TmuxService.js.map +1 -1
  123. package/dist/types/activity.d.ts +4 -0
  124. package/dist/types/activity.d.ts.map +1 -0
  125. package/dist/types/activity.js +2 -0
  126. package/dist/types/activity.js.map +1 -0
  127. package/dist/types.d.ts +18 -1
  128. package/dist/types.d.ts.map +1 -1
  129. package/dist/utils/attachAgent.d.ts +4 -0
  130. package/dist/utils/attachAgent.d.ts.map +1 -1
  131. package/dist/utils/attachAgent.js +18 -7
  132. package/dist/utils/attachAgent.js.map +1 -1
  133. package/dist/utils/controlPaneRecovery.d.ts +2 -0
  134. package/dist/utils/controlPaneRecovery.d.ts.map +1 -0
  135. package/dist/utils/controlPaneRecovery.js +156 -0
  136. package/dist/utils/controlPaneRecovery.js.map +1 -0
  137. package/dist/utils/devWatchExit.d.ts +2 -0
  138. package/dist/utils/devWatchExit.d.ts.map +1 -0
  139. package/dist/utils/devWatchExit.js +10 -0
  140. package/dist/utils/devWatchExit.js.map +1 -0
  141. package/dist/utils/dmuxCommand.d.ts +3 -0
  142. package/dist/utils/dmuxCommand.d.ts.map +1 -0
  143. package/dist/utils/dmuxCommand.js +18 -0
  144. package/dist/utils/dmuxCommand.js.map +1 -0
  145. package/dist/utils/fileBrowser.d.ts +61 -0
  146. package/dist/utils/fileBrowser.d.ts.map +1 -0
  147. package/dist/utils/fileBrowser.js +567 -0
  148. package/dist/utils/fileBrowser.js.map +1 -0
  149. package/dist/utils/focusDetection.d.ts +38 -0
  150. package/dist/utils/focusDetection.d.ts.map +1 -0
  151. package/dist/utils/focusDetection.js +57 -0
  152. package/dist/utils/focusDetection.js.map +1 -0
  153. package/dist/utils/generated-agents-doc.d.ts +1 -1
  154. package/dist/utils/generated-agents-doc.js +1 -1
  155. package/dist/utils/git.d.ts +4 -0
  156. package/dist/utils/git.d.ts.map +1 -1
  157. package/dist/utils/git.js +15 -0
  158. package/dist/utils/git.js.map +1 -1
  159. package/dist/utils/layoutManager.d.ts +5 -1
  160. package/dist/utils/layoutManager.d.ts.map +1 -1
  161. package/dist/utils/layoutManager.js +103 -26
  162. package/dist/utils/layoutManager.js.map +1 -1
  163. package/dist/utils/mergeTargets.d.ts +17 -0
  164. package/dist/utils/mergeTargets.d.ts.map +1 -0
  165. package/dist/utils/mergeTargets.js +132 -0
  166. package/dist/utils/mergeTargets.js.map +1 -0
  167. package/dist/utils/mergeValidation.d.ts.map +1 -1
  168. package/dist/utils/mergeValidation.js +12 -5
  169. package/dist/utils/mergeValidation.js.map +1 -1
  170. package/dist/utils/notificationSoundPreview.d.ts +10 -0
  171. package/dist/utils/notificationSoundPreview.d.ts.map +1 -0
  172. package/dist/utils/notificationSoundPreview.js +54 -0
  173. package/dist/utils/notificationSoundPreview.js.map +1 -0
  174. package/dist/utils/notificationSounds.d.ts +17 -0
  175. package/dist/utils/notificationSounds.d.ts.map +1 -0
  176. package/dist/utils/notificationSounds.js +123 -0
  177. package/dist/utils/notificationSounds.js.map +1 -0
  178. package/dist/utils/paneAttentionHeuristics.d.ts +4 -0
  179. package/dist/utils/paneAttentionHeuristics.d.ts.map +1 -0
  180. package/dist/utils/paneAttentionHeuristics.js +135 -0
  181. package/dist/utils/paneAttentionHeuristics.js.map +1 -0
  182. package/dist/utils/paneCreation.d.ts +3 -1
  183. package/dist/utils/paneCreation.d.ts.map +1 -1
  184. package/dist/utils/paneCreation.js +23 -5
  185. package/dist/utils/paneCreation.js.map +1 -1
  186. package/dist/utils/paneVisibility.d.ts +12 -0
  187. package/dist/utils/paneVisibility.d.ts.map +1 -0
  188. package/dist/utils/paneVisibility.js +60 -0
  189. package/dist/utils/paneVisibility.js.map +1 -0
  190. package/dist/utils/processShutdown.d.ts +4 -0
  191. package/dist/utils/processShutdown.d.ts.map +1 -0
  192. package/dist/utils/processShutdown.js +27 -0
  193. package/dist/utils/processShutdown.js.map +1 -0
  194. package/dist/utils/promptStore.d.ts.map +1 -1
  195. package/dist/utils/promptStore.js +6 -0
  196. package/dist/utils/promptStore.js.map +1 -1
  197. package/dist/utils/reopenWorktree.d.ts.map +1 -1
  198. package/dist/utils/reopenWorktree.js +8 -0
  199. package/dist/utils/reopenWorktree.js.map +1 -1
  200. package/dist/utils/runtimePaths.d.ts +1 -0
  201. package/dist/utils/runtimePaths.d.ts.map +1 -1
  202. package/dist/utils/runtimePaths.js +3 -0
  203. package/dist/utils/runtimePaths.js.map +1 -1
  204. package/dist/utils/settingsManager.d.ts +3 -0
  205. package/dist/utils/settingsManager.d.ts.map +1 -1
  206. package/dist/utils/settingsManager.js +203 -11
  207. package/dist/utils/settingsManager.js.map +1 -1
  208. package/dist/utils/tmux.d.ts +5 -1
  209. package/dist/utils/tmux.d.ts.map +1 -1
  210. package/dist/utils/tmux.js +23 -5
  211. package/dist/utils/tmux.js.map +1 -1
  212. package/dist/utils/tmuxConfigOnboarding.js +1 -1
  213. package/dist/utils/tmuxConfigOnboarding.js.map +1 -1
  214. package/dist/utils/tmuxHookCommands.d.ts +14 -0
  215. package/dist/utils/tmuxHookCommands.d.ts.map +1 -0
  216. package/dist/utils/tmuxHookCommands.js +30 -0
  217. package/dist/utils/tmuxHookCommands.js.map +1 -0
  218. package/dist/utils/tmuxRuntimeCompatibility.d.ts +11 -0
  219. package/dist/utils/tmuxRuntimeCompatibility.d.ts.map +1 -0
  220. package/dist/utils/tmuxRuntimeCompatibility.js +71 -0
  221. package/dist/utils/tmuxRuntimeCompatibility.js.map +1 -0
  222. package/dist/utils/worktreeMetadata.d.ts +9 -0
  223. package/dist/utils/worktreeMetadata.d.ts.map +1 -0
  224. package/dist/utils/worktreeMetadata.js +60 -0
  225. package/dist/utils/worktreeMetadata.js.map +1 -0
  226. package/dist/workers/PaneWorker.js +64 -128
  227. package/dist/workers/PaneWorker.js.map +1 -1
  228. package/dist/workers/WorkerMessages.d.ts +4 -1
  229. package/dist/workers/WorkerMessages.d.ts.map +1 -1
  230. package/dist/workers/WorkerMessages.js.map +1 -1
  231. package/native/macos/dmux-helper-Info.plist +30 -0
  232. package/native/macos/dmux-helper-icon.png +0 -0
  233. package/native/macos/dmux-helper.swift +831 -0
  234. package/native/macos/sounds/dmux-braam.caf +0 -0
  235. package/native/macos/sounds/dmux-brass.caf +0 -0
  236. package/native/macos/sounds/dmux-ding-bell.caf +0 -0
  237. package/native/macos/sounds/dmux-future.caf +0 -0
  238. package/native/macos/sounds/dmux-harp.caf +0 -0
  239. package/native/macos/sounds/dmux-quiet-bells.caf +0 -0
  240. package/native/macos/sounds/dmux-sonar.caf +0 -0
  241. package/native/macos/sounds/dmux-success.caf +0 -0
  242. package/native/macos/sounds/dmux-triumphant-trumpet.caf +0 -0
  243. package/native/macos/sounds/dmux-war-horn.caf +0 -0
  244. package/package.json +3 -1
@@ -0,0 +1,831 @@
1
+ import AppKit
2
+ import ApplicationServices
3
+ import Dispatch
4
+ import Foundation
5
+
6
+ private let helperBundleIconName = "dmux-helper"
7
+ private let notificationTitleTokenKey = "titleToken"
8
+ private let notificationBundleIdKey = "bundleId"
9
+ private let notificationTmuxPaneIdKey = "tmuxPaneId"
10
+ private let notificationTmuxSocketPathKey = "tmuxSocketPath"
11
+
12
+ struct SubscribeMessage: Decodable {
13
+ let type: String
14
+ let instanceId: String
15
+ let titleToken: String
16
+ let bundleId: String?
17
+ let terminalProgram: String?
18
+ }
19
+
20
+ struct IncomingEnvelope: Decodable {
21
+ let type: String
22
+ }
23
+
24
+ struct NotifyMessage: Decodable {
25
+ let type: String
26
+ let title: String
27
+ let subtitle: String?
28
+ let body: String
29
+ let soundName: String?
30
+ let titleToken: String?
31
+ let bundleId: String?
32
+ let tmuxPaneId: String?
33
+ let tmuxSocketPath: String?
34
+ }
35
+
36
+ struct PreviewSoundMessage: Decodable {
37
+ let type: String
38
+ let soundName: String?
39
+ }
40
+
41
+ struct FocusStateMessage: Encodable {
42
+ let type = "focus-state"
43
+ let instanceId: String
44
+ let fullyFocused: Bool
45
+ let accessibilityTrusted: Bool
46
+ let matchedTitleToken: Bool
47
+ let frontmostAppBundleId: String?
48
+ let focusedWindowTitle: String?
49
+ }
50
+
51
+ struct FocusSnapshot: Equatable {
52
+ let accessibilityTrusted: Bool
53
+ let frontmostAppBundleId: String?
54
+ let focusedWindowTitle: String?
55
+ }
56
+
57
+ struct FrontmostWindowInfo {
58
+ let processIdentifier: pid_t
59
+ let bundleId: String?
60
+ let title: String?
61
+ }
62
+
63
+ private struct PreparedNotificationSound {
64
+ let notificationSoundName: String?
65
+ let helperSound: NSSound?
66
+ }
67
+
68
+ final class ClientConnection {
69
+ let fd: Int32
70
+ private let queue: DispatchQueue
71
+ private var source: DispatchSourceRead?
72
+ private var buffer = Data()
73
+ private var subscribeMessage: SubscribeMessage?
74
+ private let onReady: (ClientConnection, SubscribeMessage) -> Void
75
+ private let onNotify: (NotifyMessage) -> Void
76
+ private let onPreviewSound: (PreviewSoundMessage) -> Void
77
+ private let onClose: (ClientConnection) -> Void
78
+
79
+ init(
80
+ fd: Int32,
81
+ queue: DispatchQueue,
82
+ onReady: @escaping (ClientConnection, SubscribeMessage) -> Void,
83
+ onNotify: @escaping (NotifyMessage) -> Void,
84
+ onPreviewSound: @escaping (PreviewSoundMessage) -> Void,
85
+ onClose: @escaping (ClientConnection) -> Void
86
+ ) {
87
+ self.fd = fd
88
+ self.queue = queue
89
+ self.onReady = onReady
90
+ self.onNotify = onNotify
91
+ self.onPreviewSound = onPreviewSound
92
+ self.onClose = onClose
93
+ }
94
+
95
+ func start() {
96
+ let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue)
97
+ source.setEventHandler { [weak self] in
98
+ self?.handleReadable()
99
+ }
100
+ source.setCancelHandler { [fd] in
101
+ Darwin.close(fd)
102
+ }
103
+ self.source = source
104
+ source.resume()
105
+ }
106
+
107
+ func send(snapshot: FocusSnapshot) {
108
+ guard let subscribeMessage else {
109
+ return
110
+ }
111
+
112
+ let matchedTitleToken = snapshot.focusedWindowTitle?.contains(subscribeMessage.titleToken) ?? false
113
+ let bundleMatches: Bool
114
+ if let expectedBundleId = subscribeMessage.bundleId, !expectedBundleId.isEmpty {
115
+ bundleMatches = snapshot.frontmostAppBundleId == expectedBundleId
116
+ } else {
117
+ bundleMatches = true
118
+ }
119
+
120
+ let fullyFocused = snapshot.accessibilityTrusted && matchedTitleToken && bundleMatches
121
+ let message = FocusStateMessage(
122
+ instanceId: subscribeMessage.instanceId,
123
+ fullyFocused: fullyFocused,
124
+ accessibilityTrusted: snapshot.accessibilityTrusted,
125
+ matchedTitleToken: matchedTitleToken,
126
+ frontmostAppBundleId: snapshot.frontmostAppBundleId,
127
+ focusedWindowTitle: snapshot.focusedWindowTitle
128
+ )
129
+
130
+ let encoder = JSONEncoder()
131
+ guard let encoded = try? encoder.encode(message) else {
132
+ return
133
+ }
134
+
135
+ var payload = encoded
136
+ payload.append(0x0A)
137
+ payload.withUnsafeBytes { rawBuffer in
138
+ guard let baseAddress = rawBuffer.baseAddress else {
139
+ return
140
+ }
141
+ _ = Darwin.write(fd, baseAddress, rawBuffer.count)
142
+ }
143
+ }
144
+
145
+ func close() {
146
+ source?.cancel()
147
+ source = nil
148
+ onClose(self)
149
+ }
150
+
151
+ private func handleReadable() {
152
+ var chunk = [UInt8](repeating: 0, count: 4096)
153
+ let bytesRead = Darwin.read(fd, &chunk, chunk.count)
154
+
155
+ if bytesRead <= 0 {
156
+ close()
157
+ return
158
+ }
159
+
160
+ buffer.append(chunk, count: Int(bytesRead))
161
+ guard subscribeMessage == nil else {
162
+ return
163
+ }
164
+
165
+ guard let newlineIndex = buffer.firstIndex(of: 0x0A) else {
166
+ return
167
+ }
168
+
169
+ let lineData = buffer.prefix(upTo: newlineIndex)
170
+ buffer.removeSubrange(...newlineIndex)
171
+
172
+ do {
173
+ let envelope = try JSONDecoder().decode(IncomingEnvelope.self, from: lineData)
174
+ switch envelope.type {
175
+ case "subscribe":
176
+ let message = try JSONDecoder().decode(SubscribeMessage.self, from: lineData)
177
+ subscribeMessage = message
178
+ onReady(self, message)
179
+ case "notify":
180
+ let message = try JSONDecoder().decode(NotifyMessage.self, from: lineData)
181
+ onNotify(message)
182
+ close()
183
+ case "preview-sound":
184
+ let message = try JSONDecoder().decode(PreviewSoundMessage.self, from: lineData)
185
+ onPreviewSound(message)
186
+ close()
187
+ default:
188
+ close()
189
+ }
190
+ } catch {
191
+ close()
192
+ }
193
+ }
194
+ }
195
+
196
+ final class FocusMonitor: NSObject, NSUserNotificationCenterDelegate, NSSoundDelegate {
197
+ private let socketPath: String
198
+ private let pollInterval: TimeInterval
199
+ private let queue = DispatchQueue(label: "dmux.helper.focus", qos: .userInitiated)
200
+ private var listenerFD: Int32 = -1
201
+ private var listenerSource: DispatchSourceRead?
202
+ private var timer: DispatchSourceTimer?
203
+ private var clients: [Int32: ClientConnection] = [:]
204
+ private var lastSnapshot: FocusSnapshot?
205
+ private var didRequestAccessibilityPrompt = false
206
+ private var activeNotificationSounds: [ObjectIdentifier: NSSound] = [:]
207
+ private var activePreviewSounds: [ObjectIdentifier: NSSound] = [:]
208
+ private var activePreviewNotification: NSUserNotification?
209
+
210
+ init(socketPath: String, pollInterval: TimeInterval) {
211
+ self.socketPath = socketPath
212
+ self.pollInterval = pollInterval
213
+ super.init()
214
+ }
215
+
216
+ func start() throws {
217
+ try prepareSocket()
218
+ startAcceptingConnections()
219
+ startPolling()
220
+ }
221
+
222
+ private func prepareSocket() throws {
223
+ let socketDirectory = URL(fileURLWithPath: socketPath).deletingLastPathComponent()
224
+ try FileManager.default.createDirectory(at: socketDirectory, withIntermediateDirectories: true)
225
+
226
+ if FileManager.default.fileExists(atPath: socketPath) {
227
+ try FileManager.default.removeItem(atPath: socketPath)
228
+ }
229
+
230
+ listenerFD = Darwin.socket(AF_UNIX, SOCK_STREAM, 0)
231
+ guard listenerFD >= 0 else {
232
+ throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno))
233
+ }
234
+
235
+ var address = sockaddr_un()
236
+ address.sun_family = sa_family_t(AF_UNIX)
237
+
238
+ let maxPathLength = MemoryLayout.size(ofValue: address.sun_path)
239
+ let utf8Path = socketPath.utf8CString
240
+ guard utf8Path.count <= maxPathLength else {
241
+ throw NSError(domain: "dmux.helper", code: 1, userInfo: [
242
+ NSLocalizedDescriptionKey: "Socket path too long: \(socketPath)",
243
+ ])
244
+ }
245
+
246
+ withUnsafeMutablePointer(to: &address.sun_path) { pathPointer in
247
+ let rawPointer = UnsafeMutableRawPointer(pathPointer).assumingMemoryBound(to: CChar.self)
248
+ _ = utf8Path.withUnsafeBufferPointer { bufferPointer in
249
+ strncpy(rawPointer, bufferPointer.baseAddress, maxPathLength - 1)
250
+ }
251
+ }
252
+
253
+ let addressLength = socklen_t(MemoryLayout<sa_family_t>.size + utf8Path.count)
254
+ let bindResult = withUnsafePointer(to: &address) { pointer in
255
+ pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPointer in
256
+ Darwin.bind(listenerFD, sockPointer, addressLength)
257
+ }
258
+ }
259
+
260
+ guard bindResult == 0 else {
261
+ throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno))
262
+ }
263
+
264
+ guard Darwin.listen(listenerFD, 16) == 0 else {
265
+ throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno))
266
+ }
267
+
268
+ let currentFlags = fcntl(listenerFD, F_GETFL, 0)
269
+ guard currentFlags >= 0, fcntl(listenerFD, F_SETFL, currentFlags | O_NONBLOCK) == 0 else {
270
+ throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno))
271
+ }
272
+ }
273
+
274
+ private func startAcceptingConnections() {
275
+ let source = DispatchSource.makeReadSource(fileDescriptor: listenerFD, queue: queue)
276
+ source.setEventHandler { [weak self] in
277
+ self?.acceptPendingClients()
278
+ }
279
+ source.setCancelHandler { [listenerFD, socketPath] in
280
+ if listenerFD >= 0 {
281
+ Darwin.close(listenerFD)
282
+ }
283
+ try? FileManager.default.removeItem(atPath: socketPath)
284
+ }
285
+ listenerSource = source
286
+ source.resume()
287
+ }
288
+
289
+ private func acceptPendingClients() {
290
+ while true {
291
+ let clientFD = Darwin.accept(listenerFD, nil, nil)
292
+ if clientFD < 0 {
293
+ if errno == EAGAIN || errno == EWOULDBLOCK {
294
+ return
295
+ }
296
+ return
297
+ }
298
+
299
+ let connection = ClientConnection(
300
+ fd: clientFD,
301
+ queue: queue,
302
+ onReady: { [weak self] connection, _ in
303
+ guard let self else {
304
+ return
305
+ }
306
+ connection.send(snapshot: self.lastSnapshot ?? self.captureSnapshot())
307
+ },
308
+ onNotify: { [weak self] message in
309
+ DispatchQueue.main.async {
310
+ self?.deliverNotification(message)
311
+ }
312
+ },
313
+ onPreviewSound: { [weak self] message in
314
+ DispatchQueue.main.async {
315
+ self?.playPreviewSound(message)
316
+ }
317
+ },
318
+ onClose: { [weak self] connection in
319
+ self?.clients.removeValue(forKey: connection.fd)
320
+ }
321
+ )
322
+
323
+ clients[connection.fd] = connection
324
+ connection.start()
325
+ }
326
+ }
327
+
328
+ private func startPolling() {
329
+ let timer = DispatchSource.makeTimerSource(queue: queue)
330
+ timer.schedule(deadline: .now(), repeating: pollInterval)
331
+ timer.setEventHandler { [weak self] in
332
+ self?.pollFocusState()
333
+ }
334
+ self.timer = timer
335
+ timer.resume()
336
+ }
337
+
338
+ private func pollFocusState() {
339
+ let snapshot = snapshotForPolling()
340
+ if snapshot != lastSnapshot {
341
+ lastSnapshot = snapshot
342
+ for client in clients.values {
343
+ client.send(snapshot: snapshot)
344
+ }
345
+ }
346
+ }
347
+
348
+ private func snapshotForPolling() -> FocusSnapshot {
349
+ if Thread.isMainThread {
350
+ return captureSnapshot()
351
+ }
352
+
353
+ return DispatchQueue.main.sync {
354
+ captureSnapshot()
355
+ }
356
+ }
357
+
358
+ private func captureSnapshot() -> FocusSnapshot {
359
+ let accessibilityTrusted: Bool
360
+ if didRequestAccessibilityPrompt {
361
+ accessibilityTrusted = AXIsProcessTrusted()
362
+ } else {
363
+ let trustOptions = [kAXTrustedCheckOptionPrompt.takeRetainedValue() as String: true] as CFDictionary
364
+ accessibilityTrusted = AXIsProcessTrustedWithOptions(trustOptions)
365
+ didRequestAccessibilityPrompt = true
366
+ }
367
+
368
+ let frontmostWindowInfo = captureFrontmostWindowInfo()
369
+ let processIdentifier = frontmostWindowInfo?.processIdentifier
370
+ let bundleId = frontmostWindowInfo?.bundleId
371
+ let fallbackTitle = frontmostWindowInfo?.title
372
+
373
+ guard accessibilityTrusted, let processIdentifier else {
374
+ return FocusSnapshot(
375
+ accessibilityTrusted: accessibilityTrusted,
376
+ frontmostAppBundleId: bundleId,
377
+ focusedWindowTitle: fallbackTitle
378
+ )
379
+ }
380
+
381
+ let appElement = AXUIElementCreateApplication(processIdentifier)
382
+ var focusedWindow: CFTypeRef?
383
+ let focusedWindowResult = AXUIElementCopyAttributeValue(
384
+ appElement,
385
+ kAXFocusedWindowAttribute as CFString,
386
+ &focusedWindow
387
+ )
388
+
389
+ guard focusedWindowResult == .success, let focusedWindowElement = focusedWindow else {
390
+ return FocusSnapshot(
391
+ accessibilityTrusted: accessibilityTrusted,
392
+ frontmostAppBundleId: bundleId,
393
+ focusedWindowTitle: fallbackTitle
394
+ )
395
+ }
396
+
397
+ var titleValue: CFTypeRef?
398
+ let titleResult = AXUIElementCopyAttributeValue(
399
+ focusedWindowElement as! AXUIElement,
400
+ kAXTitleAttribute as CFString,
401
+ &titleValue
402
+ )
403
+
404
+ let title = titleResult == .success ? titleValue as? String : fallbackTitle
405
+ return FocusSnapshot(
406
+ accessibilityTrusted: accessibilityTrusted,
407
+ frontmostAppBundleId: bundleId,
408
+ focusedWindowTitle: title
409
+ )
410
+ }
411
+
412
+ private func captureFrontmostWindowInfo() -> FrontmostWindowInfo? {
413
+ let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements]
414
+ guard let rawWindowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else {
415
+ return nil
416
+ }
417
+
418
+ for windowInfo in rawWindowList {
419
+ guard let ownerPID = windowInfo[kCGWindowOwnerPID as String] as? pid_t else {
420
+ continue
421
+ }
422
+
423
+ let layer = windowInfo[kCGWindowLayer as String] as? Int ?? 0
424
+ if layer != 0 {
425
+ continue
426
+ }
427
+
428
+ let alpha = windowInfo[kCGWindowAlpha as String] as? Double ?? 1
429
+ if alpha <= 0 {
430
+ continue
431
+ }
432
+
433
+ if let bounds = windowInfo[kCGWindowBounds as String] as? [String: Any] {
434
+ let width = bounds["Width"] as? Double ?? 0
435
+ let height = bounds["Height"] as? Double ?? 0
436
+ if width <= 0 || height <= 0 {
437
+ continue
438
+ }
439
+ }
440
+
441
+ let title = windowInfo[kCGWindowName as String] as? String
442
+ let bundleId = NSRunningApplication(processIdentifier: ownerPID)?.bundleIdentifier
443
+ return FrontmostWindowInfo(
444
+ processIdentifier: ownerPID,
445
+ bundleId: bundleId,
446
+ title: title
447
+ )
448
+ }
449
+
450
+ return nil
451
+ }
452
+
453
+ private func deliverNotification(_ message: NotifyMessage) {
454
+ let title = message.title.trimmingCharacters(in: .whitespacesAndNewlines)
455
+ let body = message.body.trimmingCharacters(in: .whitespacesAndNewlines)
456
+ let subtitle = message.subtitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
457
+ guard !title.isEmpty, !body.isEmpty else {
458
+ return
459
+ }
460
+
461
+ let center = NSUserNotificationCenter.default
462
+ center.delegate = self
463
+
464
+ // Bundled notification sounds are played by the helper because Notification Center
465
+ // does not reliably honor custom NSUserNotification sounds on current macOS builds.
466
+ let preparedSound = prepareNotificationSound(from: message.soundName)
467
+ let notification = NSUserNotification()
468
+ notification.identifier = UUID().uuidString
469
+ notification.title = title
470
+ notification.subtitle = subtitle
471
+ notification.informativeText = body
472
+ notification.soundName = preparedSound.notificationSoundName
473
+ notification.deliveryDate = Date()
474
+
475
+ let focusUserInfo = buildNotificationUserInfo(from: message)
476
+ if !focusUserInfo.isEmpty {
477
+ notification.userInfo = focusUserInfo
478
+ notification.hasActionButton = true
479
+ notification.actionButtonTitle = "Open"
480
+ notification.otherButtonTitle = "Dismiss"
481
+ }
482
+
483
+ if let icon = loadAppIcon() {
484
+ let identityImageSelector = NSSelectorFromString("set_identityImage:")
485
+ if notification.responds(to: identityImageSelector) {
486
+ notification.perform(identityImageSelector, with: icon)
487
+ }
488
+
489
+ let borderSelector = NSSelectorFromString("set_identityImageHasBorder:")
490
+ if notification.responds(to: borderSelector) {
491
+ notification.perform(borderSelector, with: NSNumber(value: false))
492
+ }
493
+ }
494
+
495
+ center.deliver(notification)
496
+ playPreparedNotificationSound(preparedSound.helperSound)
497
+ }
498
+
499
+ private func prepareNotificationSound(from requestedSoundName: String?) -> PreparedNotificationSound {
500
+ guard let requestedSoundName else {
501
+ return PreparedNotificationSound(
502
+ notificationSoundName: NSUserNotificationDefaultSoundName,
503
+ helperSound: nil
504
+ )
505
+ }
506
+
507
+ let trimmedSoundName = requestedSoundName.trimmingCharacters(in: .whitespacesAndNewlines)
508
+ guard !trimmedSoundName.isEmpty else {
509
+ return PreparedNotificationSound(
510
+ notificationSoundName: NSUserNotificationDefaultSoundName,
511
+ helperSound: nil
512
+ )
513
+ }
514
+
515
+ let nsSoundName = trimmedSoundName as NSString
516
+ let resourceName = nsSoundName.deletingPathExtension
517
+ let resourceExtension = nsSoundName.pathExtension.isEmpty ? nil : nsSoundName.pathExtension
518
+
519
+ guard let bundledSoundPath = Bundle.main.path(forResource: resourceName, ofType: resourceExtension),
520
+ let helperSound = NSSound(contentsOfFile: bundledSoundPath, byReference: true) else {
521
+ return PreparedNotificationSound(
522
+ notificationSoundName: NSUserNotificationDefaultSoundName,
523
+ helperSound: nil
524
+ )
525
+ }
526
+
527
+ helperSound.delegate = self
528
+ return PreparedNotificationSound(
529
+ notificationSoundName: nil,
530
+ helperSound: helperSound
531
+ )
532
+ }
533
+
534
+ private func playPreparedNotificationSound(_ helperSound: NSSound?) {
535
+ guard let helperSound else {
536
+ return
537
+ }
538
+
539
+ let soundId = ObjectIdentifier(helperSound)
540
+ activeNotificationSounds[soundId] = helperSound
541
+ if !helperSound.play() {
542
+ activeNotificationSounds.removeValue(forKey: soundId)
543
+ }
544
+ }
545
+
546
+ private func playPreviewSound(_ message: PreviewSoundMessage) {
547
+ stopPreviewPlayback()
548
+
549
+ let preparedSound = prepareNotificationSound(from: message.soundName)
550
+ if let helperSound = preparedSound.helperSound {
551
+ playPreparedPreviewSound(helperSound)
552
+ return
553
+ }
554
+
555
+ guard preparedSound.notificationSoundName == NSUserNotificationDefaultSoundName else {
556
+ return
557
+ }
558
+
559
+ let center = NSUserNotificationCenter.default
560
+ center.delegate = self
561
+
562
+ let notification = NSUserNotification()
563
+ notification.identifier = UUID().uuidString
564
+ notification.title = " "
565
+ notification.informativeText = " "
566
+ notification.soundName = NSUserNotificationDefaultSoundName
567
+ notification.deliveryDate = Date()
568
+
569
+ activePreviewNotification = notification
570
+ center.deliver(notification)
571
+
572
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self, weak notification] in
573
+ guard let self, let notification else {
574
+ return
575
+ }
576
+
577
+ if self.activePreviewNotification === notification {
578
+ center.removeDeliveredNotification(notification)
579
+ self.activePreviewNotification = nil
580
+ }
581
+ }
582
+ }
583
+
584
+ private func playPreparedPreviewSound(_ helperSound: NSSound) {
585
+ let soundId = ObjectIdentifier(helperSound)
586
+ activePreviewSounds[soundId] = helperSound
587
+ helperSound.delegate = self
588
+ if !helperSound.play() {
589
+ activePreviewSounds.removeValue(forKey: soundId)
590
+ }
591
+ }
592
+
593
+ private func stopPreviewPlayback() {
594
+ for previewSound in activePreviewSounds.values {
595
+ previewSound.stop()
596
+ }
597
+ activePreviewSounds.removeAll()
598
+
599
+ guard let previewNotification = activePreviewNotification else {
600
+ return
601
+ }
602
+
603
+ NSUserNotificationCenter.default.removeDeliveredNotification(previewNotification)
604
+ activePreviewNotification = nil
605
+ }
606
+
607
+ private func buildNotificationUserInfo(from message: NotifyMessage) -> [String: Any] {
608
+ var userInfo: [String: Any] = [:]
609
+
610
+ if let titleToken = message.titleToken?.trimmingCharacters(in: .whitespacesAndNewlines), !titleToken.isEmpty {
611
+ userInfo[notificationTitleTokenKey] = titleToken
612
+ }
613
+
614
+ if let bundleId = message.bundleId?.trimmingCharacters(in: .whitespacesAndNewlines), !bundleId.isEmpty {
615
+ userInfo[notificationBundleIdKey] = bundleId
616
+ }
617
+
618
+ if let tmuxPaneId = message.tmuxPaneId?.trimmingCharacters(in: .whitespacesAndNewlines), !tmuxPaneId.isEmpty {
619
+ userInfo[notificationTmuxPaneIdKey] = tmuxPaneId
620
+ }
621
+
622
+ if let tmuxSocketPath = message.tmuxSocketPath?.trimmingCharacters(in: .whitespacesAndNewlines), !tmuxSocketPath.isEmpty {
623
+ userInfo[notificationTmuxSocketPathKey] = tmuxSocketPath
624
+ }
625
+
626
+ return userInfo
627
+ }
628
+
629
+ private func loadAppIcon() -> NSImage? {
630
+ if let icon = NSImage(named: helperBundleIconName) {
631
+ return icon
632
+ }
633
+
634
+ guard let iconPath = Bundle.main.path(forResource: helperBundleIconName, ofType: "png") else {
635
+ return nil
636
+ }
637
+
638
+ return NSImage(contentsOfFile: iconPath)
639
+ }
640
+
641
+ func userNotificationCenter(
642
+ _ center: NSUserNotificationCenter,
643
+ shouldPresent notification: NSUserNotification
644
+ ) -> Bool {
645
+ true
646
+ }
647
+
648
+ func userNotificationCenter(
649
+ _ center: NSUserNotificationCenter,
650
+ didActivate notification: NSUserNotification
651
+ ) {
652
+ defer {
653
+ center.removeDeliveredNotification(notification)
654
+ }
655
+
656
+ switch notification.activationType {
657
+ case .actionButtonClicked, .contentsClicked:
658
+ activateNotificationTarget(notification)
659
+ default:
660
+ break
661
+ }
662
+ }
663
+
664
+ func sound(_ sound: NSSound, didFinishPlaying aBool: Bool) {
665
+ activeNotificationSounds.removeValue(forKey: ObjectIdentifier(sound))
666
+ activePreviewSounds.removeValue(forKey: ObjectIdentifier(sound))
667
+ }
668
+
669
+ private func activateNotificationTarget(_ notification: NSUserNotification) {
670
+ let userInfo = notification.userInfo ?? [:]
671
+ let titleToken = userInfo[notificationTitleTokenKey] as? String
672
+ let bundleId = userInfo[notificationBundleIdKey] as? String
673
+ let tmuxPaneId = userInfo[notificationTmuxPaneIdKey] as? String
674
+ let tmuxSocketPath = userInfo[notificationTmuxSocketPathKey] as? String
675
+
676
+ if let bundleId, !bundleId.isEmpty {
677
+ if let titleToken, !titleToken.isEmpty {
678
+ _ = focusTerminalWindow(bundleId: bundleId, titleToken: titleToken)
679
+ } else {
680
+ _ = activateTerminalApplication(bundleId: bundleId)
681
+ }
682
+ }
683
+
684
+ if let tmuxPaneId, !tmuxPaneId.isEmpty {
685
+ selectTmuxPane(tmuxPaneId, socketPath: tmuxSocketPath)
686
+ }
687
+ }
688
+
689
+ private func activateTerminalApplication(bundleId: String) -> Bool {
690
+ let apps = NSRunningApplication.runningApplications(withBundleIdentifier: bundleId)
691
+ guard let app = apps.first else {
692
+ return false
693
+ }
694
+
695
+ return app.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
696
+ }
697
+
698
+ private func focusTerminalWindow(bundleId: String, titleToken: String) -> Bool {
699
+ let apps = NSRunningApplication.runningApplications(withBundleIdentifier: bundleId)
700
+ guard !apps.isEmpty else {
701
+ return false
702
+ }
703
+
704
+ guard AXIsProcessTrusted() else {
705
+ return activateTerminalApplication(bundleId: bundleId)
706
+ }
707
+
708
+ for app in apps {
709
+ let appElement = AXUIElementCreateApplication(app.processIdentifier)
710
+ guard let matchingWindow = findMatchingWindow(appElement: appElement, titleToken: titleToken) else {
711
+ continue
712
+ }
713
+
714
+ _ = app.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
715
+ _ = AXUIElementSetAttributeValue(appElement, kAXFocusedWindowAttribute as CFString, matchingWindow)
716
+ _ = AXUIElementSetAttributeValue(matchingWindow, kAXMainAttribute as CFString, kCFBooleanTrue)
717
+ _ = AXUIElementPerformAction(matchingWindow, kAXRaiseAction as CFString)
718
+ return true
719
+ }
720
+
721
+ return activateTerminalApplication(bundleId: bundleId)
722
+ }
723
+
724
+ private func findMatchingWindow(appElement: AXUIElement, titleToken: String) -> AXUIElement? {
725
+ var windowsValue: CFTypeRef?
726
+ let result = AXUIElementCopyAttributeValue(
727
+ appElement,
728
+ kAXWindowsAttribute as CFString,
729
+ &windowsValue
730
+ )
731
+
732
+ guard result == .success, let windows = windowsValue as? [AXUIElement] else {
733
+ return nil
734
+ }
735
+
736
+ for window in windows {
737
+ guard let title = copyStringAttribute(window, attribute: kAXTitleAttribute as CFString) else {
738
+ continue
739
+ }
740
+
741
+ if title.contains(titleToken) {
742
+ return window
743
+ }
744
+ }
745
+
746
+ return nil
747
+ }
748
+
749
+ private func copyStringAttribute(_ element: AXUIElement, attribute: CFString) -> String? {
750
+ var value: CFTypeRef?
751
+ let result = AXUIElementCopyAttributeValue(element, attribute, &value)
752
+ guard result == .success else {
753
+ return nil
754
+ }
755
+
756
+ return value as? String
757
+ }
758
+
759
+ private func selectTmuxPane(_ paneId: String, socketPath: String?) {
760
+ var arguments = ["tmux"]
761
+ if let socketPath, !socketPath.isEmpty {
762
+ arguments.append(contentsOf: ["-S", socketPath])
763
+ }
764
+ arguments.append(contentsOf: ["select-pane", "-t", paneId])
765
+
766
+ let process = Process()
767
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
768
+ process.arguments = arguments
769
+ process.standardOutput = nil
770
+ process.standardError = nil
771
+ try? process.run()
772
+ }
773
+ }
774
+
775
+ func parseArguments() -> (socketPath: String, pollMilliseconds: Int) {
776
+ var socketPath = "\(NSHomeDirectory())/.dmux/native-helper/run/dmux-helper.sock"
777
+ var pollMilliseconds = 250
778
+
779
+ var iterator = CommandLine.arguments.dropFirst().makeIterator()
780
+ while let argument = iterator.next() {
781
+ switch argument {
782
+ case "--socket":
783
+ if let value = iterator.next() {
784
+ socketPath = value
785
+ }
786
+ case "--poll-ms":
787
+ if let value = iterator.next(), let parsedValue = Int(value) {
788
+ pollMilliseconds = max(100, parsedValue)
789
+ }
790
+ default:
791
+ continue
792
+ }
793
+ }
794
+
795
+ return (socketPath, pollMilliseconds)
796
+ }
797
+
798
+ final class DmuxHelperAppDelegate: NSObject, NSApplicationDelegate {
799
+ private let monitor: FocusMonitor
800
+
801
+ init(monitor: FocusMonitor) {
802
+ self.monitor = monitor
803
+ }
804
+
805
+ func applicationDidFinishLaunching(_ notification: Notification) {
806
+ NSApplication.shared.setActivationPolicy(.prohibited)
807
+
808
+ if let icon = NSImage(named: helperBundleIconName)
809
+ ?? Bundle.main.path(forResource: helperBundleIconName, ofType: "png").flatMap(NSImage.init(contentsOfFile:)) {
810
+ NSApplication.shared.applicationIconImage = icon
811
+ }
812
+
813
+ do {
814
+ try monitor.start()
815
+ } catch {
816
+ fputs("dmux-helper failed to start: \(error)\n", stderr)
817
+ NSApp.terminate(nil)
818
+ }
819
+ }
820
+ }
821
+
822
+ let arguments = parseArguments()
823
+ let monitor = FocusMonitor(
824
+ socketPath: arguments.socketPath,
825
+ pollInterval: TimeInterval(arguments.pollMilliseconds) / 1000.0
826
+ )
827
+
828
+ let app = NSApplication.shared
829
+ let delegate = DmuxHelperAppDelegate(monitor: monitor)
830
+ app.delegate = delegate
831
+ app.run()