feat: spotify segment for windows

This commit is contained in:
lnu 2020-11-05 08:56:12 +01:00 committed by Jan De Dobbeleer
parent 2844eed0f5
commit 96cac5f689
14 changed files with 412 additions and 32 deletions

View file

@ -25,6 +25,7 @@ indent_size = 2
; Markdown - match markdownlint settings ; Markdown - match markdownlint settings
[*.{md,markdown}] [*.{md,markdown}]
indent_size = 2 indent_size = 2
trim_trailing_whitespace = false
; PowerShell - match defaults for New-ModuleManifest and PSScriptAnalyzer Invoke-Formatter ; PowerShell - match defaults for New-ModuleManifest and PSScriptAnalyzer Invoke-Formatter
[*.{ps1,psd1,psm1}] [*.{ps1,psd1,psm1}]

View file

@ -6,8 +6,8 @@ sidebar_label: Spotify
## What ## What
Show the currently playing song in the Spotify MacOS client. Only available on MacOS for obvious reasons. 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 using Applescript. Be aware this can make the prompt a tad bit slower as it needs to get a response from the Spotify player.
## Sample Configuration ## 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", "background": "#1BD760",
"properties": { "properties": {
"prefix": "  ", "prefix": "  ",
"playing_icon": " ",
"paused_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 ` - playing_icon: `string` - text/icon to show when playing - defaults to `\uE602 `
- paused_icon: `string` - text/icon to show when paused - defaults to `\uF8E3 ` - 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 ` - ` - track_separator: `string` - text/icon to put between the artist and song name - defaults to ` - `

View file

@ -36,6 +36,7 @@ type environmentInfo interface {
getArgs() *args getArgs() *args
getBatteryInfo() (*battery.Battery, error) getBatteryInfo() (*battery.Battery, error)
getShellName() string getShellName() string
getWindowTitle(imageName string, windowTitleRegex string) (string, error)
} }
type environment struct { type environment struct {

View file

@ -3,6 +3,7 @@
package main package main
import ( import (
"errors"
"os" "os"
) )
@ -13,3 +14,7 @@ func (env *environment) isRunningAsRoot() bool {
func (env *environment) homeDir() string { func (env *environment) homeDir() string {
return os.Getenv("HOME") return os.Getenv("HOME")
} }
func (env *environment) getWindowTitle(imageName string, windowTitleRegex string) (string, error) {
return "", errors.New("not implemented")
}

View file

@ -1,3 +1,5 @@
// +build windows
package main package main
import ( import (
@ -45,3 +47,7 @@ func (env *environment) homeDir() string {
} }
return home return home
} }
func (env *environment) getWindowTitle(imageName string, windowTitleRegex string) (string, error) {
return getWindowTitle(imageName, windowTitleRegex)
}

View file

@ -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
}

View file

@ -108,6 +108,11 @@ func (env *MockedEnvironment) getShellName() string {
return args.String(0) 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) { func TestIsInHomeDirTrue(t *testing.T) {
home := "/home/bill" home := "/home/bill"
env := new(MockedEnvironment) env := new(MockedEnvironment)

View file

@ -17,35 +17,19 @@ const (
PlayingIcon Property = "playing_icon" PlayingIcon Property = "playing_icon"
//PausedIcon indicates a song is paused //PausedIcon indicates a song is paused
PausedIcon Property = "paused_icon" PausedIcon Property = "paused_icon"
//StoppedIcon indicates a song is stopped
StoppedIcon Property = "stopped_icon"
//TrackSeparator is put between the artist and the track //TrackSeparator is put between the artist and the track
TrackSeparator Property = "track_separator" 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 { func (s *spotify) string() string {
icon := "" icon := ""
switch s.status { switch s.status {
case "stopped":
// in this case, no artist or track info
icon = s.props.getString(StoppedIcon, "\uF04D ")
return icon
case "paused": case "paused":
icon = s.props.getString(PausedIcon, "\uF8E3 ") icon = s.props.getString(PausedIcon, "\uF8E3 ")
case "playing": case "playing":
@ -59,8 +43,3 @@ func (s *spotify) init(props *properties, env environmentInfo) {
s.props = props s.props = props
s.env = env s.env = env
} }
func (s *spotify) runAppleScriptCommand(command string) string {
val, _ := s.env.runCommand("osascript", "-e", command)
return val
}

27
segment_spotify_darwin.go Normal file
View file

@ -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
}

View file

@ -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())
}

View file

@ -0,0 +1,8 @@
// +build !darwin
// +build !windows
package main
func (s *spotify) enabled() bool {
return false
}

View file

@ -1,3 +1,5 @@
// +build windows
package main package main
import ( import (
@ -6,6 +8,32 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestSpotifyEnabled(t *testing.T) { func TestSpotifyStringPlayingSong(t *testing.T) {
assert.True(t, true) 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())
} }

View file

@ -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
}

View file

@ -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())
}