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.
- package/README.md +2 -1
- package/dist/DmuxApp.d.ts.map +1 -1
- package/dist/DmuxApp.js +226 -51
- package/dist/DmuxApp.js.map +1 -1
- package/dist/FileBrowserApp.d.ts +4 -0
- package/dist/FileBrowserApp.d.ts.map +1 -0
- package/dist/FileBrowserApp.js +693 -0
- package/dist/FileBrowserApp.js.map +1 -0
- package/dist/actions/implementations/closeAction.d.ts.map +1 -1
- package/dist/actions/implementations/closeAction.js +47 -5
- package/dist/actions/implementations/closeAction.js.map +1 -1
- package/dist/actions/implementations/mergeAction.d.ts.map +1 -1
- package/dist/actions/implementations/mergeAction.js +51 -16
- package/dist/actions/implementations/mergeAction.js.map +1 -1
- package/dist/actions/implementations/viewAction.d.ts.map +1 -1
- package/dist/actions/implementations/viewAction.js +7 -0
- package/dist/actions/implementations/viewAction.js.map +1 -1
- package/dist/actions/index.d.ts.map +1 -1
- package/dist/actions/index.js +24 -0
- package/dist/actions/index.js.map +1 -1
- package/dist/actions/merge/multiMergeOrchestrator.js +1 -1
- package/dist/actions/merge/multiMergeOrchestrator.js.map +1 -1
- package/dist/actions/types.d.ts +19 -4
- package/dist/actions/types.d.ts.map +1 -1
- package/dist/actions/types.js +91 -0
- package/dist/actions/types.js.map +1 -1
- package/dist/components/indicators/Spinner.d.ts +2 -0
- package/dist/components/indicators/Spinner.d.ts.map +1 -1
- package/dist/components/indicators/Spinner.js +4 -4
- package/dist/components/indicators/Spinner.js.map +1 -1
- package/dist/components/panes/KebabMenu.d.ts +2 -2
- package/dist/components/panes/KebabMenu.d.ts.map +1 -1
- package/dist/components/panes/KebabMenu.js +9 -4
- package/dist/components/panes/KebabMenu.js.map +1 -1
- package/dist/components/panes/PaneCard.d.ts.map +1 -1
- package/dist/components/panes/PaneCard.js +20 -4
- package/dist/components/panes/PaneCard.js.map +1 -1
- package/dist/components/panes/PanesGrid.d.ts +1 -0
- package/dist/components/panes/PanesGrid.d.ts.map +1 -1
- package/dist/components/panes/PanesGrid.js +11 -3
- package/dist/components/panes/PanesGrid.js.map +1 -1
- package/dist/components/popups/agentChoicePopup.js +29 -24
- package/dist/components/popups/agentChoicePopup.js.map +1 -1
- package/dist/components/popups/agentChoiceSelection.d.ts +8 -0
- package/dist/components/popups/agentChoiceSelection.d.ts.map +1 -0
- package/dist/components/popups/agentChoiceSelection.js +14 -0
- package/dist/components/popups/agentChoiceSelection.js.map +1 -0
- package/dist/components/popups/kebabMenuPopup.js +9 -4
- package/dist/components/popups/kebabMenuPopup.js.map +1 -1
- package/dist/components/popups/notificationSoundsPopup.d.ts +25 -0
- package/dist/components/popups/notificationSoundsPopup.d.ts.map +1 -0
- package/dist/components/popups/notificationSoundsPopup.js +165 -0
- package/dist/components/popups/notificationSoundsPopup.js.map +1 -0
- package/dist/components/popups/settingsPopup.js +361 -26
- package/dist/components/popups/settingsPopup.js.map +1 -1
- package/dist/components/popups/shortcutsPopup.js +11 -5
- package/dist/components/popups/shortcutsPopup.js.map +1 -1
- package/dist/constants/layout.d.ts +9 -0
- package/dist/constants/layout.d.ts.map +1 -0
- package/dist/constants/layout.js +9 -0
- package/dist/constants/layout.js.map +1 -0
- package/dist/hooks/useActionSystem.d.ts +9 -5
- package/dist/hooks/useActionSystem.d.ts.map +1 -1
- package/dist/hooks/useActionSystem.js +21 -19
- package/dist/hooks/useActionSystem.js.map +1 -1
- package/dist/hooks/useInputHandling.d.ts +1 -0
- package/dist/hooks/useInputHandling.d.ts.map +1 -1
- package/dist/hooks/useInputHandling.js +499 -79
- package/dist/hooks/useInputHandling.js.map +1 -1
- package/dist/hooks/usePaneCreation.d.ts +4 -2
- package/dist/hooks/usePaneCreation.d.ts.map +1 -1
- package/dist/hooks/usePaneCreation.js +6 -0
- package/dist/hooks/usePaneCreation.js.map +1 -1
- package/dist/hooks/usePaneLoading.d.ts +1 -0
- package/dist/hooks/usePaneLoading.d.ts.map +1 -1
- package/dist/hooks/usePaneLoading.js +10 -7
- package/dist/hooks/usePaneLoading.js.map +1 -1
- package/dist/hooks/usePaneSync.js +2 -2
- package/dist/hooks/usePaneSync.js.map +1 -1
- package/dist/hooks/usePanes.d.ts.map +1 -1
- package/dist/hooks/usePanes.js +18 -4
- package/dist/hooks/usePanes.js.map +1 -1
- package/dist/hooks/useProjectActivity.d.ts +7 -0
- package/dist/hooks/useProjectActivity.d.ts.map +1 -0
- package/dist/hooks/useProjectActivity.js +79 -0
- package/dist/hooks/useProjectActivity.js.map +1 -0
- package/dist/hooks/useServices.d.ts +3 -0
- package/dist/hooks/useServices.d.ts.map +1 -1
- package/dist/hooks/useServices.js +4 -0
- package/dist/hooks/useServices.js.map +1 -1
- package/dist/index.js +59 -15
- package/dist/index.js.map +1 -1
- package/dist/layout/LayoutCalculator.d.ts.map +1 -1
- package/dist/layout/LayoutCalculator.js +4 -1
- package/dist/layout/LayoutCalculator.js.map +1 -1
- package/dist/services/DmuxAttentionService.d.ts +33 -0
- package/dist/services/DmuxAttentionService.d.ts.map +1 -0
- package/dist/services/DmuxAttentionService.js +172 -0
- package/dist/services/DmuxAttentionService.js.map +1 -0
- package/dist/services/DmuxFocusService.d.ts +58 -0
- package/dist/services/DmuxFocusService.d.ts.map +1 -0
- package/dist/services/DmuxFocusService.js +671 -0
- package/dist/services/DmuxFocusService.js.map +1 -0
- package/dist/services/PaneAnalyzer.d.ts +11 -2
- package/dist/services/PaneAnalyzer.d.ts.map +1 -1
- package/dist/services/PaneAnalyzer.js +88 -22
- package/dist/services/PaneAnalyzer.js.map +1 -1
- package/dist/services/PopupManager.d.ts +31 -14
- package/dist/services/PopupManager.d.ts.map +1 -1
- package/dist/services/PopupManager.js +147 -68
- package/dist/services/PopupManager.js.map +1 -1
- package/dist/services/StatusDetector.d.ts +14 -0
- package/dist/services/StatusDetector.d.ts.map +1 -1
- package/dist/services/StatusDetector.js +60 -12
- package/dist/services/StatusDetector.js.map +1 -1
- package/dist/services/TmuxHookManager.d.ts.map +1 -1
- package/dist/services/TmuxHookManager.js +4 -2
- package/dist/services/TmuxHookManager.js.map +1 -1
- package/dist/services/TmuxService.d.ts +37 -2
- package/dist/services/TmuxService.d.ts.map +1 -1
- package/dist/services/TmuxService.js +138 -16
- package/dist/services/TmuxService.js.map +1 -1
- package/dist/types/activity.d.ts +4 -0
- package/dist/types/activity.d.ts.map +1 -0
- package/dist/types/activity.js +2 -0
- package/dist/types/activity.js.map +1 -0
- package/dist/types.d.ts +18 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/attachAgent.d.ts +4 -0
- package/dist/utils/attachAgent.d.ts.map +1 -1
- package/dist/utils/attachAgent.js +18 -7
- package/dist/utils/attachAgent.js.map +1 -1
- package/dist/utils/controlPaneRecovery.d.ts +2 -0
- package/dist/utils/controlPaneRecovery.d.ts.map +1 -0
- package/dist/utils/controlPaneRecovery.js +156 -0
- package/dist/utils/controlPaneRecovery.js.map +1 -0
- package/dist/utils/devWatchExit.d.ts +2 -0
- package/dist/utils/devWatchExit.d.ts.map +1 -0
- package/dist/utils/devWatchExit.js +10 -0
- package/dist/utils/devWatchExit.js.map +1 -0
- package/dist/utils/dmuxCommand.d.ts +3 -0
- package/dist/utils/dmuxCommand.d.ts.map +1 -0
- package/dist/utils/dmuxCommand.js +18 -0
- package/dist/utils/dmuxCommand.js.map +1 -0
- package/dist/utils/fileBrowser.d.ts +61 -0
- package/dist/utils/fileBrowser.d.ts.map +1 -0
- package/dist/utils/fileBrowser.js +567 -0
- package/dist/utils/fileBrowser.js.map +1 -0
- package/dist/utils/focusDetection.d.ts +38 -0
- package/dist/utils/focusDetection.d.ts.map +1 -0
- package/dist/utils/focusDetection.js +57 -0
- package/dist/utils/focusDetection.js.map +1 -0
- package/dist/utils/generated-agents-doc.d.ts +1 -1
- package/dist/utils/generated-agents-doc.js +1 -1
- package/dist/utils/git.d.ts +4 -0
- package/dist/utils/git.d.ts.map +1 -1
- package/dist/utils/git.js +15 -0
- package/dist/utils/git.js.map +1 -1
- package/dist/utils/layoutManager.d.ts +5 -1
- package/dist/utils/layoutManager.d.ts.map +1 -1
- package/dist/utils/layoutManager.js +103 -26
- package/dist/utils/layoutManager.js.map +1 -1
- package/dist/utils/mergeTargets.d.ts +17 -0
- package/dist/utils/mergeTargets.d.ts.map +1 -0
- package/dist/utils/mergeTargets.js +132 -0
- package/dist/utils/mergeTargets.js.map +1 -0
- package/dist/utils/mergeValidation.d.ts.map +1 -1
- package/dist/utils/mergeValidation.js +12 -5
- package/dist/utils/mergeValidation.js.map +1 -1
- package/dist/utils/notificationSoundPreview.d.ts +10 -0
- package/dist/utils/notificationSoundPreview.d.ts.map +1 -0
- package/dist/utils/notificationSoundPreview.js +54 -0
- package/dist/utils/notificationSoundPreview.js.map +1 -0
- package/dist/utils/notificationSounds.d.ts +17 -0
- package/dist/utils/notificationSounds.d.ts.map +1 -0
- package/dist/utils/notificationSounds.js +123 -0
- package/dist/utils/notificationSounds.js.map +1 -0
- package/dist/utils/paneAttentionHeuristics.d.ts +4 -0
- package/dist/utils/paneAttentionHeuristics.d.ts.map +1 -0
- package/dist/utils/paneAttentionHeuristics.js +135 -0
- package/dist/utils/paneAttentionHeuristics.js.map +1 -0
- package/dist/utils/paneCreation.d.ts +3 -1
- package/dist/utils/paneCreation.d.ts.map +1 -1
- package/dist/utils/paneCreation.js +23 -5
- package/dist/utils/paneCreation.js.map +1 -1
- package/dist/utils/paneVisibility.d.ts +12 -0
- package/dist/utils/paneVisibility.d.ts.map +1 -0
- package/dist/utils/paneVisibility.js +60 -0
- package/dist/utils/paneVisibility.js.map +1 -0
- package/dist/utils/processShutdown.d.ts +4 -0
- package/dist/utils/processShutdown.d.ts.map +1 -0
- package/dist/utils/processShutdown.js +27 -0
- package/dist/utils/processShutdown.js.map +1 -0
- package/dist/utils/promptStore.d.ts.map +1 -1
- package/dist/utils/promptStore.js +6 -0
- package/dist/utils/promptStore.js.map +1 -1
- package/dist/utils/reopenWorktree.d.ts.map +1 -1
- package/dist/utils/reopenWorktree.js +8 -0
- package/dist/utils/reopenWorktree.js.map +1 -1
- package/dist/utils/runtimePaths.d.ts +1 -0
- package/dist/utils/runtimePaths.d.ts.map +1 -1
- package/dist/utils/runtimePaths.js +3 -0
- package/dist/utils/runtimePaths.js.map +1 -1
- package/dist/utils/settingsManager.d.ts +3 -0
- package/dist/utils/settingsManager.d.ts.map +1 -1
- package/dist/utils/settingsManager.js +203 -11
- package/dist/utils/settingsManager.js.map +1 -1
- package/dist/utils/tmux.d.ts +5 -1
- package/dist/utils/tmux.d.ts.map +1 -1
- package/dist/utils/tmux.js +23 -5
- package/dist/utils/tmux.js.map +1 -1
- package/dist/utils/tmuxConfigOnboarding.js +1 -1
- package/dist/utils/tmuxConfigOnboarding.js.map +1 -1
- package/dist/utils/tmuxHookCommands.d.ts +14 -0
- package/dist/utils/tmuxHookCommands.d.ts.map +1 -0
- package/dist/utils/tmuxHookCommands.js +30 -0
- package/dist/utils/tmuxHookCommands.js.map +1 -0
- package/dist/utils/tmuxRuntimeCompatibility.d.ts +11 -0
- package/dist/utils/tmuxRuntimeCompatibility.d.ts.map +1 -0
- package/dist/utils/tmuxRuntimeCompatibility.js +71 -0
- package/dist/utils/tmuxRuntimeCompatibility.js.map +1 -0
- package/dist/utils/worktreeMetadata.d.ts +9 -0
- package/dist/utils/worktreeMetadata.d.ts.map +1 -0
- package/dist/utils/worktreeMetadata.js +60 -0
- package/dist/utils/worktreeMetadata.js.map +1 -0
- package/dist/workers/PaneWorker.js +64 -128
- package/dist/workers/PaneWorker.js.map +1 -1
- package/dist/workers/WorkerMessages.d.ts +4 -1
- package/dist/workers/WorkerMessages.d.ts.map +1 -1
- package/dist/workers/WorkerMessages.js.map +1 -1
- package/native/macos/dmux-helper-Info.plist +30 -0
- package/native/macos/dmux-helper-icon.png +0 -0
- package/native/macos/dmux-helper.swift +831 -0
- package/native/macos/sounds/dmux-braam.caf +0 -0
- package/native/macos/sounds/dmux-brass.caf +0 -0
- package/native/macos/sounds/dmux-ding-bell.caf +0 -0
- package/native/macos/sounds/dmux-future.caf +0 -0
- package/native/macos/sounds/dmux-harp.caf +0 -0
- package/native/macos/sounds/dmux-quiet-bells.caf +0 -0
- package/native/macos/sounds/dmux-sonar.caf +0 -0
- package/native/macos/sounds/dmux-success.caf +0 -0
- package/native/macos/sounds/dmux-triumphant-trumpet.caf +0 -0
- package/native/macos/sounds/dmux-war-horn.caf +0 -0
- 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()
|