This repository was archived by the owner on May 3, 2023. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 83
/
Copy pathdarwin.go
241 lines (222 loc) · 6.99 KB
/
darwin.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
package thyme
import (
"bytes"
"fmt"
"hash/fnv"
"log"
"os/exec"
"strconv"
"strings"
"time"
)
func init() {
RegisterTracker("darwin", NewDarwinTracker)
}
// DarwinTracker tracks application usage using the "System Events" API in AppleScript. Due to the liminations of this
// API, the DarwinTracker will not be able to detect individual windows of applications that are not scriptable (in the
// AppleScript sense). For these applications, a single window is emitted with the name set to the application process
// name and the ID set to the process ID.
type DarwinTracker struct{}
var _ Tracker = (*DarwinTracker)(nil)
func NewDarwinTracker() Tracker {
return &DarwinTracker{}
}
// allWindowsScript fetches the windows of all scriptable applications. It
// iterates through each application process known to System Events and attempts
// to script the application with the same name as the application process. If
// such an application exists and is scriptable, it prints the name of every
// window in the application. Otherwise, it just prints the name of every
// visible window in the application. If no visible windows exist, it will just
// print the application name. (System Events processes only have windows in
// the current desktop/workspace.)
const (
allWindowsScript = `
tell application "System Events"
set listOfProcesses to (every application process where background only is false)
end tell
repeat with proc in listOfProcesses
set procName to (name of proc)
set procID to (id of proc)
log "PROCESS " & procID & ":" & procName
-- Attempt to list windows if the process is scriptable
try
tell application procName
repeat with i from 1 to (count windows)
log "WINDOW " & (id of window i) & ":" & (name of window i) as string
end repeat
end tell
end try
end repeat
`
activeWindowsScript = `
tell application "System Events"
set proc to (first application process whose frontmost is true)
end tell
set procName to (name of proc)
try
tell application procName
log "WINDOW " & (id of window 1) & ":" & (name of window 1)
end tell
on error e
log "WINDOW " & (id of proc) & ":" & (name of first window of proc)
end try
`
// visibleWindowsScript generates a mapping from process to windows in the
// current desktop (note: this is slightly different than the behavior of
// the previous two scripts, where an empty windows list for a process
// should NOT imply that there is one window named after the process.
// Furthermore, the window IDs are not valid in this script (only the window
// name is valid).
visibleWindowsScript = `
tell application "System Events"
set listOfProcesses to (every process whose visible is true)
end tell
repeat with proc in listOfProcesses
set procName to (name of proc)
set procID to (id of proc)
log "PROCESS " & procID & ":" & procName
set app_windows to (every window of proc)
repeat with each_window in app_windows
log "WINDOW -1:" & (name of each_window) as string
end repeat
end repeat
`
)
func (t *DarwinTracker) Deps() string {
return `
You will need to enable privileges for "Terminal" in System Preferences > Security & Privacy > Privacy > Accessibility.
See https://support.apple.com/en-us/HT202802 for details.
Note: this command prints out this message regardless of whether this has been done or not.
`
}
func (t *DarwinTracker) Snap() (*Snapshot, error) {
var allWindows []*Window
var allProcWins map[process][]*Window
{
procWins, err := runAS(allWindowsScript)
if err != nil {
return nil, err
}
for proc, wins := range procWins {
if len(wins) == 0 {
allWindows = append(allWindows, &Window{ID: proc.id, Name: proc.name})
} else {
allWindows = append(allWindows, wins...)
}
}
allProcWins = procWins
}
var active int64
{
procWins, err := runAS(activeWindowsScript)
if err != nil {
return nil, err
}
if len(procWins) > 1 {
return nil, fmt.Errorf("found more than one active process: %+v", procWins)
}
for proc, wins := range procWins {
if len(wins) == 0 {
active = proc.id
} else if len(wins) == 1 {
active = wins[0].ID
} else {
return nil, fmt.Errorf("found more than one active window: %+v", wins)
}
}
}
var visible []int64
{
procWins, err := runAS(visibleWindowsScript)
if err != nil {
return nil, err
}
for proc, wins := range procWins {
allWins := allProcWins[proc]
for _, visWin := range wins {
if len(allWins) == 0 {
visible = append(visible, proc.id)
} else {
found := false
for _, win := range allWins {
if win.Name == visWin.Name {
visible = append(visible, win.ID)
found = true
break
}
}
if !found {
log.Printf("warning: window ID not found for visible window %q", visWin.Name)
}
}
}
}
}
return &Snapshot{
Time: time.Now(),
Windows: allWindows,
Active: active,
Visible: visible,
}, nil
}
// process is the {name, id} of a process
type process struct {
name string
id int64
}
// runAS runs script as AppleScript and parses the output into a map of
// processes to windows.
func runAS(script string) (map[process][]*Window, error) {
cmd := exec.Command("osascript")
cmd.Stdin = bytes.NewBuffer([]byte(script))
b, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("AppleScript error: %s, output was:\n%s", err, string(b))
}
return parseASOutput(string(b))
}
// parseASOutput parses the output of the AppleScript snippets used to extract window information.
func parseASOutput(out string) (map[process][]*Window, error) {
proc := process{}
procWins := make(map[process][]*Window)
for _, line := range strings.Split(out, "\n") {
if strings.HasPrefix(line, "PROCESS ") {
c := strings.Index(line, ":")
procID, err := strconv.ParseInt(line[len("PROCESS "):c], 10, 0)
if err != nil {
return nil, err
}
proc = process{line[c+1:], procID}
procWins[proc] = nil
} else if strings.HasPrefix(line, "WINDOW ") {
win, winID := parseWindowLine(line, proc.id)
procWins[proc] = append(procWins[proc],
&Window{ID: winID, Name: fmt.Sprintf("%s - %s", win, proc.name)},
)
}
}
return procWins, nil
}
// parseWindowLine parses window ID from a line of the AppleScript
// output. If the ID is missing ("missing value"), parseWindowLine
// will return the hash of the window title and process ID. Note: if 2
// windows controlled by the same process both have IDs missing and
// have the same title, they will hash to the same ID. This is
// unfortunate but seems to be the best behavior.
func parseWindowLine(line string, procId int64) (string, int64) {
c := strings.Index(line, ":")
win := line[c+1:]
winID, err := strconv.ParseInt(line[len("WINDOW "):c], 10, 0)
if err != nil {
// sometimes "missing value" appears here, so generate a value
// taking the process ID and the window index to generate a hash
winID = hash(fmt.Sprintf("%s%v", win, procId))
}
return win, winID
}
// hash converts a string to an integer hash
func hash(s string) int64 {
h := fnv.New32a()
h.Write([]byte(s))
return int64(h.Sum32())
}