Skip to content

Commit

Permalink
feat: spotify segment for windows
Browse files Browse the repository at this point in the history
  • Loading branch information
lnu authored and JanDeDobbeleer committed Nov 10, 2020
1 parent 2844eed commit 96cac5f
Show file tree
Hide file tree
Showing 14 changed files with 412 additions and 32 deletions.
1 change: 1 addition & 0 deletions .editorconfig
Expand Up @@ -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}]
Expand Down
9 changes: 6 additions & 3 deletions docs/docs/segment-spotify.md
Expand Up @@ -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

Expand All @@ -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" : " - "
}
}
```
Expand All @@ -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 ` - `
1 change: 1 addition & 0 deletions environment.go
Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions environment_unix.go
Expand Up @@ -3,6 +3,7 @@
package main

import (
"errors"
"os"
)

Expand All @@ -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")
}
6 changes: 6 additions & 0 deletions environment_windows.go
@@ -1,3 +1,5 @@
// +build windows

package main

import (
Expand Down Expand Up @@ -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)
}
174 changes: 174 additions & 0 deletions 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
}
5 changes: 5 additions & 0 deletions segment_path_test.go
Expand Up @@ -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)
Expand Down
33 changes: 6 additions & 27 deletions segment_spotify.go
Expand Up @@ -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":
Expand All @@ -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
}
27 changes: 27 additions & 0 deletions 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
}

0 comments on commit 96cac5f

Please sign in to comment.