diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 522ff49..678f713 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,7 +5,7 @@ jobs: build: strategy: matrix: - go-version: [~1.17, ^1] + go-version: [~1.23, ^1] os: [ubuntu-latest] runs-on: ${{ matrix.os }} env: diff --git a/.gitignore b/.gitignore index 0a568d2..8db284c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ dist/ # direnv .envrc + +# GoLand Project Files +.idea/ +*.iml diff --git a/config.go b/config.go index e21add0..ca54e00 100644 --- a/config.go +++ b/config.go @@ -5,14 +5,14 @@ import ( "errors" "fmt" "image/color" - "io/ioutil" + "os" "path/filepath" "reflect" "strconv" "strings" "github.com/BurntSushi/toml" - colorful "github.com/lucasb-eyer/go-colorful" + "github.com/lucasb-eyer/go-colorful" ) // DBusConfig describes a dbus action. @@ -51,11 +51,18 @@ type KeyConfig struct { // Keys is a slice of keys. type Keys []KeyConfig +type WindowConfig struct { + Resource string `toml:"resource,omitempty"` + Title string `toml:"title,omitempty"` + Keys Keys `toml:"keys"` +} + // DeckConfig is the central configuration struct. type DeckConfig struct { - Background string `toml:"background,omitempty"` - Parent string `toml:"parent,omitempty"` - Keys Keys `toml:"keys"` + Background string `toml:"background,omitempty"` + Parent string `toml:"parent,omitempty"` + Windows []WindowConfig `toml:"window,omitempty"` + Keys Keys `toml:"keys"` } // MergeDeckConfig merges key configuration from multiple configs. @@ -77,7 +84,9 @@ func MergeDeckConfig(base, parent *DeckConfig) DeckConfig { if background == "" { background = parent.Background } - return DeckConfig{background, base.Parent, keys} + + windows := append(base.Windows, parent.Windows...) + return DeckConfig{background, base.Parent, windows, keys} } // LoadConfigFromFile loads a DeckConfig from a file while checking for circular @@ -98,12 +107,14 @@ func LoadConfigFromFile(base, path string, files []string) (DeckConfig, error) { } } - file, err := ioutil.ReadFile(filename) + file, err := os.ReadFile(filename) if err != nil { return config, err } - _, err = toml.Decode(string(file), &config) + if _, err = toml.Decode(string(file), &config); err != nil { + return config, err + } if config.Parent != "" { parent, err := LoadConfigFromFile(base, config.Parent, append(files, filename)) if err != nil { @@ -133,7 +144,7 @@ func (c DeckConfig) Save(filename string) error { return err } - return ioutil.WriteFile(filename, b.Bytes(), 0600) + return os.WriteFile(filename, b.Bytes(), 0600) } // ConfigValue tries to convert an interface{} to the desired type. diff --git a/dbus.go b/dbus.go new file mode 100644 index 0000000..fe58190 --- /dev/null +++ b/dbus.go @@ -0,0 +1,59 @@ +package main + +import ( + "github.com/godbus/dbus/v5" + "github.com/godbus/dbus/v5/introspect" +) + +const ( + dbusInterface = "io.github.muesli.DeckMaster" + dbusMonitorPath = "/Monitor" + introInterface = "org.freedesktop.DBus.Introspectable" + intro = ` + + + + + + ` + introspect.IntrospectDataString + "" +) + +type ActiveWindow struct { + resource string + title string + id string +} + +type WindowChannel struct { + channel chan ActiveWindow +} + +func (w *WindowChannel) ActiveWindowChanged(class, title, id string) *dbus.Error { + w.channel <- ActiveWindow{class, title, id} + return nil +} + +func MonitorActiveWindowChanged() <-chan ActiveWindow { + w := WindowChannel{make(chan ActiveWindow)} + cnn, _ := dbus.SessionBus() + _ = cnn.Export(&w, dbusMonitorPath, dbusInterface) + + introspectable := introspect.Introspectable(intro) + _ = cnn.Export(introspectable, dbusMonitorPath, introInterface) + + reply, e := cnn.RequestName(dbusInterface, dbus.NameFlagDoNotQueue) + if e != nil { + errorLog(e, "failed to request name on active window changed") + } + if reply != dbus.RequestNameReplyPrimaryOwner { + errorLogF("service '%s' already running", dbusInterface) + } + return w.channel +} + +func CallDBus(object, path, method string, args ...interface{}) error { + cnn, _ := dbus.SessionBus() + + o := cnn.Object(object, dbus.ObjectPath(path)) + return o.Call(method, 0, args...).Err +} diff --git a/deck.go b/deck.go index a260afb..a13206a 100644 --- a/deck.go +++ b/deck.go @@ -6,22 +6,34 @@ import ( "image/draw" "math" "os" - "os/exec" "path/filepath" + "regexp" "strconv" "strings" "time" "github.com/atotto/clipboard" - "github.com/godbus/dbus" "github.com/muesli/streamdeck" ) +var ( + SPACES = regexp.MustCompile(`\s+`) + PATH = strings.Split(os.Getenv("PATH"), ":") +) + +type WindowWidgets struct { + resource regexp.Regexp + title regexp.Regexp + widgets map[uint8]Widget +} + // Deck is a set of widgets. type Deck struct { - File string - Background image.Image - Widgets []Widget + file string + background image.Image + windows []WindowWidgets + overrides map[uint8]*Widget + widgets map[uint8]Widget } // LoadDeck loads a deck configuration. @@ -30,7 +42,7 @@ func LoadDeck(dev *streamdeck.Device, base string, deck string) (*Deck, error) { if err != nil { return nil, err } - verbosef("Loading deck: %s", path) + verboseLog("Loading deck: %s", path) dc, err := LoadConfig(path) if err != nil { @@ -38,14 +50,16 @@ func LoadDeck(dev *streamdeck.Device, base string, deck string) (*Deck, error) { } d := Deck{ - File: path, + overrides: make(map[uint8]*Widget), + widgets: make(map[uint8]Widget), + file: path, } if dc.Background != "" { - bgpath, err := expandPath(filepath.Dir(path), dc.Background) + bgPath, err := expandPath(filepath.Dir(path), dc.Background) if err != nil { return nil, err } - if err := d.loadBackground(dev, bgpath); err != nil { + if err := d.loadBackground(dev, bgPath); err != nil { return nil, err } } @@ -68,14 +82,66 @@ func LoadDeck(dev *streamdeck.Device, base string, deck string) (*Deck, error) { w = NewBaseWidget(dev, filepath.Dir(path), i, nil, nil, bg) } - d.Widgets = append(d.Widgets, w) + d.widgets[i] = w + } + + for _, w := range dc.Windows { + if e := d.addWindow(dev, &w); e != nil { + return nil, e + } } return &d, nil } +func (deck *Deck) addWindow(dev *streamdeck.Device, w *WindowConfig) error { + verboseLog("loading window overrides %s:%s", w.Resource, w.Title) + + resource, err := regexp.Compile(w.Resource) + if err != nil { + errorLogF("failed to compile regex: %s", w.Resource) + return err + } + + title, err := regexp.Compile(w.Title) + if err != nil { + errorLogF("failed to compile regex: %s", w.Title) + return err + } + + window := WindowWidgets{ + resource: *resource, + title: *title, + widgets: make(map[uint8]Widget), + } + for _, key := range w.Keys { + if e := window.addWidget(dev, deck, key); e != nil { + errorLogF("failed to add widget %s:%s[%d]", w.Resource, w.Title, key.Index) + return e + } + } + deck.windows = append(deck.windows, window) + return nil +} + +func (ww *WindowWidgets) addWidget(dev *streamdeck.Device, deck *Deck, key KeyConfig) error { + bg := deck.backgroundForKey(dev, key.Index) + widget, err := NewWidget(dev, filepath.Dir(deck.file), key, bg) + if err != nil { + return err + } + ww.widgets[key.Index] = widget + return nil +} + +func (ww *WindowWidgets) Matches(window ActiveWindow) bool { + resource := ww.resource.MatchString(window.resource) + title := ww.title.MatchString(window.title) + return resource && title +} + // loads a background image. -func (d *Deck) loadBackground(dev *streamdeck.Device, bg string) error { +func (deck *Deck) loadBackground(dev *streamdeck.Device, bg string) error { f, err := os.Open(bg) if err != nil { return err @@ -99,25 +165,59 @@ func (d *Deck) loadBackground(dev *streamdeck.Device, bg string) error { return fmt.Errorf("supplied background image has wrong dimensions, expected %dx%d pixels", width, height) } - d.Background = background + deck.background = background return nil } // returns the background image for an individual key. -func (d Deck) backgroundForKey(dev *streamdeck.Device, key uint8) image.Image { +func (deck *Deck) backgroundForKey(dev *streamdeck.Device, key uint8) image.Image { padding := int(dev.Padding) pixels := int(dev.Pixels) bg := image.NewRGBA(image.Rect(0, 0, pixels, pixels)) - if d.Background != nil { - startx := int(key%dev.Columns) * (pixels + padding) - starty := int(key/dev.Columns) * (pixels + padding) - draw.Draw(bg, bg.Bounds(), d.Background, image.Point{startx, starty}, draw.Src) + if deck.background != nil { + start := image.Point{ + X: int(key%dev.Columns) * (pixels + padding), + Y: int(key/dev.Columns) * (pixels + padding), + } + draw.Draw(bg, bg.Bounds(), deck.background, start, draw.Src) } - return bg } +func (deck *Deck) WindowChanged(window ActiveWindow) { + verboseLog("windowChanged %s:%s %s", window.resource, window.title, window.id) + var match = false + for _, w := range deck.windows { + if w.Matches(window) { + verboseLog("windowMatch: %s:%s", w.resource, w.title) + for i, widget := range w.widgets { + deck.overrideWidget(i, widget) + } + match = true + } + } + if !match { + for key := range deck.overrides { + deck.restoreWidget(key) + } + } +} + +func (deck *Deck) overrideWidget(key uint8, widget Widget) { + deck.overrides[key] = &widget + if err := widget.Update(); err != nil { + fatal(err) + } +} + +func (deck *Deck) restoreWidget(key uint8) { + delete(deck.overrides, key) + if err := deck.widgets[key].Update(); err != nil { + fatal(err) + } +} + // handles keypress with delay. func emulateKeyPressWithDelay(keys string) { kd := strings.Split(keys, "+") @@ -142,16 +242,16 @@ func emulateKeyPresses(keys string) { // emulates a (multi-)key press. func emulateKeyPress(keys string) { if keyboard == nil { - fmt.Fprintln(os.Stderr, "Keyboard emulation is disabled!") + errorLogF("Keyboard emulation is disabled!") return } kk := strings.Split(keys, "-") for i, k := range kk { k = formatKeycodes(strings.TrimSpace(k)) - kc, err := strconv.Atoi(k) - if err != nil { - fmt.Fprintf(os.Stderr, "%s is not a valid keycode: %s\n", k, err) + kc, e := strconv.Atoi(k) + if e != nil { + errorLog(e, "%s is not a valid keycode", k) } if i+1 < len(kk) { @@ -165,126 +265,119 @@ func emulateKeyPress(keys string) { // emulates a clipboard paste. func emulateClipboard(text string) { - err := clipboard.WriteAll(text) - if err != nil { - fmt.Fprintf(os.Stderr, "Pasting to clipboard failed: %s\n", err) - } + errorLog(clipboard.WriteAll(text), "failed to paste from the Clipboard") // paste the string emulateKeyPress("29-47") // ctrl-v } // executes a dbus method. -func executeDBusMethod(object, path, method, args string) { - call := dbusConn.Object(object, dbus.ObjectPath(path)).Call(method, 0, args) - if call.Err != nil { - fmt.Fprintf(os.Stderr, "dbus call failed: %s\n", call.Err) +func executeDBusMethod(config *DBusConfig) { + if e := CallDBus(config.Object, config.Path, config.Method, config.Value); e != nil { + errorLog(e, "DBus call failed %+v", config) } } -// executes a command. -func executeCommand(cmd string) { - exp, err := expandPath("", cmd) - if err == nil { - cmd = exp +func (deck *Deck) Widgets(yield func(Widget) bool) { + for i, w := range deck.widgets { + override := deck.overrides[i] + if override == nil { + if !yield(w) { + return + } + } } - args := strings.Split(cmd, " ") - - c := exec.Command(args[0], args[1:]...) //nolint:gosec - if *verbose { - c.Stdout = os.Stdout - c.Stderr = os.Stderr + for i := range deck.overrides { + widget := deck.overrides[i] + if !yield(*widget) { + return + } } +} - if err := c.Start(); err != nil { - fmt.Fprintf(os.Stderr, "Command failed: %s\n", err) - return - } - if err := c.Wait(); err != nil { - fmt.Fprintf(os.Stderr, "Command failed: %s\n", err) +func (deck *Deck) widget(key uint8) Widget { + widget := deck.overrides[key] + if widget != nil { + return *widget } + return deck.widgets[key] } // triggerAction triggers an action. -func (d *Deck) triggerAction(dev *streamdeck.Device, index uint8, hold bool) { - for _, w := range d.Widgets { - if w.Key() != index { - continue - } +func (deck *Deck) triggerAction(dev *streamdeck.Device, index uint8, hold bool) { + w := deck.widget(index) + w.TriggerAction(hold) + + var a *ActionConfig + if hold { + a = w.ActionHold() + } else { + a = w.Action() + } - var a *ActionConfig - if hold { - a = w.ActionHold() - } else { - a = w.Action() + if a == nil { + return + } + if a.Deck != "" { + newDeck, err := LoadDeck(dev, filepath.Dir(deck.file), a.Deck) + if err != nil { + errorLog(err, "Failed to load deck %s", a.Deck) + return } - - if a == nil { - w.TriggerAction(hold) - continue + if err := dev.Clear(); err != nil { + fatal(err) + return } - if a.Deck != "" { - d, err := LoadDeck(dev, filepath.Dir(d.File), a.Deck) - if err != nil { - fmt.Fprintln(os.Stderr, "Can't load deck:", err) - return - } - if err := dev.Clear(); err != nil { + deck = newDeck + deck.updateWidgets() + } + if a.Keycode != "" { + emulateKeyPresses(a.Keycode) + } + if a.Paste != "" { + emulateClipboard(a.Paste) + } + if a.DBus.Method != "" { + executeDBusMethod(&a.DBus) + } + if a.Exec != "" { + errorLog(executeCommand(a.Exec), "failed to execute command") + } + if a.Device != "" { + switch { + case a.Device == "sleep": + if err := dev.Sleep(); err != nil { fatal(err) - return } - deck = d - deck.updateWidgets() - } - if a.Keycode != "" { - emulateKeyPresses(a.Keycode) - } - if a.Paste != "" { - emulateClipboard(a.Paste) - } - if a.DBus.Method != "" { - executeDBusMethod(a.DBus.Object, a.DBus.Path, a.DBus.Method, a.DBus.Value) - } - if a.Exec != "" { - go executeCommand(a.Exec) - } - if a.Device != "" { - switch { - case a.Device == "sleep": - if err := dev.Sleep(); err != nil { - fatalf("error: %v\n", err) - } - - case strings.HasPrefix(a.Device, "brightness"): - d.adjustBrightness(dev, strings.TrimPrefix(a.Device, "brightness")) - - default: - fmt.Fprintln(os.Stderr, "Unrecognized special action:", a.Device) - } + case strings.HasPrefix(a.Device, "brightness"): + deck.adjustBrightness(dev, strings.TrimPrefix(a.Device, "brightness")) + + default: + errorLogF("Unrecognized special action: %s", a.Device) } } } // updateWidgets updates/repaints all the widgets. -func (d *Deck) updateWidgets() { - for _, w := range d.Widgets { +func (deck *Deck) updateWidgets() { + for w := range deck.Widgets { if !w.RequiresUpdate() { continue } // fmt.Println("Repaint", w.Key()) if err := w.Update(); err != nil { - fatalf("error: %v", err) + fatal(err) } } } // adjustBrightness adjusts the brightness. -func (d *Deck) adjustBrightness(dev *streamdeck.Device, value string) { +func (deck *Deck) adjustBrightness(dev *streamdeck.Device, value string) { if len(value) == 0 { - fmt.Fprintln(os.Stderr, "No brightness value specified") + errorLogF("no brightness value specified") return } @@ -302,18 +395,18 @@ func (d *Deck) adjustBrightness(dev *streamdeck.Device, value string) { if v == math.MinInt64 { v = 10 } - v = int64(*brightness) - v + v = int64(*brightnessConfig) - v case '+': // brightness+[n]: if v == math.MinInt64 { v = 10 } - v = int64(*brightness) + v + v = int64(*brightnessConfig) + v default: v = math.MinInt64 } if v == math.MinInt64 { - fmt.Fprintf(os.Stderr, "Could not grok the brightness from value '%s'\n", value) + errorLogF("could not grok the brightness from value '%s'", value) return } @@ -323,8 +416,8 @@ func (d *Deck) adjustBrightness(dev *streamdeck.Device, value string) { v = 100 } if err := dev.SetBrightness(uint8(v)); err != nil { - fatalf("error: %v\n", err) + fatal(err) } - *brightness = uint(v) + *brightnessConfig = uint(v) } diff --git a/desktop_unix.go b/desktop_unix.go index c6ed231..66b94cc 100644 --- a/desktop_unix.go +++ b/desktop_unix.go @@ -1,14 +1,11 @@ //go:build linux -// +build linux package main import ( "bytes" "errors" - "fmt" "image" - "os" "time" "github.com/jezek/xgb" @@ -50,16 +47,16 @@ type Window struct { } // Connect establishes a connection with an Xorg display. -func Connect(display string) (*Xorg, error) { +func Connect() (*Xorg, error) { var x Xorg var err error - x.conn, err = xgb.NewConnDisplay(display) + x.conn, err = xgb.NewConn() if err != nil { return nil, err } - x.util, err = xgbutil.NewConnDisplay(display) + x.util, err = xgbutil.NewConn() if err != nil { return nil, err } @@ -67,6 +64,8 @@ func Connect(display string) (*Xorg, error) { if err := screensaver.Init(x.conn); err == nil { drw := xproto.Drawable(x.root) screensaver.SelectInput(x.conn, drw, screensaver.EventNotifyMask) + } else { + return nil, err } setup := xproto.Setup(x.conn) @@ -82,7 +81,7 @@ func Connect(display string) (*Xorg, error) { } // Close terminates the connection. -func (x Xorg) Close() { +func (x *Xorg) Close() { x.util.Conn().Close() x.conn.Close() } @@ -146,33 +145,33 @@ func (x *Xorg) TrackWindows(ch chan interface{}, timeout time.Duration) { } // ActiveWindow returns the currently active window. -func (x Xorg) ActiveWindow() Window { +func (x *Xorg) ActiveWindow() Window { return x.activeWindow } // RequestActivation requests a window to be focused. -func (x Xorg) RequestActivation(w Window) error { +func (x *Xorg) RequestActivation(w Window) error { return ewmh.ActiveWindowReq(x.util, xproto.Window(w.ID)) } // CloseWindow closes a window. -func (x Xorg) CloseWindow(w Window) error { +func (x *Xorg) CloseWindow(w Window) error { return ewmh.CloseWindow(x.util, xproto.Window(w.ID)) } -func (x Xorg) atom(aname string) *xproto.InternAtomReply { +func (x *Xorg) atom(aname string) *xproto.InternAtomReply { a, err := xproto.InternAtom(x.conn, true, uint16(len(aname)), aname).Reply() if err != nil { - fatal("atom:", err) + fatal(err) } return a } -func (x Xorg) property(w xproto.Window, a *xproto.InternAtomReply) (*xproto.GetPropertyReply, error) { +func (x *Xorg) property(w xproto.Window, a *xproto.InternAtomReply) (*xproto.GetPropertyReply, error) { return xproto.GetProperty(x.conn, false, w, a.Atom, xproto.GetPropertyTypeAny, 0, (1<<32)-1).Reply() } -func (x Xorg) active() xproto.Window { +func (x *Xorg) active() xproto.Window { p, err := x.property(x.root, x.activeAtom) if err != nil || len(p.Value) == 0 { return x.root @@ -180,7 +179,7 @@ func (x Xorg) active() xproto.Window { return xproto.Window(xgb.Get32(p.Value)) } -func (x Xorg) name(w xproto.Window) (string, error) { +func (x *Xorg) name(w xproto.Window) (string, error) { name, err := x.property(w, x.netNameAtom) if err != nil { return "", err @@ -197,17 +196,17 @@ func (x Xorg) name(w xproto.Window) (string, error) { return string(name.Value), nil } -func (x Xorg) icon(w xproto.Window) (image.Image, error) { +func (x *Xorg) icon(w xproto.Window) (image.Image, error) { icon, err := xgraphics.FindIcon(x.util, w, 128, 128) if err != nil { - fmt.Fprintf(os.Stderr, "Could not find icon for window %d\n", w) + errorLogF("Could not find icon for window %d", w) return nil, err } return icon, nil } -func (x Xorg) class(w xproto.Window) (string, error) { +func (x *Xorg) class(w xproto.Window) (string, error) { class, err := x.property(w, x.classAtom) if err != nil { return "", err @@ -220,7 +219,7 @@ func (x Xorg) class(w xproto.Window) (string, error) { return "", errors.New("empty class") } -func (x Xorg) window() (Window, bool) { +func (x *Xorg) window() (Window, bool) { id := x.active() /* skip invalid window id */ if id == 0 { @@ -248,16 +247,16 @@ func (x Xorg) window() (Window, bool) { }, true } -func (x Xorg) spy(w xproto.Window) { +func (x *Xorg) spy(w xproto.Window) { xproto.ChangeWindowAttributes(x.conn, w, xproto.CwEventMask, []uint32{xproto.EventMaskPropertyChange | xproto.EventMaskStructureNotify}) } -func (x Xorg) waitForEvent(events chan<- xgb.Event) { +func (x *Xorg) waitForEvent(events chan<- xgb.Event) { for { ev, err := x.conn.WaitForEvent() if err != nil { - verbosef("wait for event: %s", err) + verboseLog("wait for event: %s", err) continue } events <- ev @@ -268,7 +267,7 @@ func (x Xorg) waitForEvent(events chan<- xgb.Event) { func (x Xorg) queryIdle() time.Duration { info, err := screensaver.QueryInfo(x.conn, xproto.Drawable(x.root)).Reply() if err != nil { - fmt.Fprintln(os.Stderr, "query idle:", err) + errorLogF("query idle:", err) return 0 } return time.Duration(info.MsSinceUserInput) * time.Millisecond diff --git a/fonts.go b/fonts.go index 7773d26..e3ae2e1 100644 --- a/fonts.go +++ b/fonts.go @@ -1,7 +1,6 @@ package main import ( - "fmt" "image" "image/color" "io/ioutil" @@ -85,22 +84,22 @@ func loadFont(name string) (*truetype.Font, error) { } func init() { - var err error - ttfFont, err = loadFont("Roboto-Regular.ttf") - if err != nil { - fmt.Fprintln(os.Stderr, "Error loading font:", err) + var e error + ttfFont, e = loadFont("Roboto-Regular.ttf") + if e != nil { + errorLog(e, "failed to load Roboto-Regular.ttf") os.Exit(1) } - ttfThinFont, err = loadFont("Roboto-Thin.ttf") - if err != nil { - fmt.Fprintln(os.Stderr, "Error loading font:", err) + ttfThinFont, e = loadFont("Roboto-Thin.ttf") + if e != nil { + errorLog(e, "failed to load Roboto-Thin.ttf") os.Exit(1) } - ttfBoldFont, err = loadFont("Roboto-Bold.ttf") - if err != nil { - fmt.Fprintln(os.Stderr, "Error loading font:", err) + ttfBoldFont, e = loadFont("Roboto-Bold.ttf") + if e != nil { + errorLog(e, "failed to load Roboto-Bold.ttf") os.Exit(1) } } diff --git a/go.mod b/go.mod index bf198a4..9a0a100 100644 --- a/go.mod +++ b/go.mod @@ -1,34 +1,35 @@ module github.com/muesli/deckmaster -go 1.17 +go 1.24.0 + +toolchain go1.24.4 require ( - github.com/BurntSushi/toml v1.2.1 + github.com/BurntSushi/toml v1.5.0 github.com/atotto/clipboard v0.1.4 - github.com/bendahl/uinput v1.6.1 + github.com/bendahl/uinput v1.7.0 github.com/flopp/go-findfont v0.1.0 - github.com/godbus/dbus v4.1.0+incompatible + github.com/godbus/dbus/v5 v5.1.0 github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 - github.com/jezek/xgb v1.1.0 - github.com/jezek/xgbutil v0.0.0-20210302171758-530099784e66 - github.com/lucasb-eyer/go-colorful v1.2.0 + github.com/jezek/xgb v1.1.1 + github.com/jezek/xgbutil v0.0.0-20250620170308-517212d66001 + github.com/lucasb-eyer/go-colorful v1.3.0 github.com/mitchellh/go-homedir v1.1.0 github.com/muesli/streamdeck v0.4.0 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/shirou/gopsutil v3.21.11+incompatible - golang.org/x/image v0.7.0 + github.com/tvidal-net/pulseaudio v0.0.0-20250620201345-9831624d251c + golang.org/x/image v0.31.0 ) require ( github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298 // indirect github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/karalabe/hid v1.0.1-0.20190806082151-9c14560f9ee8 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/testify v1.2.2 // indirect - github.com/tklauser/go-sysconf v0.3.9 // indirect - github.com/tklauser/numcpus v0.3.0 // indirect - github.com/yusufpapurcu/wmi v1.2.2 // indirect - golang.org/x/sys v0.5.0 // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/tklauser/go-sysconf v0.3.15 // indirect + github.com/tklauser/numcpus v0.10.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + golang.org/x/sys v0.36.0 // indirect ) diff --git a/go.sum b/go.sum index 7bf1e95..c740373 100644 --- a/go.sum +++ b/go.sum @@ -2,32 +2,34 @@ github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298 h1:1qlsVAQJ github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298/go.mod h1:D+QujdIlUNfa0igpNMk6UIvlb6C252URs4yupRUV4lQ= github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966 h1:lTG4HQym5oPKjL7nGs+csTgiDna685ZXjxijkne828g= github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966/go.mod h1:Mid70uvE93zn9wgF92A/r5ixgnvX8Lh68fxp9KQBaI0= -github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= -github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/bendahl/uinput v1.6.1 h1:A8b6mtqC3E7ZkpLdQWNeyZNmLPhqxGS+fScrim3TV/k= -github.com/bendahl/uinput v1.6.1/go.mod h1:Np7w3DINc9wB83p12fTAM3DPPhFnAKP0WTXRqCQJ6Z8= +github.com/bendahl/uinput v1.7.0 h1:nA4fm8Wu8UYNOPykIZm66nkWEyvxzfmJ8YC02PM40jg= +github.com/bendahl/uinput v1.7.0/go.mod h1:Np7w3DINc9wB83p12fTAM3DPPhFnAKP0WTXRqCQJ6Z8= github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/flopp/go-findfont v0.1.0 h1:lPn0BymDUtJo+ZkV01VS3661HL6F4qFlkhcJN55u6mU= github.com/flopp/go-findfont v0.1.0/go.mod h1:wKKxRDjD024Rh7VMwoU90i6ikQRCr+JTHB5n4Ejkqvw= -github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/godbus/dbus v4.1.0+incompatible h1:WqqLRTsQic3apZUK9qC5sGNfXthmPXzUZ7nQPrNITa4= -github.com/godbus/dbus v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/jezek/xgb v1.1.0 h1:wnpxJzP1+rkbGclEkmwpVFQWpuE2PUGNUzP8SbfFobk= github.com/jezek/xgb v1.1.0/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= -github.com/jezek/xgbutil v0.0.0-20210302171758-530099784e66 h1:+wPhoJD8EH0/bXipIq8Lc2z477jfox9zkXPCJdhvHj8= -github.com/jezek/xgbutil v0.0.0-20210302171758-530099784e66/go.mod h1:KACeV+k6b+aoLTVrrurywEbu3UpqoQcQywj4qX8aQKM= +github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4= +github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= +github.com/jezek/xgbutil v0.0.0-20250620170308-517212d66001 h1:QzytUteVo3b0NqBGPPdq3GELwd0mpqYUHatYJFWRbZ0= +github.com/jezek/xgbutil v0.0.0-20250620170308-517212d66001/go.mod h1:AHecLyFNy6AN9f/+0AH/h1MI7X1+JL5bmCz4XlVZk7Y= github.com/karalabe/hid v1.0.1-0.20190806082151-9c14560f9ee8 h1:AP5krei6PpUCFOp20TSmxUS4YLoLvASBcArJqM/V+DY= github.com/karalabe/hid v1.0.1-0.20190806082151-9c14560f9ee8/go.mod h1:Vr51f8rUOLYrfrWDFlV12GGQgM5AT8sVh+2fY4MPeu8= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/muesli/coral v1.0.0/go.mod h1:bf91M/dkp7iHQw73HOoR9PekdTJMTD6ihJgWoDitde8= @@ -41,20 +43,22 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/tklauser/go-sysconf v0.3.9 h1:JeUVdAOWhhxVcU6Eqr/ATFHgXk/mmiItdKeJPev3vTo= -github.com/tklauser/go-sysconf v0.3.9/go.mod h1:11DU/5sG7UexIrp/O6g35hrWzu0JxlwQ3LSFUzyeuhs= -github.com/tklauser/numcpus v0.3.0 h1:ILuRUQBtssgnxw0XXIjKUC56fgnOrFoQQ/4+DeU2biQ= -github.com/tklauser/numcpus v0.3.0/go.mod h1:yFGUr7TUHQRAhyqBcEg0Ge34zDBAsIvJJcyE6boqnA8= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= +github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= +github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= +github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= +github.com/tvidal-net/pulseaudio v0.0.0-20250620201345-9831624d251c h1:kwARGYoU3KpBJ13tgTBZwMQQKyVNAahiJWWvwI6cvMw= +github.com/tvidal-net/pulseaudio v0.0.0-20250620201345-9831624d251c/go.mod h1:KobzMEwIxp4dPvYKZoi7Ekn3SNM6WPRQVWN7y8HQb+o= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg= -github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/image v0.6.0/go.mod h1:MXLdDR43H7cDJq5GEGXEVeeNhPgi+YYEQ2pC1byI1x0= -golang.org/x/image v0.7.0 h1:gzS29xtG1J5ybQlv0PuyfE3nmc6R4qB73m6LUUmvFuw= -golang.org/x/image v0.7.0/go.mod h1:nd/q4ef1AKKYl/4kft7g+6UyGbdiqWqTP1ZAbRoV7Rg= +golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA= +golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -68,11 +72,12 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210816074244-15123e1e1f71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -81,7 +86,6 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= @@ -89,3 +93,5 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/kwin/contents/code/main.js b/kwin/contents/code/main.js new file mode 100644 index 0000000..f2813ae --- /dev/null +++ b/kwin/contents/code/main.js @@ -0,0 +1,17 @@ +const service = "io.github.muesli.DeckMaster"; +const path = "/Monitor"; +const method = "ActiveWindowChanged"; + +function activeWindowChanged(window) { + if (window) { + const name = window.resourceName + "." + window.resourceClass; + const caption = window.caption; + const id = window.internalId.toString(); + callDBus(service, path, service, method, name, caption, id); + } +} + +const signal = workspace.windowActivated ?? workspace.clientActivated +signal.connect(activeWindowChanged); + +print("enabled: DeckMaster"); diff --git a/kwin/metadata.json b/kwin/metadata.json new file mode 100644 index 0000000..126446d --- /dev/null +++ b/kwin/metadata.json @@ -0,0 +1,18 @@ +{ + "KPlugin": { + "Name": "DeckMaster", + "Description": "Sends ActiveWindowChanged notifications to DeckMaster", + "Icon": "preferences-system-windows", + + "Authors": [ + {"Name": "Thiago Vidal"} + ], + "Id": "deckmaster", + "Version": "1.0", + "License": "GPLv3", + "Website": "https://github.com/muesli/deckmaster/kwin" + }, + "X-Plasma-API": "javascript", + "X-Plasma-MainScript": "code/main.js", + "KPackageStructure": "KWin/Script" +} \ No newline at end of file diff --git a/layouts.go b/layouts.go index 46eeacf..1517010 100644 --- a/layouts.go +++ b/layouts.go @@ -3,7 +3,6 @@ package main import ( "fmt" "image" - "os" "strconv" "strings" ) @@ -52,9 +51,9 @@ func (l *Layout) FormatLayout(frameReps []string, frameCount int) []image.Rectan continue } - frame, err := formatFrame(frameReps[i]) - if err != nil { - fmt.Fprintln(os.Stderr, "using default frame:", err) + frame, e := formatFrame(frameReps[i]) + if e != nil { + errorLog(e, "using default frame") frame = l.defaultFrame(frameCount, i) } l.frames = append(l.frames, frame) diff --git a/main.go b/main.go index c808619..6b90538 100644 --- a/main.go +++ b/main.go @@ -1,18 +1,19 @@ package main import ( - "errors" "flag" "fmt" "os" "os/signal" "path/filepath" + "regexp" "sync" "syscall" "time" + "github.com/godbus/dbus/v5" + "github.com/bendahl/uinput" - "github.com/godbus/dbus" "github.com/mitchellh/go-homedir" "github.com/muesli/streamdeck" ) @@ -28,19 +29,21 @@ var ( deck *Deck - dbusConn *dbus.Conn keyboard uinput.Keyboard shutdown = make(chan error) + invalidChars = regexp.MustCompile("[[:^graph:]]+") + + pa *PulseAudio xorg *Xorg recentWindows []Window - deckFile = flag.String("deck", "main.deck", "path to deck config file") - device = flag.String("device", "", "which device to use (serial number)") - brightness = flag.Uint("brightness", 80, "brightness in percent") - sleep = flag.String("sleep", "", "sleep timeout") - verbose = flag.Bool("verbose", false, "verbose output") - version = flag.Bool("version", false, "display version") + deckFileConfig = flag.String("deck", "main.deck", "path to deck config file") + deviceConfig = flag.String("device", "", "which device to use (serial number)") + brightnessConfig = flag.Uint("brightness", 80, "brightness in percent") + sleepConfig = flag.String("sleep", "", "sleep timeout") + verboseConfig = flag.Bool("verbose", false, "verbose output") + versionConfig = flag.Bool("version", false, "display version") ) const ( @@ -48,20 +51,29 @@ const ( longPressDuration = 350 * time.Millisecond ) -func fatal(v ...interface{}) { - go func() { shutdown <- errors.New(fmt.Sprint(v...)) }() +func errorLog(e error, format string, args ...interface{}) { + if e != nil { + message := fmt.Sprintf(format, args...) + _, _ = fmt.Fprintf(os.Stderr, "ERROR: %s\n\t%+v\n", message, e) + } +} + +func errorLogF(format string, args ...interface{}) { + _, _ = fmt.Fprintf(os.Stderr, format+"\n", args...) } -func fatalf(format string, a ...interface{}) { - go func() { shutdown <- fmt.Errorf(format, a...) }() +func fatal(e error) { + go func() { shutdown <- e }() } -func verbosef(format string, a ...interface{}) { - if !*verbose { - return +func verboseLog(format string, a ...interface{}) { + if *verboseConfig { + fmt.Printf(format+"\n", a...) } +} - fmt.Printf(format+"\n", a...) +func strip(s string) string { + return invalidChars.ReplaceAllString(s, "") } func expandPath(base, path string) (string, error) { @@ -81,6 +93,22 @@ func expandPath(base, path string) (string, error) { return filepath.Abs(path) } +func reapChildProcesses() { + sigs := make(chan os.Signal) + signal.Notify(sigs, syscall.SIGCHLD) + + for range sigs { + for { + var status syscall.WaitStatus + pid, err := syscall.Wait4(-1, &status, syscall.WNOHANG, nil) + if pid <= 0 || err != nil { + break + } + verboseLog("reaped pid=%d status=%d", pid, status.ExitStatus()) + } + } +} + func eventLoop(dev *streamdeck.Device, tch chan interface{}) error { sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) @@ -91,9 +119,14 @@ func eventLoop(dev *streamdeck.Device, tch chan interface{}) error { var keyStates sync.Map keyTimestamps := make(map[uint8]time.Time) - kch, err := dev.ReadKeys() - if err != nil { - return err + go pa.Start() + go reapChildProcesses() + + wch := MonitorActiveWindowChanged() + + kch, e := dev.ReadKeys() + if e != nil { + return e } for { select { @@ -102,8 +135,8 @@ func eventLoop(dev *streamdeck.Device, tch chan interface{}) error { case k, ok := <-kch: if !ok { - if err = dev.Open(); err != nil { - return err + if e = dev.Open(); e != nil { + return e } continue } @@ -117,27 +150,45 @@ func eventLoop(dev *streamdeck.Device, tch chan interface{}) error { if state && !k.Pressed { // key was released if time.Since(keyTimestamps[k.Index]) < longPressDuration { - verbosef("Triggering short action for key %d", k.Index) + verboseLog("Triggering short action for key %d", k.Index) deck.triggerAction(dev, k.Index, false) } } if !state && k.Pressed { // key was pressed go func() { - // launch timer to observe keystate + // launch timer to observe KeyState time.Sleep(longPressDuration) if state, ok := keyStates.Load(k.Index); ok && state.(bool) { // key still pressed - verbosef("Triggering long action for key %d", k.Index) + verboseLog("Triggering long action for key %d", k.Index) deck.triggerAction(dev, k.Index, true) } }() } keyTimestamps[k.Index] = time.Now() - case e := <-tch: - switch event := e.(type) { + case changeType := <-pa.Updates(): + playback := changeType == SinkMuteChanged || changeType == SinkChanged + for widget := range deck.Widgets { + w, success := widget.(MuteChangedMonitor) + if success { + w.MuteChanged(playback) + } + if changeType == SinkChanged || changeType == SourceChanged { + w, success := widget.(AudioChangedMonitor) + if success { + w.AudioStreamChanged(changeType) + } + } + } + + case activeWindow := <-wch: + deck.WindowChanged(activeWindow) + + case event := <-tch: + switch event := event.(type) { case WindowClosedEvent: handleWindowClosed(event) @@ -149,12 +200,11 @@ func eventLoop(dev *streamdeck.Device, tch chan interface{}) error { return err case <-hup: - verbosef("Received SIGHUP, reloading configuration...") + verboseLog("Received SIGHUP, reloading configuration...") - nd, err := LoadDeck(dev, ".", deck.File) - if err != nil { - verbosef("The new configuration is not valid, keeping the current one.") - fmt.Fprintf(os.Stderr, "Configuration Error: %s\n", err) + nd, e := LoadDeck(dev, ".", deck.file) + if e != nil { + errorLog(e, "invalid configuration") continue } @@ -169,12 +219,10 @@ func eventLoop(dev *streamdeck.Device, tch chan interface{}) error { } func closeDevice(dev *streamdeck.Device) { - if err := dev.Reset(); err != nil { - fmt.Fprintln(os.Stderr, "Unable to reset Stream Deck") - } - if err := dev.Close(); err != nil { - fmt.Fprintln(os.Stderr, "Unable to close Stream Deck") - } + errorLog(dev.Reset(), "failed to reset Stream Deck") + errorLog(dev.Clear(), "failed to clear the Stream Deck") + errorLog(dev.Sleep(), "failed to sleep the Stream Deck") + errorLog(dev.Close(), "failed to close Stream Deck") } func initDevice() (*streamdeck.Device, error) { @@ -187,19 +235,19 @@ func initDevice() (*streamdeck.Device, error) { } dev := d[0] - if len(*device) > 0 { + if len(*deviceConfig) > 0 { found := false for _, v := range d { - if v.Serial == *device { + if v.Serial == *deviceConfig { dev = v found = true break } } if !found { - fmt.Fprintln(os.Stderr, "Can't find device. Available devices:") + errorLogF("Can't find device. Available devices:") for _, v := range d { - fmt.Fprintf(os.Stderr, "Serial %s (%d buttons)\n", v.Serial, dev.Keys) + errorLogF("Serial %s (%d buttons)", v.Serial, dev.Keys) } os.Exit(1) } @@ -212,23 +260,23 @@ func initDevice() (*streamdeck.Device, error) { if err != nil { return &dev, err } - verbosef("Found device with serial %s (%d buttons, firmware %s)", - dev.Serial, dev.Keys, ver) + verboseLog("Found device with serial %s (%d buttons, firmware %s)", + dev.Serial, dev.Keys, strip(ver)) if err := dev.Reset(); err != nil { return &dev, err } - if *brightness > 100 { - *brightness = 100 + if *brightnessConfig > 100 { + *brightnessConfig = 100 } - if err = dev.SetBrightness(uint8(*brightness)); err != nil { + if err = dev.SetBrightness(uint8(*brightnessConfig)); err != nil { return &dev, err } dev.SetSleepFadeDuration(fadeDuration) - if len(*sleep) > 0 { - timeout, err := time.ParseDuration(*sleep) + if len(*sleepConfig) > 0 { + timeout, err := time.ParseDuration(*sleepConfig) if err != nil { return &dev, err } @@ -241,44 +289,52 @@ func initDevice() (*streamdeck.Device, error) { func run() error { // initialize device - dev, err := initDevice() + dev, e := initDevice() if dev != nil { defer closeDevice(dev) } - if err != nil { - return fmt.Errorf("Unable to initialize Stream Deck: %s", err) + if e != nil { + return fmt.Errorf("failed to initialize Stream Deck: %w", e) } // initialize dbus connection - dbusConn, err = dbus.SessionBus() - if err != nil { - return fmt.Errorf("Unable to connect to dbus: %s", err) + sessionBus, e := dbus.ConnectSessionBus() + if e != nil { + return fmt.Errorf("failed to connect to DBus %w", e) } + defer sessionBus.Close() //nolint:errcheck // initialize xorg connection and track window focus tch := make(chan interface{}) - xorg, err = Connect(os.Getenv("DISPLAY")) - if err == nil { + xorg, e = Connect() + if e == nil { defer xorg.Close() xorg.TrackWindows(tch, time.Second) } else { - fmt.Fprintf(os.Stderr, "Could not connect to X server: %s\n", err) - fmt.Fprintln(os.Stderr, "Tracking window manager will be disabled!") + errorLog(e, "failed to connect to X server (Wayland?)") } // initialize virtual keyboard - keyboard, err = uinput.CreateKeyboard("/dev/uinput", []byte("Deckmaster")) - if err != nil { - fmt.Fprintf(os.Stderr, "Could not create virtual input device (/dev/uinput): %s\n", err) - fmt.Fprintln(os.Stderr, "Emulating keyboard events will be disabled!") + keyboard, e = uinput.CreateKeyboard("/dev/uinput", []byte("deckmaster")) + if e != nil { + errorLog(e, "failed to create virtual input device (/dev/uinput)") + errorLogF("Emulating keyboard events will be disabled!") } else { defer keyboard.Close() //nolint:errcheck } + // initialize PulseAudio + pa, e = NewPulseAudio() + if e != nil { + errorLog(e, "failed to create PulseAudio device") + } else { + defer pa.Close() + } + // load deck - deck, err = LoadDeck(dev, ".", *deckFile) - if err != nil { - return fmt.Errorf("Can't load deck: %s", err) + deck, e = LoadDeck(dev, ".", *deckFileConfig) + if e != nil { + return fmt.Errorf("failed to load deck: %s", e) } deck.updateWidgets() @@ -288,7 +344,7 @@ func run() error { func main() { flag.Parse() - if *version { + if *versionConfig { if len(CommitSHA) > 7 { CommitSHA = CommitSHA[:7] } @@ -305,8 +361,8 @@ func main() { os.Exit(0) } - if err := run(); err != nil { - fmt.Fprintln(os.Stderr, err) + if e := run(); e != nil { + errorLog(e, "fatal") os.Exit(1) } } diff --git a/process.go b/process.go new file mode 100644 index 0000000..b9cfef8 --- /dev/null +++ b/process.go @@ -0,0 +1,82 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path" + "path/filepath" + "strings" +) + +const ( + DirMode = 0755 + FileMode = 0644 + cgroupProcsFile = "cgroup.procs" + processCGroupFile = "/proc/self/cgroup" + baseCGroupPath = "/sys/fs/cgroup" +) + +var ( + childProcessCGroup = createNewCGroup("deckmaster.scope") +) + +func runningCGroup() string { + s, err := os.ReadFile(processCGroupFile) + if err != nil { + errorLogF("Unable to read the control group for the current process") + panic(err) + } + split := strings.Split(string(s), ":") + cgroup := split[len(split)-1] + return strings.TrimSpace(cgroup) +} + +func createNewCGroup(name string) string { + cgroupParent := filepath.Dir(runningCGroup()) + cgroupPath := path.Join(baseCGroupPath, cgroupParent, name) + if err := os.MkdirAll(cgroupPath, DirMode); err != nil { + errorLogF("Unable to create new control group for child processes\n\t", cgroupPath) + panic(err) + } + verboseLog("Using control group to spawn child processes\n\t%s", cgroupPath) + return cgroupPath +} + +func moveProcessToCGroup(pid int, cgroup string) error { + cgroupFile := path.Join(cgroup, cgroupProcsFile) + fileContents := fmt.Sprintf("%d\n", pid) + return os.WriteFile(cgroupFile, []byte(fileContents), FileMode) +} + +func expandExecutable(exe string) string { + for _, base := range PATH { + cmd := filepath.Join(base, exe) + s, e := os.Stat(cmd) + if e != nil || s.IsDir() { + continue + } + fileMode := s.Mode() + if fileMode&0111 != 0 { + return cmd + } + } + return exe +} + +// executes a command. +func executeCommand(cmd string) error { + args := SPACES.Split(cmd, -1) + exe := expandExecutable(args[0]) + + command := exec.Command(exe, args[1:]...) + if err := command.Start(); err != nil { + errorLogF("failed to execute '%s %s'", exe, args[1:]) + return err + } + pid := command.Process.Pid + if err := moveProcessToCGroup(pid, childProcessCGroup); err != nil { + errorLog(err, "Unable to move child process %d to cgroup", pid) + } + return command.Process.Release() +} diff --git a/pulseaudio.go b/pulseaudio.go new file mode 100644 index 0000000..904de9b --- /dev/null +++ b/pulseaudio.go @@ -0,0 +1,198 @@ +package main + +import ( + "strings" + "time" + + "github.com/tvidal-net/pulseaudio" +) + +const ( + SinkChanged = iota + SourceChanged + SinkMuteChanged + SourceMuteChanged +) + +type ChangeType uint8 + +type PulseAudio struct { + client pulseaudio.Client + currentSink pulseaudio.Sink + currentSource pulseaudio.Source + updates chan ChangeType +} + +func getSink(name string, client *pulseaudio.Client) (*pulseaudio.Sink, error) { + sinks, err := client.Sinks() + if err != nil { + return nil, err + } + for _, sink := range sinks { + if sink.Name == name { + return &sink, nil + } + } + return nil, &pulseaudio.Error{Cmd: "getSink", Code: 3} +} + +func getSource(name string, client *pulseaudio.Client) (*pulseaudio.Source, error) { + sources, err := client.Sources() + if err != nil { + return nil, err + } + for _, source := range sources { + if source.Name == name { + return &source, nil + } + } + return nil, &pulseaudio.Error{Cmd: "getSource", Code: 3} +} + +func NewPulseAudio() (*PulseAudio, error) { + client, err := pulseaudio.NewClient() + if err != nil { + return nil, err + } + + serverInfo, err := client.ServerInfo() + if err != nil { + client.Close() + return nil, err + } + + defaultSink, err := getSink(serverInfo.DefaultSink, client) + if err != nil { + client.Close() + return nil, err + } + + defaultSource, err := getSource(serverInfo.DefaultSource, client) + if err != nil { + client.Close() + return nil, err + } + pulseAudio := &PulseAudio{ + *client, + *defaultSink, + *defaultSource, + make(chan ChangeType), + } + return pulseAudio, nil +} + +func (pa *PulseAudio) Updates() <-chan ChangeType { + return pa.updates +} + +func (pa *PulseAudio) Start() { + var pulseAudioUpdates <-chan struct{} + for { + clientUpdates, e := pa.client.Updates() + if e != nil { + errorLog(e, "failed to subscribe to PulseAudio updates") + time.Sleep(time.Second) + } else { + pulseAudioUpdates = clientUpdates + break + } + } + for { + _ = <-pulseAudioUpdates + serverInfo, e := pa.client.ServerInfo() + if e != nil { + errorLog(e, "failed to get PulseAudio server info") + continue + } + + defaultSink, e := getSink(serverInfo.DefaultSink, &pa.client) + if e != nil { + errorLog(e, "failed to get PulseAudio sinks") + continue + } + if defaultSink.Name != pa.CurrentSinkName() { + pa.currentSink = *defaultSink + pa.updates <- SinkChanged + } + if defaultSink.Muted != pa.currentSink.Muted { + pa.currentSink = *defaultSink + pa.updates <- SinkMuteChanged + } + + defaultSource, e := getSource(serverInfo.DefaultSource, &pa.client) + if e != nil { + errorLog(e, "failed to get PulseAudio sources") + continue + } + if defaultSource.Name != pa.CurrentSourceName() { + pa.currentSource = *defaultSource + pa.updates <- SourceChanged + } + if defaultSource.Muted != pa.currentSource.Muted { + pa.currentSource = *defaultSource + pa.updates <- SourceMuteChanged + } + } +} + +func (pa *PulseAudio) Muted(isSinkStream bool) bool { + if isSinkStream { + return pa.currentSink.Muted + } else { + return pa.currentSource.Muted + } +} + +func (pa *PulseAudio) ToggleMute(isSinkStream bool) error { + if isSinkStream { + return pa.client.SetSinkMute(!pa.currentSink.Muted, pa.CurrentSinkName()) + } else { + return pa.client.SetSourceMute(!pa.currentSource.Muted, pa.CurrentSourceName()) + } +} + +func (pa *PulseAudio) CurrentSinkName() string { + return pa.currentSink.Name +} + +func (pa *PulseAudio) SetSink(partialName string) error { + verboseLog("currentSink: %s", pa.CurrentSinkName()) + sinks, err := pa.client.Sinks() + if err != nil { + return err + } + for _, sink := range sinks { + sinkName := sink.Name + if sink.Name != pa.CurrentSinkName() && strings.Contains(sinkName, partialName) { + verboseLog("setSink \"%s\"=%s", partialName, sinkName) + return pa.client.SetDefaultSink(sinkName) + } + } + return nil +} + +func (pa *PulseAudio) CurrentSourceName() string { + return pa.currentSource.Name +} + +func (pa *PulseAudio) SetSource(partialName string) error { + verboseLog("currentSource: %s", pa.CurrentSourceName()) + sources, err := pa.client.Sources() + if err != nil { + return err + } + for _, source := range sources { + if source.MonitorSourceName == "" { + sourceName := source.Name + if source.Name != pa.CurrentSourceName() && strings.Contains(sourceName, partialName) { + verboseLog("setSource \"%s\"=%s", partialName, sourceName) + return pa.client.SetDefaultSource(sourceName) + } + } + } + return nil +} + +func (pa *PulseAudio) Close() { + pa.client.Close() +} diff --git a/widget.go b/widget.go index 60764b1..f437425 100644 --- a/widget.go +++ b/widget.go @@ -5,6 +5,9 @@ import ( "image" "image/color" "image/draw" + _ "image/gif" + _ "image/jpeg" + _ "image/png" "os" "path/filepath" "time" @@ -17,7 +20,7 @@ import ( var ( // DefaultColor is the standard color for text rendering. - DefaultColor = color.RGBA{255, 255, 255, 255} + DefaultColor = color.White ) // Widget is an interface implemented by all available widgets. @@ -98,6 +101,12 @@ func NewWidget(dev *streamdeck.Device, base string, kc KeyConfig, bg image.Image case "button": return NewButtonWidget(bw, kc.Widget) + case "audio": + return NewAudioWidget(bw, kc.Widget) + + case "mute": + return NewMuteWidget(bw, kc.Widget) + case "clock": kc.Widget.Config = make(map[string]interface{}) kc.Widget.Config["format"] = "%H;%i;%s" @@ -127,7 +136,7 @@ func NewWidget(dev *streamdeck.Device, base string, kc KeyConfig, bg image.Image } // unknown widget ID - return nil, fmt.Errorf("Unknown widget with ID %s", kc.Widget.ID) + return nil, fmt.Errorf("unknown widget with ID %s", kc.Widget.ID) } // renders the widget including its background image. @@ -163,6 +172,9 @@ func loadImage(path string) (image.Image, error) { defer f.Close() //nolint:errcheck icon, _, err := image.Decode(f) + if err != nil { + return nil, fmt.Errorf("image=%s, %w", filepath.Base(path), err) + } return icon, err } @@ -216,7 +228,7 @@ func drawString(img *image.RGBA, bounds image.Rectangle, ttf *truetype.Font, tex c := ftContext(img, ttf, dpi, fontsize) if fontsize <= 0 { - // pick biggest available height to fit the string + // pick the biggest available height to fit the string fontsize, _ = maxPointSize(text, ftContext(img, ttf, dpi, fontsize), dpi, bounds.Dx(), bounds.Dy()) @@ -238,8 +250,8 @@ func drawString(img *image.RGBA, bounds image.Rectangle, ttf *truetype.Font, tex } c.SetSrc(image.NewUniform(color)) - if _, err := c.DrawString(text, freetype.Pt(pt.X, pt.Y)); err != nil { - fmt.Fprintf(os.Stderr, "Can't render string: %s\n", err) + if _, e := c.DrawString(text, freetype.Pt(pt.X, pt.Y)); e != nil { + errorLog(e, "failed to render string") return } } diff --git a/widget_audio.go b/widget_audio.go new file mode 100644 index 0000000..79ff048 --- /dev/null +++ b/widget_audio.go @@ -0,0 +1,122 @@ +package main + +import ( + "image" + "strings" +) + +const ( + AltImageConfig = "alt" + MainStreamConfig = "main" + AltStreamConfig = "stream" +) + +type AudioWidget struct { + *ButtonWidget + + alt image.Image + mainStream []string + altStream []string +} + +type AudioChangedMonitor interface { + AudioStreamChanged(changeType ChangeType) +} + +func NewAudioWidget(bw *BaseWidget, opts WidgetConfig) (*AudioWidget, error) { + button, err := NewButtonWidget(bw, opts) + if err != nil { + return nil, err + } + + var mainStreamConfig, altStreamConfig, altImageConfig string + _ = ConfigValue(opts.Config[MainStreamConfig], &mainStreamConfig) + _ = ConfigValue(opts.Config[AltStreamConfig], &altStreamConfig) + w := &AudioWidget{ + ButtonWidget: button, + mainStream: strings.Split(mainStreamConfig, ","), + altStream: strings.Split(altStreamConfig, ","), + } + _ = ConfigValue(opts.Config[AltImageConfig], &altImageConfig) + if err := w.LoadImage(&w.alt, altImageConfig); err != nil { + return nil, err + } + return w, nil +} + +func (w *AudioWidget) MainSourceStream() string { + if len(w.mainStream) > 0 { + return w.mainStream[0] + } + return "" +} + +func (w *AudioWidget) MainSinkStream() string { + if len(w.mainStream) > 1 { + return w.mainStream[1] + } + return w.MainSourceStream() +} + +func (w *AudioWidget) AltSourceStream() string { + if len(w.altStream) > 0 { + return w.altStream[0] + } + return "" +} + +func (w *AudioWidget) AltSinkStream() string { + if len(w.altStream) > 1 { + return w.altStream[1] + } + return w.AltSourceStream() +} + +func (w *AudioWidget) IsMainStreamDefault() bool { + sinkName := w.MainSinkStream() + if sinkName == "" { + return !strings.Contains(pa.CurrentSinkName(), w.AltSinkStream()) + } + return strings.Contains(pa.CurrentSinkName(), sinkName) +} + +func (w *AudioWidget) SetSinkStream(alt bool) { + if alt { + errorLog(pa.SetSink(w.AltSinkStream()), "failed to set PulseAudio sink stream") + } else { + errorLog(pa.SetSink(w.MainSinkStream()), "failed to set PulseAudio sink stream") + } +} + +func (w *AudioWidget) SetSourceStream(alt bool) { + if alt { + errorLog(pa.SetSource(w.AltSourceStream()), "failed to set PulseAudio source stream") + } else { + errorLog(pa.SetSource(w.MainSourceStream()), "failed to set PulseAudio source stream") + } + errorLog(w.Update(), "failed to update Widget") +} + +func (w *AudioWidget) Update() error { + if w.IsMainStreamDefault() { + return w.Draw(w.icon) + } else { + return w.Draw(w.alt) + } +} + +func (w *AudioWidget) TriggerAction(hold bool) { + if !hold { + w.SetSinkStream(w.IsMainStreamDefault()) + } +} + +func (w *AudioWidget) AudioStreamChanged(changeType ChangeType) { + if changeType == SinkChanged { + verboseLog("SinkChanged") + w.SetSourceStream(!w.IsMainStreamDefault()) + } else { + verboseLog("SourceChanged") + errorLog(w.Update(), "failed to update Widget") + } +} diff --git a/widget_button.go b/widget_button.go index e57025d..91bf8a4 100644 --- a/widget_button.go +++ b/widget_button.go @@ -42,17 +42,18 @@ func NewButtonWidget(bw *BaseWidget, opts WidgetConfig) (*ButtonWidget, error) { color: color, flatten: flatten, } - if icon != "" { - if err := w.LoadImage(icon); err != nil { - return nil, err - } + if err := w.LoadImage(&w.icon, icon); err != nil { + return nil, err } - return w, nil } // LoadImage loads an image from disk. -func (w *ButtonWidget) LoadImage(path string) error { +func (w *ButtonWidget) LoadImage(property *image.Image, path string) error { + if path == "" { + return nil + } + path, err := expandPath(w.base, path) if err != nil { return err @@ -62,7 +63,11 @@ func (w *ButtonWidget) LoadImage(path string) error { return err } - w.SetImage(icon) + if w.flatten { + *property = flattenImage(icon, w.color) + } else { + *property = icon + } return nil } @@ -76,26 +81,31 @@ func (w *ButtonWidget) SetImage(img image.Image) { // Update renders the widget. func (w *ButtonWidget) Update() error { + return w.Draw(w.icon) +} + +// Draw draws the image to the device button +func (w *ButtonWidget) Draw(icon image.Image) error { size := int(w.dev.Pixels) margin := size / 18 height := size - (margin * 2) img := image.NewRGBA(image.Rect(0, 0, size, size)) if w.label != "" { - iconsize := int((float64(height) / 3.0) * 2.0) + iconSize := int((float64(height) / 3.0) * 2.0) bounds := img.Bounds() - if w.icon != nil { + if icon != nil { err := drawImage(img, - w.icon, - iconsize, + icon, + iconSize, image.Pt(-1, margin)) if err != nil { return err } - bounds.Min.Y += iconsize + margin + bounds.Min.Y += iconSize + margin bounds.Max.Y -= margin } @@ -107,9 +117,9 @@ func (w *ButtonWidget) Update() error { w.fontsize, w.color, image.Pt(-1, -1)) - } else if w.icon != nil { + } else if icon != nil { err := drawImage(img, - w.icon, + icon, height, image.Pt(-1, -1)) diff --git a/widget_mute.go b/widget_mute.go new file mode 100644 index 0000000..b3e3fe8 --- /dev/null +++ b/widget_mute.go @@ -0,0 +1,63 @@ +package main + +import ( + "image" +) + +const ( + StreamConfig = "stream" + MutedConfig = "muted" + MicStreamConfig = "mic" +) + +type MuteWidget struct { + *ButtonWidget + + muted image.Image + playback bool +} + +type MuteChangedMonitor interface { + MuteChanged(isSinkStream bool) +} + +func NewMuteWidget(bw *BaseWidget, opts WidgetConfig) (*MuteWidget, error) { + button, err := NewButtonWidget(bw, opts) + if err != nil { + return nil, err + } + + var muted, stream string + _ = ConfigValue(opts.Config[MutedConfig], &muted) + _ = ConfigValue(opts.Config[StreamConfig], &stream) + + isPlayback := stream != MicStreamConfig + w := &MuteWidget{ + ButtonWidget: button, + playback: isPlayback, + } + if err := w.LoadImage(&w.muted, muted); err != nil { + return nil, err + } + return w, nil +} + +func (w *MuteWidget) Update() error { + if pa.Muted(w.playback) { + return w.Draw(w.muted) + } else { + return w.Draw(w.icon) + } +} + +func (w *MuteWidget) TriggerAction(hold bool) { + if !hold { + errorLog(pa.ToggleMute(w.playback), "failed to toggle mute") + } +} + +func (w *MuteWidget) MuteChanged(playback bool) { + if playback == w.playback { + errorLog(w.Update(), "failed to update widget") + } +} diff --git a/widget_recent_window.go b/widget_recent_window.go index ff7750d..b660912 100644 --- a/widget_recent_window.go +++ b/widget_recent_window.go @@ -1,9 +1,7 @@ package main import ( - "fmt" "image" - "os" ) // RecentWindowWidget is a widget displaying a recently activated window. @@ -76,7 +74,7 @@ func (w *RecentWindowWidget) Update() error { // TriggerAction gets called when a button is pressed. func (w *RecentWindowWidget) TriggerAction(hold bool) { if xorg == nil { - fmt.Fprintln(os.Stderr, "xorg support is disabled!") + errorLogF("xorg support is disabled!") return } diff --git a/widget_weather.go b/widget_weather.go index 8d30220..f1f8d09 100644 --- a/widget_weather.go +++ b/widget_weather.go @@ -7,7 +7,6 @@ import ( "image" "io/ioutil" "net/http" - "os" "path/filepath" "strings" "sync" @@ -57,7 +56,7 @@ func (w *WeatherData) Condition() (string, error) { defer w.responseMutex.RUnlock() if strings.Contains(w.response, "Unknown location") { - fmt.Fprintln(os.Stderr, "unknown location:", w.location) + errorLogF("unknown location: %s", w.location) return "", nil } @@ -75,7 +74,7 @@ func (w *WeatherData) Temperature() (string, error) { defer w.responseMutex.RUnlock() if strings.Contains(w.response, "Unknown location") { - fmt.Fprintln(os.Stderr, "unknown location:", w.location) + errorLogF("unknown location: %s", w.location) return "", nil } @@ -112,20 +111,20 @@ func (w *WeatherData) Fetch() { if time.Since(lastRefresh) < time.Minute*15 { return } - verbosef("Refreshing weather data...") + verboseLog("Refreshing weather data...") url := "http://wttr.in/" + w.location + "?format=%x+%t" + formatUnit(w.unit) - resp, err := http.Get(url) //nolint:gosec - if err != nil { - fmt.Fprintln(os.Stderr, "can't fetch weather data:", err) + resp, e := http.Get(url) //nolint:gosec + if e != nil { + errorLog(e, "failed to fetch weather data") return } defer resp.Body.Close() //nolint:errcheck - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - fmt.Fprintln(os.Stderr, "can't read weather data:", err) + body, e := ioutil.ReadAll(resp.Body) + if e != nil { + errorLog(e, "failed to read weather data") return } @@ -219,7 +218,7 @@ func (w *WeatherWidget) Update() error { var err error weatherIcon, err = loadThemeImage(w.theme, iconName) if err != nil { - fmt.Fprintln(os.Stderr, "weather widget using fallback icons") + errorLogF("weather widget using fallback icons") weatherIcon = weatherImage(imagePath) } } else { diff --git a/window.go b/window.go index 9efa6e9..5d22866 100644 --- a/window.go +++ b/window.go @@ -5,7 +5,7 @@ import ( ) func handleActiveWindowChanged(dev *streamdeck.Device, event ActiveWindowChangedEvent) { - verbosef("Active window changed to %s (%d, %s)", + verboseLog("Active window changed to %s (%d, %s)", event.Window.Class, event.Window.ID, event.Window.Name) // remove dupes