~jcc/swaybar-commander

8b042d7760beb8b726c3b3f4904edf35b3877f97 — Jason Cox 1 year, 8 months ago 4e9e200
Add helper command to easily request block updates
M README.md => README.md +1 -1
@@ 1,6 1,6 @@
# swaybar-commander

[swaybar-commander](https://sr.ht/~jcc/swaybar-commander) is a swaybar command that allows each section of the bar to update at its own frequency. The sections are configured in a simple TOML file and can take advantage of the [swaybar-protocol](https://man.archlinux.org/man/swaybar-protocol.7.en).
[swaybar-commander](https://sr.ht/~jcc/swaybar-commander) is a swaybar command that allows each section of the bar to update independently. Each section updates at a regular frequency, and additional updates can be requested on-demand via a helper command. The sections are configured in a simple TOML file and can take advantage of the [swaybar-protocol](https://man.archlinux.org/man/swaybar-protocol.7.en).

## Documentation


M commander.go => commander.go +23 -39
@@ 6,52 6,36 @@ package main
import (
	"fmt"
	"os"
	"sync"
	"syscall"
	"time"

	"git.sr.ht/~jcc/swaybar-commander/block"
	"git.sr.ht/~jcc/swaybar-commander/config"
	"git.sr.ht/~jcc/swaybar-commander/proto"
	"github.com/bep/debounce"
)

const sigrtmin = 34
	"git.sr.ht/~jcc/swaybar-commander/commands"
)

func main() {
	conf, err := config.Load()
	if err != nil {
		fmt.Fprintf(os.Stderr, "failed to load config: %v", err)
		os.Exit(1)
	if len(os.Args) == 1 {
		err := commands.Commander()
		if err != nil {
			fmt.Fprintln(os.Stderr, err)
			os.Exit(1)
		}

		return
	}

	blocks := make([]block.Block, len(conf.Blocks))
	latestPBlocks := make([]proto.Block, len(blocks))

	for i, blockConf := range conf.Blocks {
		proto.MergeBlocks(&blockConf.Block, conf.Defaults)
		blocks[i] = block.New(blockConf)
	}

	proto.Init()

	debounce := debounce.New(100 * time.Millisecond)
	wg := sync.WaitGroup{}

	for i := range blocks {
		wg.Add(1)
	if len(os.Args) >= 3 && os.Args[1] == "update" {
		var instance string
		if len(os.Args) == 4 {
			instance = os.Args[3]
		}

		go func(i int) {
			defer wg.Done()
			updateChan := make(chan proto.Block, 1)
			go blocks[i].Run(updateChan, syscall.Signal(sigrtmin+i+1))
		err := commands.UpdateBlock(os.Args[2], instance)
		if err != nil {
			fmt.Fprintln(os.Stderr, err)
			os.Exit(1)
		}

			for {
				latestPBlocks[i] = <-updateChan
				debounce(func() { proto.Send(latestPBlocks) })
			}
		}(i)
		return
	}

	wg.Wait()
	fmt.Fprintf(os.Stderr, "USAGE: %s [update <index>]|[update <name> [<instance>]]\n", os.Args[0])
	os.Exit(1)
}

A commands/commander.go => commands/commander.go +67 -0
@@ 0,0 1,67 @@
// Package commands implements swaybar-commander's CLI's commands.
package commands

import (
	"fmt"
	"sync"
	"syscall"
	"time"

	"git.sr.ht/~jcc/swaybar-commander/block"
	"git.sr.ht/~jcc/swaybar-commander/config"
	"git.sr.ht/~jcc/swaybar-commander/proto"
	"github.com/bep/debounce"
)

const sigrtmin = 34

// Commander runs the swaybar-commander.
func Commander() error {
	conf, err := config.Load()
	if err != nil {
		return fmt.Errorf("failed to load config: %v", err)
	}

	blocks := make([]block.Block, len(conf.Blocks))
	latestPBlocks := make([]proto.Block, len(blocks))
	blockRuntimes := make([]blockRuntime, len(blocks))

	for i, blockConf := range conf.Blocks {
		proto.MergeBlocks(&blockConf.Block, conf.Defaults)
		blocks[i] = block.New(blockConf)

		blockRuntimes[i] = blockRuntime{
			blockConf.Name,
			blockConf.Instance,
			syscall.Signal(sigrtmin + i + 1),
		}
	}

	proto.Init()

	debounce := debounce.New(100 * time.Millisecond)
	wg := sync.WaitGroup{}

	for i := range blocks {
		wg.Add(1)

		go func(i int) {
			defer wg.Done()
			updateChan := make(chan proto.Block, 1)
			go blocks[i].Run(updateChan, blockRuntimes[i].Signal)

			for {
				latestPBlocks[i] = <-updateChan
				debounce(func() { proto.Send(latestPBlocks) })
			}
		}(i)
	}

	err = writeRuntimeFile(blockRuntimes)
	if err != nil {
		return fmt.Errorf("failed to write runtime file: %v", err)
	}

	wg.Wait()
	return nil
}

A commands/runtime.go => commands/runtime.go +121 -0
@@ 0,0 1,121 @@
package commands

import (
	"encoding/json"
	"fmt"
	"io/ioutil"
	"os"
	"regexp"
	"strconv"
	"strings"
	"syscall"
)

type blockRuntime struct {
	Name     *string `json:",omitempty"`
	Instance *string `json:",omitempty"`
	Signal   syscall.Signal
}

func findBlockRuntime(runtimes []blockRuntime, indexOrName string, instance string) *blockRuntime {
	if instance == "" {
		index, err := strconv.Atoi(indexOrName)
		if err == nil && index >= 0 && index < len(runtimes) {
			return &runtimes[index]
		}
	}

	for _, runtime := range runtimes {
		if runtime.Name != nil && *runtime.Name == indexOrName {
			if instance == "" {
				return &runtime
			}

			if runtime.Instance != nil && *runtime.Instance == instance {
				return &runtime
			}
		}
	}

	return nil
}

func writeRuntimeFile(runtimes []blockRuntime) error {
	runtimeFile := getRuntimeFilePath()
	runtimeJson, err := json.Marshal(runtimes)
	if err != nil {
		return fmt.Errorf("failed to marshal block runtimes: %v", err)
	}

	err = os.WriteFile(runtimeFile, runtimeJson, 0600)
	if err != nil {
		return fmt.Errorf("failed to write block runtimes: %v", err)
	}

	// todo: delete file on exit
	return nil
}

func readRuntimeFile(path string) (*os.Process, []blockRuntime, error) {
	pid, err := strconv.Atoi(strings.Split(path, ".")[2])
	if err != nil {
		return nil, nil, fmt.Errorf("bad runtime file path: %s", path)
	}

	process, err := os.FindProcess(pid)
	if err != nil {
		fmt.Fprintf(os.Stderr, "dangling runtime file at %s\n", path)
		return nil, nil, nil
	}

	runtimeJson, err := ioutil.ReadFile(path)
	if err != nil {
		return nil, nil, fmt.Errorf("failed to read runtime file %s: %v", path, err)
	}

	var blockRuntimes []blockRuntime
	err = json.Unmarshal(runtimeJson, &blockRuntimes)
	if err != nil {
		return nil, nil, fmt.Errorf("failed to parse runtime file %s: %v", path, err)
	}

	return process, blockRuntimes, nil
}

func getRuntimeFileDir() string {
	runtimeDir, _ := os.LookupEnv("XDG_RUNTIME_DIR")
	if runtimeDir != "" {
		return runtimeDir
	}

	return "/tmp"
}

func getRuntimeFilePath() string {
	return fmt.Sprintf(
		"%s/swaybar-commander-block-runtimes.%d.%d.json",
		getRuntimeFileDir(),
		os.Getuid(),
		os.Getpid(),
	)
}

func getRuntimeFilePaths() []string {
	dir := getRuntimeFileDir()
	entries, err := os.ReadDir(dir)
	if err != nil {
		return []string{}
	}

	expr := fmt.Sprintf("swaybar-commander-block-runtimes\\.%d\\.[0-9]+\\.json", os.Getuid())
	re := regexp.MustCompile(expr)

	paths := []string{}
	for _, entry := range entries {
		if re.MatchString(entry.Name()) {
			paths = append(paths, dir+"/"+entry.Name())
		}
	}

	return paths
}

A commands/runtime_test.go => commands/runtime_test.go +134 -0
@@ 0,0 1,134 @@
package commands

import (
	"reflect"
	"syscall"
	"testing"
)

func TestFindBlockRuntime(t *testing.T) {
	n0, n1, n2 := "N0", "N1", "N2"
	i0, i1, i2 := "I0", "I1", "I2"
	negativeOne, two, three := "-1", "2", "3"

	cases := []struct {
		desc          string
		runtimes      []blockRuntime
		indexOrName   string
		instance      string
		expectedIndex int
	}{
		{"empty", []blockRuntime{}, "", "", -1},
		{
			"name and instance 1",
			[]blockRuntime{
				{&n0, &i0, syscall.Signal(0)},
				{&n1, &i1, syscall.Signal(0)},
				{&n2, &i2, syscall.Signal(0)},
			},
			"N1",
			"I1",
			1,
		},
		{
			"name and instance 2",
			[]blockRuntime{
				{&n1, &i0, syscall.Signal(0)},
				{&n1, &i1, syscall.Signal(0)},
				{&n1, &i2, syscall.Signal(0)},
			},
			"N1",
			"I2",
			2,
		},
		{
			"name only, defined instances",
			[]blockRuntime{
				{&n1, &i0, syscall.Signal(0)},
				{&n1, &i1, syscall.Signal(0)},
				{&n1, &i2, syscall.Signal(0)},
			},
			"N1",
			"",
			0,
		},
		{
			"name only, nil instance",
			[]blockRuntime{
				{&n1, &i0, syscall.Signal(0)},
				{&n1, nil, syscall.Signal(0)},
				{&n1, &i2, syscall.Signal(0)},
			},
			"N1",
			"",
			0,
		},
		{
			"name only, all nil instance",
			[]blockRuntime{
				{&n1, nil, syscall.Signal(0)},
				{&n1, nil, syscall.Signal(0)},
				{&n1, nil, syscall.Signal(0)},
			},
			"N1",
			"",
			0,
		},
		{
			"name and instance, all same name",
			[]blockRuntime{
				{&n1, &i0, syscall.Signal(0)},
				{&n1, &i1, syscall.Signal(0)},
				{&n1, &i2, syscall.Signal(0)},
			},
			"N1",
			"I1",
			1,
		},
		{
			"index in range",
			[]blockRuntime{
				{&n0, &i0, syscall.Signal(0)},
				{&two, &i1, syscall.Signal(0)},
				{&n1, &i2, syscall.Signal(0)},
			},
			"2",
			"",
			2,
		},
		{
			"index below range",
			[]blockRuntime{
				{&n1, &i0, syscall.Signal(0)},
				{&n1, &i1, syscall.Signal(0)},
				{&negativeOne, &i2, syscall.Signal(0)},
			},
			"-1",
			"",
			2,
		},
		{
			"index above range",
			[]blockRuntime{
				{&n1, &i0, syscall.Signal(0)},
				{&three, &i1, syscall.Signal(0)},
				{&n1, &i2, syscall.Signal(0)},
			},
			"3",
			"",
			1,
		},
	}

	for _, c := range cases {
		actual := findBlockRuntime(c.runtimes, c.indexOrName, c.instance)

		if c.expectedIndex == -1 {
			if actual != nil {
				t.Errorf("%v: want nil, got %v", c.desc, *actual)
			}
		} else if !reflect.DeepEqual(*actual, c.runtimes[c.expectedIndex]) {
			t.Errorf("%v: want %v, got %v", c.desc, c.runtimes[c.expectedIndex], *actual)
		}
	}
}

A commands/updateBlock.go => commands/updateBlock.go +45 -0
@@ 0,0 1,45 @@
package commands

import (
	"fmt"
	"os"
)

// UpdateBlock requests that all running instances of swaybar-commander update
// the specified block. If indexOrName can be parsed as a number and is a valid
// index for the blocks, then the block with that index is updated. Otherwise,
// the first block with name indexOrName and instance instance is updated.
func UpdateBlock(indexOrName string, instance string) error {
	runtimePaths := getRuntimeFilePaths()
	if len(runtimePaths) == 0 {
		return fmt.Errorf("no instances of swaybar-commander appear to be running")
	}

	signalled := false

	for _, path := range runtimePaths {
		process, blockRuntimes, err := readRuntimeFile(path)
		if err != nil {
			return fmt.Errorf("failed to read runtime file %s: %v", path, err)
		}

		if process == nil {
			continue
		}

		runtime := findBlockRuntime(blockRuntimes, indexOrName, instance)
		if runtime == nil {
			fmt.Fprintf(os.Stderr, "No matching block found in pid %d\n", process.Pid)
			continue
		}

		process.Signal(runtime.Signal)
		signalled = true
	}

	if !signalled {
		return fmt.Errorf("no block matched")
	}

	return nil
}

M swaybar-commander.1.scd => swaybar-commander.1.scd +24 -3
@@ 5,12 5,33 @@ swaybar-commander(1)
swaybar-commander - swaybar command for *sway*(1) using the
*swaybar-protocol*(7).

# SYNOPSIS

*swaybar-commander*++
*swaybar-commander* update <index>++
*swaybar-commander* update <name> [<instance>]

# DESCRIPTION

swaybar-commander allows defining multiple sections of the swaybar, each of
which updates at its own frequency. To use it, you'll need to create a config
file, as described below, and then set *swaybar-commander* as your
*swaybar_command* (see *sway-bar*(5)).
which updates at its own frequency. Updates can also be requested using
*swaybar-commander update ...*, as described below.

To use it, you'll need to create a config file, as described below, and then set
*swaybar-commander* as your *swaybar_command* (see *sway-bar*(5)).

# REQUESTING UPDATES

To request an update to a single block, use *swaybar-commander update ...*. If
passed a valid index, i.e. an integer between 0 and the number of blocks minus
one, the block at that position will be updated. If passed a name only, the
first block with that *Name* (defined in the config) will be updated. If passed
a name and an instance, the first block with that *Name* and *Instance* (defined
in the config) will be updated.

Note that "updating" a block means that it will re-run its *Cmd*, and if the
output has changed, swaybar-commander will output a new element to be rendered
by the bar.

# CONFIGURATION