From 96cac5f6891fb884ae0acc51ce2fd8140b71dd18 Mon Sep 17 00:00:00 2001 From: lnu Date: Thu, 5 Nov 2020 08:56:12 +0100 Subject: [PATCH] feat: spotify segment for windows --- .editorconfig | 1 + docs/docs/segment-spotify.md | 9 +- environment.go | 1 + environment_unix.go | 5 + environment_windows.go | 6 ++ environment_windows_win32.go | 174 ++++++++++++++++++++++++++++++++ segment_path_test.go | 5 + segment_spotify.go | 33 ++---- segment_spotify_darwin.go | 27 +++++ segment_spotify_darwin_test.go | 63 ++++++++++++ segment_spotify_others.go | 8 ++ segment_spotify_test.go | 32 +++++- segment_spotify_windows.go | 28 +++++ segment_spotify_windows_test.go | 52 ++++++++++ 14 files changed, 412 insertions(+), 32 deletions(-) create mode 100644 environment_windows_win32.go create mode 100644 segment_spotify_darwin.go create mode 100644 segment_spotify_darwin_test.go create mode 100644 segment_spotify_others.go create mode 100644 segment_spotify_windows.go create mode 100644 segment_spotify_windows_test.go diff --git a/.editorconfig b/.editorconfig index 86fecc82e9ca..65538e708e7b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -25,6 +25,7 @@ indent_size = 2 ; Markdown - match markdownlint settings [*.{md,markdown}] indent_size = 2 +trim_trailing_whitespace = false ; PowerShell - match defaults for New-ModuleManifest and PSScriptAnalyzer Invoke-Formatter [*.{ps1,psd1,psm1}] diff --git a/docs/docs/segment-spotify.md b/docs/docs/segment-spotify.md index dea64c7ba445..3f2712bfe8e0 100644 --- a/docs/docs/segment-spotify.md +++ b/docs/docs/segment-spotify.md @@ -6,8 +6,8 @@ sidebar_label: Spotify ## What -Show the currently playing song in the Spotify MacOS client. Only available on MacOS for obvious reasons. -Be aware this can make the prompt a tad bit slower as it needs to get a response from the Spotify player using Applescript. +Show the currently playing song in the Spotify MacOS/Windows client. +Be aware this can make the prompt a tad bit slower as it needs to get a response from the Spotify player. ## Sample Configuration @@ -20,8 +20,10 @@ Be aware this can make the prompt a tad bit slower as it needs to get a response "background": "#1BD760", "properties": { "prefix": "  ", + "playing_icon": " ", "paused_icon": " ", - "playing_icon": " " + "stopped_icon": " ", + "track_separator" : " - " } } ``` @@ -30,4 +32,5 @@ Be aware this can make the prompt a tad bit slower as it needs to get a response - playing_icon: `string` - text/icon to show when playing - defaults to `\uE602 ` - paused_icon: `string` - text/icon to show when paused - defaults to `\uF8E3 ` +- stopped_icon: `string` - text/icon to show when paused - defaults to `\uF04D ` - track_separator: `string` - text/icon to put between the artist and song name - defaults to ` - ` diff --git a/environment.go b/environment.go index 0489bd18b920..b65ce05de0cf 100644 --- a/environment.go +++ b/environment.go @@ -36,6 +36,7 @@ type environmentInfo interface { getArgs() *args getBatteryInfo() (*battery.Battery, error) getShellName() string + getWindowTitle(imageName string, windowTitleRegex string) (string, error) } type environment struct { diff --git a/environment_unix.go b/environment_unix.go index c995f3bc3381..3590ccd27d21 100755 --- a/environment_unix.go +++ b/environment_unix.go @@ -3,6 +3,7 @@ package main import ( + "errors" "os" ) @@ -13,3 +14,7 @@ func (env *environment) isRunningAsRoot() bool { func (env *environment) homeDir() string { return os.Getenv("HOME") } + +func (env *environment) getWindowTitle(imageName string, windowTitleRegex string) (string, error) { + return "", errors.New("not implemented") +} diff --git a/environment_windows.go b/environment_windows.go index b9eaa3e82366..c7ae0f117a0f 100755 --- a/environment_windows.go +++ b/environment_windows.go @@ -1,3 +1,5 @@ +// +build windows + package main import ( @@ -45,3 +47,7 @@ func (env *environment) homeDir() string { } return home } + +func (env *environment) getWindowTitle(imageName string, windowTitleRegex string) (string, error) { + return getWindowTitle(imageName, windowTitleRegex) +} diff --git a/environment_windows_win32.go b/environment_windows_win32.go new file mode 100644 index 000000000000..346a0cad1e9a --- /dev/null +++ b/environment_windows_win32.go @@ -0,0 +1,174 @@ +// +build windows + +package main + +import ( + "fmt" + "regexp" + "strings" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +// WindowsProcess is an implementation of Process for Windows. +type WindowsProcess struct { + pid int + ppid int + exe string +} + +// getImagePid returns the +func getImagePid(imageName string) ([]int, error) { + processes, err := processes() + if err != nil { + return nil, err + } + var pids []int + for i := 0; i < len(processes); i++ { + if strings.ToLower(processes[i].exe) == imageName { + pids = append(pids, processes[i].pid) + } + } + return pids, nil +} + +// getWindowTitle returns the title of a window linked to a process name +func getWindowTitle(imageName string, windowTitleRegex string) (string, error) { + processPid, err := getImagePid(imageName) + if err != nil { + return "", nil + } + // returns the first window of the first pid + _, windowTitle, err := GetWindowTitle(processPid[0], windowTitleRegex) + if err != nil { + return "", nil + } + return windowTitle, nil +} + +func newWindowsProcess(e *windows.ProcessEntry32) *WindowsProcess { + // Find when the string ends for decoding + end := 0 + for { + if e.ExeFile[end] == 0 { + break + } + end++ + } + + return &WindowsProcess{ + pid: int(e.ProcessID), + ppid: int(e.ParentProcessID), + exe: syscall.UTF16ToString(e.ExeFile[:end]), + } +} + +// Processes returns a snapshot of all the processes +// Taken and adapted from https://github.com/mitchellh/go-ps +func processes() ([]WindowsProcess, error) { + // get process table snapshot + handle, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return nil, syscall.GetLastError() + } + defer windows.CloseHandle(handle) + + // get process infor by looping through the snapshot + var entry windows.ProcessEntry32 + entry.Size = uint32(unsafe.Sizeof(entry)) + err = windows.Process32First(handle, &entry) + if err != nil { + return nil, fmt.Errorf("error retrieving process info") + } + + results := make([]WindowsProcess, 0, 50) + for { + results = append(results, *newWindowsProcess(&entry)) + err := windows.Process32Next(handle, &entry) + if err != nil { + if err == syscall.ERROR_NO_MORE_FILES { + break + } + return nil, fmt.Errorf("Fail to syscall Process32Next: %v", err) + } + } + + return results, nil +} + +// win32 specific code + +// win32 dll load and function definitions +var ( + user32 = syscall.NewLazyDLL("user32.dll") + procEnumWindows = user32.NewProc("EnumWindows") + procGetWindowTextW = user32.NewProc("GetWindowTextW") + procGetWindowThreadProcessID = user32.NewProc("GetWindowThreadProcessId") +) + +// EnumWindows call EnumWindows from user32 and returns all active windows +func EnumWindows(enumFunc uintptr, lparam uintptr) (err error) { + r1, _, e1 := syscall.Syscall(procEnumWindows.Addr(), 2, uintptr(enumFunc), uintptr(lparam), 0) + if r1 == 0 { + if e1 != 0 { + err = error(e1) + } else { + err = syscall.EINVAL + } + } + return +} + +// GetWindowText returns the title and text of a window from a window handle +func GetWindowText(hwnd syscall.Handle, str *uint16, maxCount int32) (len int32, err error) { + r0, _, e1 := syscall.Syscall(procGetWindowTextW.Addr(), 3, uintptr(hwnd), uintptr(unsafe.Pointer(str)), uintptr(maxCount)) + len = int32(r0) + if len == 0 { + if e1 != 0 { + err = error(e1) + } else { + err = syscall.EINVAL + } + } + return +} + +// GetWindowTitle searchs for a window attached to the pid +func GetWindowTitle(pid int, windowTitleRegex string) (syscall.Handle, string, error) { + var hwnd syscall.Handle + var title string + compiledRegex, err := regexp.Compile(windowTitleRegex) + if err != nil { + return 0, "", fmt.Errorf("Error while compiling the regex '%s'", windowTitleRegex) + } + // callback fro EnumWindows + cb := syscall.NewCallback(func(h syscall.Handle, p uintptr) uintptr { + var prcsID int = 0 + // get pid + _, _, _ = procGetWindowThreadProcessID.Call(uintptr(h), uintptr(unsafe.Pointer(&prcsID))) + // check if pid matches spotify pid + if prcsID == pid { + b := make([]uint16, 200) + _, err := GetWindowText(h, &b[0], int32(len(b))) + if err != nil { + // ignore the error + return 1 // continue enumeration + } + title = syscall.UTF16ToString(b) + if compiledRegex.MatchString(title) { + hwnd = h + return 0 + } + } + + return 1 // continue enumeration + }) + // Enumerates all top-level windows on the screen + EnumWindows(cb, 0) + if hwnd == 0 { + return 0, "", fmt.Errorf("No window with title '%b' found", pid) + } + return hwnd, title, nil +} diff --git a/segment_path_test.go b/segment_path_test.go index 5af68b6b74f8..ff2ece5ebde3 100755 --- a/segment_path_test.go +++ b/segment_path_test.go @@ -108,6 +108,11 @@ func (env *MockedEnvironment) getShellName() string { return args.String(0) } +func (env *MockedEnvironment) getWindowTitle(imageName string, windowTitleRegex string) (string, error) { + args := env.Called(imageName) + return args.String(0), args.Error(1) +} + func TestIsInHomeDirTrue(t *testing.T) { home := "/home/bill" env := new(MockedEnvironment) diff --git a/segment_spotify.go b/segment_spotify.go index 3c9aa495d59f..109eab1db92e 100644 --- a/segment_spotify.go +++ b/segment_spotify.go @@ -17,35 +17,19 @@ const ( PlayingIcon Property = "playing_icon" //PausedIcon indicates a song is paused PausedIcon Property = "paused_icon" + //StoppedIcon indicates a song is stopped + StoppedIcon Property = "stopped_icon" //TrackSeparator is put between the artist and the track TrackSeparator Property = "track_separator" ) -func (s *spotify) enabled() bool { - if s.env.getRuntimeGOOS() != "darwin" { - return false - } - var err error - // Check if running - running := s.runAppleScriptCommand("application \"Spotify\" is running") - if running == "false" || running == "" { - return false - } - s.status = s.runAppleScriptCommand("tell application \"Spotify\" to player state as string") - if err != nil { - return false - } - if s.status == "stopped" { - return false - } - s.artist = s.runAppleScriptCommand("tell application \"Spotify\" to artist of current track as string") - s.track = s.runAppleScriptCommand("tell application \"Spotify\" to name of current track as string") - return true -} - func (s *spotify) string() string { icon := "" switch s.status { + case "stopped": + // in this case, no artist or track info + icon = s.props.getString(StoppedIcon, "\uF04D ") + return icon case "paused": icon = s.props.getString(PausedIcon, "\uF8E3 ") case "playing": @@ -59,8 +43,3 @@ func (s *spotify) init(props *properties, env environmentInfo) { s.props = props s.env = env } - -func (s *spotify) runAppleScriptCommand(command string) string { - val, _ := s.env.runCommand("osascript", "-e", command) - return val -} diff --git a/segment_spotify_darwin.go b/segment_spotify_darwin.go new file mode 100644 index 000000000000..ea4f6ac7b28e --- /dev/null +++ b/segment_spotify_darwin.go @@ -0,0 +1,27 @@ +// +build darwin + +package main + +func (s *spotify) enabled() bool { + var err error + // Check if running + running := s.runAppleScriptCommand("application \"Spotify\" is running") + if running == "false" || running == "" { + return false + } + s.status = s.runAppleScriptCommand("tell application \"Spotify\" to player state as string") + if err != nil { + return false + } + if s.status == "stopped" { + return false + } + s.artist = s.runAppleScriptCommand("tell application \"Spotify\" to artist of current track as string") + s.track = s.runAppleScriptCommand("tell application \"Spotify\" to name of current track as string") + return true +} + +func (s *spotify) runAppleScriptCommand(command string) string { + val, _ := s.env.runCommand("osascript", "-e", command) + return val +} diff --git a/segment_spotify_darwin_test.go b/segment_spotify_darwin_test.go new file mode 100644 index 000000000000..094dfce88591 --- /dev/null +++ b/segment_spotify_darwin_test.go @@ -0,0 +1,63 @@ +// +build darwin + +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +type spotifyArgs struct { + spotifyDarwinTitle string + spotifyDarwinRunning string + spotifyDarwinStatus string + spotifyDarwinArtist string + spotifyDarwinTrack string +} + +func bootStrapSpotifyDarwinTest(args *spotifyArgs) *spotify { + env := new(MockedEnvironment) + env.On("runCommand", "osascript", []string{"-e", "application \"Spotify\" is running"}).Return(args.spotifyDarwinRunning, nil) + env.On("runCommand", "osascript", []string{"-e", "tell application \"Spotify\" to player state as string"}).Return(args.spotifyDarwinStatus, nil) + env.On("runCommand", "osascript", []string{"-e", "tell application \"Spotify\" to artist of current track as string"}).Return(args.spotifyDarwinArtist, nil) + env.On("runCommand", "osascript", []string{"-e", "tell application \"Spotify\" to name of current track as string"}).Return(args.spotifyDarwinTrack, nil) + props := &properties{} + s := &spotify{ + env: env, + props: props, + } + return s +} + +func TestSpotifyDarwinEnabledAndSpotifyNotRunning(t *testing.T) { + args := &spotifyArgs{ + spotifyDarwinRunning: "false", + } + s := bootStrapSpotifyDarwinTest(args) + assert.Equal(t, false, s.enabled()) +} + +func TestSpotifyDarwinEnabledAndSpotifyPlaying(t *testing.T) { + args := &spotifyArgs{ + spotifyDarwinRunning: "true", + spotifyDarwinStatus: "playing", + spotifyDarwinArtist: "Candlemass", + spotifyDarwinTrack: "Spellbreaker", + } + s := bootStrapSpotifyDarwinTest(args) + assert.Equal(t, true, s.enabled()) + assert.Equal(t, "\ue602 Candlemass - Spellbreaker", s.string()) +} + +func TestSpotifyDarwinEnabledAndSpotifyPaused(t *testing.T) { + args := &spotifyArgs{ + spotifyDarwinRunning: "true", + spotifyDarwinStatus: "paused", + spotifyDarwinArtist: "Candlemass", + spotifyDarwinTrack: "Spellbreaker", + } + s := bootStrapSpotifyDarwinTest(args) + assert.Equal(t, true, s.enabled()) + assert.Equal(t, "\uF8E3 Candlemass - Spellbreaker", s.string()) +} diff --git a/segment_spotify_others.go b/segment_spotify_others.go new file mode 100644 index 000000000000..7e2c3c032afa --- /dev/null +++ b/segment_spotify_others.go @@ -0,0 +1,8 @@ +// +build !darwin +// +build !windows + +package main + +func (s *spotify) enabled() bool { + return false +} diff --git a/segment_spotify_test.go b/segment_spotify_test.go index 1574cc34c358..86df72153582 100755 --- a/segment_spotify_test.go +++ b/segment_spotify_test.go @@ -1,3 +1,5 @@ +// +build windows + package main import ( @@ -6,6 +8,32 @@ import ( "github.com/stretchr/testify/assert" ) -func TestSpotifyEnabled(t *testing.T) { - assert.True(t, true) +func TestSpotifyStringPlayingSong(t *testing.T) { + expected := "\ue602 Candlemass - Spellbreaker" + s := &spotify{ + artist: "Candlemass", + track: "Spellbreaker", + status: "playing", + } + assert.Equal(t, expected, s.string()) +} + +func TestSpotifyStringPausedSong(t *testing.T) { + expected := "\uF8E3 Candlemass - Spellbreaker" + s := &spotify{ + artist: "Candlemass", + track: "Spellbreaker", + status: "paused", + } + assert.Equal(t, expected, s.string()) +} + +func TestSpotifyStringStoppedSong(t *testing.T) { + expected := "\uf04d " + s := &spotify{ + artist: "Candlemass", + track: "Spellbreaker", + status: "stopped", + } + assert.Equal(t, expected, s.string()) } diff --git a/segment_spotify_windows.go b/segment_spotify_windows.go new file mode 100644 index 000000000000..aa1a144e05df --- /dev/null +++ b/segment_spotify_windows.go @@ -0,0 +1,28 @@ +// +build windows + +package main + +import ( + "strings" +) + +func (s *spotify) enabled() bool { + // search for spotify window to retrieve the title + // Can be either "Spotify xxx" or the song name "Candlemass - Spellbreaker" + spotifyWindowTitle, err := s.env.getWindowTitle("spotify.exe", "^(Spotify.*)|(.*\\s-\\s.*)$") + if err != nil { + return false + } + + if !strings.Contains(spotifyWindowTitle, " - ") { + s.status = "stopped" + return true + } + + infos := strings.Split(spotifyWindowTitle, " - ") + s.artist = infos[0] + // remove first element and concat others(a song can contains also a " - ") + s.track = strings.Join(infos[1:], " - ") + s.status = "playing" + return true +} diff --git a/segment_spotify_windows_test.go b/segment_spotify_windows_test.go new file mode 100644 index 000000000000..fef9f0b51f44 --- /dev/null +++ b/segment_spotify_windows_test.go @@ -0,0 +1,52 @@ +// +build windows + +package main + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +type spotifyArgs struct { + spotifyWindowsTitle string + spotifyNotRunningError error +} + +func bootStrapSpotifyWindowsTest(args *spotifyArgs) *spotify { + env := new(MockedEnvironment) + env.On("getWindowTitle", "spotify.exe").Return(args.spotifyWindowsTitle, args.spotifyNotRunningError) + props := &properties{} + s := &spotify{ + env: env, + props: props, + } + return s +} + +func TestSpotifyWindowsEnabledAndSpotifyNotRunning(t *testing.T) { + args := &spotifyArgs{ + spotifyNotRunningError: errors.New(""), + } + s := bootStrapSpotifyWindowsTest(args) + assert.Equal(t, false, s.enabled()) +} + +func TestSpotifyWindowsEnabledAndSpotifyPlaying(t *testing.T) { + args := &spotifyArgs{ + spotifyWindowsTitle: "Candlemass - Spellbreaker", + } + s := bootStrapSpotifyWindowsTest(args) + assert.Equal(t, true, s.enabled()) + assert.Equal(t, "\ue602 Candlemass - Spellbreaker", s.string()) +} + +func TestSpotifyWindowsEnabledAndSpotifyStopped(t *testing.T) { + args := &spotifyArgs{ + spotifyWindowsTitle: "Spotify premium", + } + s := bootStrapSpotifyWindowsTest(args) + assert.Equal(t, true, s.enabled()) + assert.Equal(t, "\uf04d ", s.string()) +}