Skip to content

Commit 96cac5f

Browse files
lnuJanDeDobbeleer
authored andcommitted
feat: spotify segment for windows
1 parent 2844eed commit 96cac5f

14 files changed

+412
-32
lines changed

.editorconfig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ indent_size = 2
2525
; Markdown - match markdownlint settings
2626
[*.{md,markdown}]
2727
indent_size = 2
28+
trim_trailing_whitespace = false
2829

2930
; PowerShell - match defaults for New-ModuleManifest and PSScriptAnalyzer Invoke-Formatter
3031
[*.{ps1,psd1,psm1}]

docs/docs/segment-spotify.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ sidebar_label: Spotify
66

77
## What
88

9-
Show the currently playing song in the Spotify MacOS client. Only available on MacOS for obvious reasons.
10-
Be aware this can make the prompt a tad bit slower as it needs to get a response from the Spotify player using Applescript.
9+
Show the currently playing song in the Spotify MacOS/Windows client.
10+
Be aware this can make the prompt a tad bit slower as it needs to get a response from the Spotify player.
1111

1212
## Sample Configuration
1313

@@ -20,8 +20,10 @@ Be aware this can make the prompt a tad bit slower as it needs to get a response
2020
"background": "#1BD760",
2121
"properties": {
2222
"prefix": "",
23+
"playing_icon": "",
2324
"paused_icon": "",
24-
"playing_icon": ""
25+
"stopped_icon": "",
26+
"track_separator" : " - "
2527
}
2628
}
2729
```
@@ -30,4 +32,5 @@ Be aware this can make the prompt a tad bit slower as it needs to get a response
3032

3133
- playing_icon: `string` - text/icon to show when playing - defaults to `\uE602 `
3234
- paused_icon: `string` - text/icon to show when paused - defaults to `\uF8E3 `
35+
- stopped_icon: `string` - text/icon to show when paused - defaults to `\uF04D `
3336
- track_separator: `string` - text/icon to put between the artist and song name - defaults to ` - `

environment.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type environmentInfo interface {
3636
getArgs() *args
3737
getBatteryInfo() (*battery.Battery, error)
3838
getShellName() string
39+
getWindowTitle(imageName string, windowTitleRegex string) (string, error)
3940
}
4041

4142
type environment struct {

environment_unix.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package main
44

55
import (
6+
"errors"
67
"os"
78
)
89

@@ -13,3 +14,7 @@ func (env *environment) isRunningAsRoot() bool {
1314
func (env *environment) homeDir() string {
1415
return os.Getenv("HOME")
1516
}
17+
18+
func (env *environment) getWindowTitle(imageName string, windowTitleRegex string) (string, error) {
19+
return "", errors.New("not implemented")
20+
}

environment_windows.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
// +build windows
2+
13
package main
24

35
import (
@@ -45,3 +47,7 @@ func (env *environment) homeDir() string {
4547
}
4648
return home
4749
}
50+
51+
func (env *environment) getWindowTitle(imageName string, windowTitleRegex string) (string, error) {
52+
return getWindowTitle(imageName, windowTitleRegex)
53+
}

environment_windows_win32.go

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// +build windows
2+
3+
package main
4+
5+
import (
6+
"fmt"
7+
"regexp"
8+
"strings"
9+
"syscall"
10+
"unsafe"
11+
12+
"golang.org/x/sys/windows"
13+
)
14+
15+
// WindowsProcess is an implementation of Process for Windows.
16+
type WindowsProcess struct {
17+
pid int
18+
ppid int
19+
exe string
20+
}
21+
22+
// getImagePid returns the
23+
func getImagePid(imageName string) ([]int, error) {
24+
processes, err := processes()
25+
if err != nil {
26+
return nil, err
27+
}
28+
var pids []int
29+
for i := 0; i < len(processes); i++ {
30+
if strings.ToLower(processes[i].exe) == imageName {
31+
pids = append(pids, processes[i].pid)
32+
}
33+
}
34+
return pids, nil
35+
}
36+
37+
// getWindowTitle returns the title of a window linked to a process name
38+
func getWindowTitle(imageName string, windowTitleRegex string) (string, error) {
39+
processPid, err := getImagePid(imageName)
40+
if err != nil {
41+
return "", nil
42+
}
43+
// returns the first window of the first pid
44+
_, windowTitle, err := GetWindowTitle(processPid[0], windowTitleRegex)
45+
if err != nil {
46+
return "", nil
47+
}
48+
return windowTitle, nil
49+
}
50+
51+
func newWindowsProcess(e *windows.ProcessEntry32) *WindowsProcess {
52+
// Find when the string ends for decoding
53+
end := 0
54+
for {
55+
if e.ExeFile[end] == 0 {
56+
break
57+
}
58+
end++
59+
}
60+
61+
return &WindowsProcess{
62+
pid: int(e.ProcessID),
63+
ppid: int(e.ParentProcessID),
64+
exe: syscall.UTF16ToString(e.ExeFile[:end]),
65+
}
66+
}
67+
68+
// Processes returns a snapshot of all the processes
69+
// Taken and adapted from https://github.com/mitchellh/go-ps
70+
func processes() ([]WindowsProcess, error) {
71+
// get process table snapshot
72+
handle, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
73+
if err != nil {
74+
return nil, syscall.GetLastError()
75+
}
76+
defer windows.CloseHandle(handle)
77+
78+
// get process infor by looping through the snapshot
79+
var entry windows.ProcessEntry32
80+
entry.Size = uint32(unsafe.Sizeof(entry))
81+
err = windows.Process32First(handle, &entry)
82+
if err != nil {
83+
return nil, fmt.Errorf("error retrieving process info")
84+
}
85+
86+
results := make([]WindowsProcess, 0, 50)
87+
for {
88+
results = append(results, *newWindowsProcess(&entry))
89+
err := windows.Process32Next(handle, &entry)
90+
if err != nil {
91+
if err == syscall.ERROR_NO_MORE_FILES {
92+
break
93+
}
94+
return nil, fmt.Errorf("Fail to syscall Process32Next: %v", err)
95+
}
96+
}
97+
98+
return results, nil
99+
}
100+
101+
// win32 specific code
102+
103+
// win32 dll load and function definitions
104+
var (
105+
user32 = syscall.NewLazyDLL("user32.dll")
106+
procEnumWindows = user32.NewProc("EnumWindows")
107+
procGetWindowTextW = user32.NewProc("GetWindowTextW")
108+
procGetWindowThreadProcessID = user32.NewProc("GetWindowThreadProcessId")
109+
)
110+
111+
// EnumWindows call EnumWindows from user32 and returns all active windows
112+
func EnumWindows(enumFunc uintptr, lparam uintptr) (err error) {
113+
r1, _, e1 := syscall.Syscall(procEnumWindows.Addr(), 2, uintptr(enumFunc), uintptr(lparam), 0)
114+
if r1 == 0 {
115+
if e1 != 0 {
116+
err = error(e1)
117+
} else {
118+
err = syscall.EINVAL
119+
}
120+
}
121+
return
122+
}
123+
124+
// GetWindowText returns the title and text of a window from a window handle
125+
func GetWindowText(hwnd syscall.Handle, str *uint16, maxCount int32) (len int32, err error) {
126+
r0, _, e1 := syscall.Syscall(procGetWindowTextW.Addr(), 3, uintptr(hwnd), uintptr(unsafe.Pointer(str)), uintptr(maxCount))
127+
len = int32(r0)
128+
if len == 0 {
129+
if e1 != 0 {
130+
err = error(e1)
131+
} else {
132+
err = syscall.EINVAL
133+
}
134+
}
135+
return
136+
}
137+
138+
// GetWindowTitle searchs for a window attached to the pid
139+
func GetWindowTitle(pid int, windowTitleRegex string) (syscall.Handle, string, error) {
140+
var hwnd syscall.Handle
141+
var title string
142+
compiledRegex, err := regexp.Compile(windowTitleRegex)
143+
if err != nil {
144+
return 0, "", fmt.Errorf("Error while compiling the regex '%s'", windowTitleRegex)
145+
}
146+
// callback fro EnumWindows
147+
cb := syscall.NewCallback(func(h syscall.Handle, p uintptr) uintptr {
148+
var prcsID int = 0
149+
// get pid
150+
_, _, _ = procGetWindowThreadProcessID.Call(uintptr(h), uintptr(unsafe.Pointer(&prcsID)))
151+
// check if pid matches spotify pid
152+
if prcsID == pid {
153+
b := make([]uint16, 200)
154+
_, err := GetWindowText(h, &b[0], int32(len(b)))
155+
if err != nil {
156+
// ignore the error
157+
return 1 // continue enumeration
158+
}
159+
title = syscall.UTF16ToString(b)
160+
if compiledRegex.MatchString(title) {
161+
hwnd = h
162+
return 0
163+
}
164+
}
165+
166+
return 1 // continue enumeration
167+
})
168+
// Enumerates all top-level windows on the screen
169+
EnumWindows(cb, 0)
170+
if hwnd == 0 {
171+
return 0, "", fmt.Errorf("No window with title '%b' found", pid)
172+
}
173+
return hwnd, title, nil
174+
}

segment_path_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ func (env *MockedEnvironment) getShellName() string {
108108
return args.String(0)
109109
}
110110

111+
func (env *MockedEnvironment) getWindowTitle(imageName string, windowTitleRegex string) (string, error) {
112+
args := env.Called(imageName)
113+
return args.String(0), args.Error(1)
114+
}
115+
111116
func TestIsInHomeDirTrue(t *testing.T) {
112117
home := "/home/bill"
113118
env := new(MockedEnvironment)

segment_spotify.go

Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,35 +17,19 @@ const (
1717
PlayingIcon Property = "playing_icon"
1818
//PausedIcon indicates a song is paused
1919
PausedIcon Property = "paused_icon"
20+
//StoppedIcon indicates a song is stopped
21+
StoppedIcon Property = "stopped_icon"
2022
//TrackSeparator is put between the artist and the track
2123
TrackSeparator Property = "track_separator"
2224
)
2325

24-
func (s *spotify) enabled() bool {
25-
if s.env.getRuntimeGOOS() != "darwin" {
26-
return false
27-
}
28-
var err error
29-
// Check if running
30-
running := s.runAppleScriptCommand("application \"Spotify\" is running")
31-
if running == "false" || running == "" {
32-
return false
33-
}
34-
s.status = s.runAppleScriptCommand("tell application \"Spotify\" to player state as string")
35-
if err != nil {
36-
return false
37-
}
38-
if s.status == "stopped" {
39-
return false
40-
}
41-
s.artist = s.runAppleScriptCommand("tell application \"Spotify\" to artist of current track as string")
42-
s.track = s.runAppleScriptCommand("tell application \"Spotify\" to name of current track as string")
43-
return true
44-
}
45-
4626
func (s *spotify) string() string {
4727
icon := ""
4828
switch s.status {
29+
case "stopped":
30+
// in this case, no artist or track info
31+
icon = s.props.getString(StoppedIcon, "\uF04D ")
32+
return icon
4933
case "paused":
5034
icon = s.props.getString(PausedIcon, "\uF8E3 ")
5135
case "playing":
@@ -59,8 +43,3 @@ func (s *spotify) init(props *properties, env environmentInfo) {
5943
s.props = props
6044
s.env = env
6145
}
62-
63-
func (s *spotify) runAppleScriptCommand(command string) string {
64-
val, _ := s.env.runCommand("osascript", "-e", command)
65-
return val
66-
}

segment_spotify_darwin.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// +build darwin
2+
3+
package main
4+
5+
func (s *spotify) enabled() bool {
6+
var err error
7+
// Check if running
8+
running := s.runAppleScriptCommand("application \"Spotify\" is running")
9+
if running == "false" || running == "" {
10+
return false
11+
}
12+
s.status = s.runAppleScriptCommand("tell application \"Spotify\" to player state as string")
13+
if err != nil {
14+
return false
15+
}
16+
if s.status == "stopped" {
17+
return false
18+
}
19+
s.artist = s.runAppleScriptCommand("tell application \"Spotify\" to artist of current track as string")
20+
s.track = s.runAppleScriptCommand("tell application \"Spotify\" to name of current track as string")
21+
return true
22+
}
23+
24+
func (s *spotify) runAppleScriptCommand(command string) string {
25+
val, _ := s.env.runCommand("osascript", "-e", command)
26+
return val
27+
}

0 commit comments

Comments
 (0)