diff --git a/README.md b/README.md index db005d3..0b88299 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,6 @@ Yet Another i3status replacement written in Go. [![GitHub release](https://img.shields.io/github/release/burik666/yagostatus.svg)](https://github.com/burik666/yagostatus) [![GitHub license](https://img.shields.io/github/license/burik666/yagostatus.svg)](https://github.com/burik666/yagostatus/blob/master/LICENSE) -[![Codacy Badge](https://api.codacy.com/project/badge/Grade/fb1e1cbd0987425783c6ae30d5c5a833)](https://app.codacy.com/app/burik666/yagostatus?utm_source=github.com&utm_medium=referral&utm_content=burik666/yagostatus&utm_campaign=badger) -[![Twitter](https://img.shields.io/twitter/url/https/github.com/burik666/yagostatus.svg?style=social)](https://twitter.com/intent/tweet?text=Yet%20Another%20i3status%20replacement%20written%20in%20Go.%0A&url=https%3A%2F%2Fgithub.com%2Fburik666%2Fyagostatus&hashtags=i3,i3wm,i3status,golang) [![yagostatus.gif](https://raw.githubusercontent.com/wiki/burik666/yagostatus/yagostatus.gif)](https://github.com/burik666/yagostatus/wiki/Conky) @@ -15,26 +13,22 @@ Yet Another i3status replacement written in Go. - Shell scripting widgets and events handlers. - Wrapping other status programs (i3status, py3status, conky, etc.). - Different widgets on different workspaces. +- [Snippets](https://github.com/burik666/ygs-snippets). - Templates for widgets outputs. - Update widget via http/websocket requests. +- Update widget by POSIX Real-Time Signals (SIGRTMIN-SIGRTMAX). ## Installation go get github.com/burik666/yagostatus cp $GOPATH/src/github.com/burik666/yagostatus/yagostatus.yml ~/.config/i3/yagostatus.yml -Get the absolute path to the yagostatus binary: - - $ echo $GOPATH/bin/yagostatus - /home/burik/go/bin/yagostatus - - -Replace `status_command` to `~/go/bin/yagostatus` in your i3 config file. +Replace `status_command` to `~/go/bin/yagostatus --config ~/.config/i3/yagostatus.yml` in your i3 config file. ### Troubleshooting Yagostatus outputs error messages in stderr, you can log them by redirecting stderr to a file. -`status_command ~/go/bin/yagostatus --config ~/.config/i3/yagostatus.yml 2> /tmp/yagostatus.log` +`status_command exec ~/go/bin/yagostatus --config ~/.config/i3/yagostatus.yml 2> /tmp/yagostatus.log` ## Configuration @@ -60,12 +54,12 @@ widgets: - widget: clock format: Jan _2 Mon 15:04:05 # https://golang.org/pkg/time/#Time.Format - template: > - { + templates: > + [{ "color": "#ffffff", "separator": true, - "separator_block_width": 20 - } + "separator_block_width": 21 + }] ``` ## Widgets @@ -100,16 +94,19 @@ Example: ] ``` -- `template` - The template that is applied to the output of the widget. +- `templates` - The templates that apply to widget blocks. - `events` - List of commands to be executed on user actions. * `button` - X11 button ID (0 for any, 1 to 3 for left/middle/right mouse button. 4/5 for mouse wheel up/down. Default: `0`). * `modifiers` - List of X11 modifiers condition. * `command` - Command to execute (via `sh -c`). Сlick_event json will be written to stdin. Also env variables are available: `$I3_NAME`, `$I3_INSTANCE`, `$I3_BUTTON`, `$I3_MODIFIERS`, `$I3_X`, `$I3_Y`, `$I3_RELATIVE_X`, `$I3_RELATIVE_Y`, `$I3_WIDTH`, `$I3_HEIGHT`, `$I3_MODIFIERS`. - * `output` - If `true` widget text will be replaced with the command output (default: `false`). + The clicked widget fields are available as ENV variables with the prefix `I3_` (example:` $ I3_full_text`). + * `workdir` - Set a working directory. + * `output_format` - The command output format (none, text, json, auto) (default: `none`). * `name` - Filter by `name` for widgets with multiple blocks (default: empty). * `instance` - Filter by `instance` for widgets with multiple blocks (default: empty). + * `override` - If `true`, previously defined events with the same `button`, `modifier`, `name` and `instance` will be ignored (default: `false`) Example: ```yml @@ -125,10 +122,15 @@ Example: "name": "ch" } ] - template: > - { - "color": "#0000ff" - } + templates: > + [ + { + "color": "#ff8000" + }, + { + "color": "#ff3030" + } + ] events: - button: 1 command: /usr/bin/firefox @@ -146,6 +148,33 @@ Example: name: ch ``` +### Snippets + +Yagostatus supports the inclusion of snippets from files. +```yml + - widget: $ygs-snippets/snip.yaml + msg: hello world + color: #00ff00 +``` + +`ygs-snippets/snip.yaml`: +```yml +variables: + msg: "default messsage" + color: #ffffff +widgets: + - widget: static + blocks: > + [ + { + "full_text": "message: ${msg}", + "color": "${color}" + } + ] +``` + +`ygs-snippets/snip.yaml` - relative path from the current file. + ### Widget `clock` @@ -160,8 +189,20 @@ The clock widget returns the current time in the specified format. This widget runs the command at the specified interval. - `command` - Command to execute (via `sh -c`). -- `interval` - Update interval in seconds (set 0 to run once at start). +- `workdir` - Set a working directory. +- `interval` - Update interval in seconds (`0` to run once at start; `-1` for loop without delay; default: `0`). +- `retry` - Retry interval in seconds if command failed (default: none). +- `silent` - Don't show error widget if command failed (default: `false`). - `events_update` - Update widget if an event occurred (default: `false`). +- `output_format` - The command output format (none, text, json, auto) (default: `auto`). +- `signal` - SIGRTMIN offset to update widget. Should be between 0 and `SIGRTMIN`-`SIGRTMAX`. + +The current widget fields are available as ENV variables with the prefix `I3_` (example: `$I3_full_text`). +For widgets with multiple blocks, an suffix with an index will be added. (example: `$I3_full_text`, `$I3_full_text_1`, `$I3_full_text_2`, etc.) + +Use pkill to send signals: + + pkill -SIGRTMIN+1 yagostatus ### Widget `wrapper` @@ -170,6 +211,7 @@ The wrapper widget starts the command and proxy received blocks (and click_event See: https://i3wm.org/docs/i3bar-protocol.html - `command` - Command to execute. +- `workdir` - Set a working directory. ### Widget `static` @@ -183,22 +225,65 @@ The static widget renders the blocks. Useful for labels and buttons. The http widget starts http server and accept HTTP or Websocket requests. -- `listen` - Address and port for binding (example: `localhost:9900`). +- `network` - `tcp` or `unix` (default `tcp`). +- `listen` - Hostname and port or path to the socket file to bind (example: `localhost:9900`, `/tmp/yagostatus.sock`). - `path` - Path for receiving requests (example: `/mystatus/`). Must be unique for multiple widgets with same `listen`. For example, you can update the widget with the following command: - curl http://localhost:9900/mystatus/ -d '[{"full_text": "hello"}, {"full_text": "world"}]' + curl http://localhost:9900/mystatus/ -d '[{"full_text": "hello"}, {"full_text": "world"}]' Send an empty array to clear: - curl http://localhost:9900/mystatus/ -d '[]' + curl http://localhost:9900/mystatus/ -d '[]' + +Unix socket: + + curl --unix-socket /tmp/yagostatus.sock localhost/mystatus/ -d '[{"full_text": "hello"}]' ## Examples +### Counter + +This example shows how you can use custom fields. + +- Left mouse button - increment +- Right mouse button - decrement +- Middle mouse button - reset + +```yml + - widget: static + blocks: > + [ + { + "full_text":"COUNTER" + } + ] + events: + - command: | + printf '[{"full_text":"Counter: %d", "_count":%d}]' $((I3__COUNT + 1)) $((I3__COUNT + 1)) + output_format: json + button: 1 + - command: | + printf '[{"full_text":"Counter: %d", "_count":%d}]' $((I3__COUNT - 1)) $((I3__COUNT - 1)) + output_format: json + button: 3 + - command: | + printf '[{"full_text":"Counter: 0", "_count":0}]' + output_format: json + button: 2 +``` + ### Volume control +i3 config: +``` +bindsym XF86AudioLowerVolume exec amixer -c 1 -q set Master 1%-; exec pkill -SIGRTMIN+1 yagostatus +bindsym XF86AudioRaiseVolume exec amixer -c 1 -q set Master 1%+; exec pkill -SIGRTMIN+1 yagostatus +bindsym XF86AudioMute exec amixer -q set Master toggle; exec pkill -SIGRTMIN+1 yagostatus +``` + ```yml - widget: exec command: | @@ -210,6 +295,7 @@ Send an empty array to clear: echo -e '[{"full_text": "\xF0\x9F\x94\x8A '${res[0]}'", "color": "'$color'"}]' interval: 0 + signal: 1 events_update: true events: - button: 1 @@ -221,12 +307,12 @@ Send an empty array to clear: - button: 5 command: amixer -q set Master 3%- - template: > - { + templates: > + [{ "markup": "pango", "separator": true, - "separator_block_width": 20 - } + "separator_block_width": 21 + }] ``` ### Weather @@ -249,11 +335,11 @@ Requires [jq](https://stedolan.github.com/jq/) for json parsing. - widget: exec command: curl -s 'http://api.openweathermap.org/data/2.5/weather?q=London,uk&units=metric&appid='|jq .main.temp interval: 300 - template: > - { + templates: > + [{ "separator": true, - "separator_block_width": 20 - } + "separator_block_width": 21 + }] ``` ### Conky @@ -308,16 +394,16 @@ conky.text = [[ { ${lua_parse cpu cpu1} , "min_width": "100%", "align": "right", "separator": false }, { ${lua_parse cpu cpu2} , "min_width": "100%", "align": "right", "separator": false }, { ${lua_parse cpu cpu3} , "min_width": "100%", "align": "right", "separator": false }, -{ ${lua_parse cpu cpu4} , "min_width": "100%", "align": "right", "separator": true, "separator_block_width":20 }, +{ ${lua_parse cpu cpu4} , "min_width": "100%", "align": "right", "separator": true, "separator_block_width":21 }, { "full_text": "RAM:", "color": "\#2e9ef4", "separator": false }, -{ "full_text": "${mem} / ${memeasyfree}", "color": ${if_match ${memperc}<80}"\#ffffff"${else}"\#ff0000"${endif}, "separator": true, "separator_block_width":20 }, +{ "full_text": "${mem} / ${memeasyfree}", "color": ${if_match ${memperc}<80}"\#ffffff"${else}"\#ff0000"${endif}, "separator": true, "separator_block_width":21 }, { "full_text": "sda:", "color": "\#2e9ef4", "separator": false }, -{ "full_text": "▼ ${diskio_read sda} ▲ ${diskio_write sda}", "color": "\#ffffff", "separator": true, "separator_block_width":20 }, +{ "full_text": "▼ ${diskio_read sda} ▲ ${diskio_write sda}", "color": "\#ffffff", "separator": true, "separator_block_width":21 }, { "full_text": "eth0:", "color": "\#2e9ef4", "separator": false }, -{ "full_text": "▼ ${downspeed eth0} ▲ ${upspeed eth0}", "color": "\#ffffff", "separator": true, "separator_block_width":20 } +{ "full_text": "▼ ${downspeed eth0} ▲ ${upspeed eth0}", "color": "\#ffffff", "separator": true, "separator_block_width":21 } ] diff --git a/go.mod b/go.mod index 270d591..00c75d5 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,11 @@ module github.com/burik666/yagostatus +go 1.14 + require ( - github.com/pkg/errors v0.8.1 - go.i3wm.org/i3 v0.0.0-20181105220049-e2468ef5e1cd - golang.org/x/net v0.0.0-20181102091132-c10e9556a7bc - gopkg.in/yaml.v2 v2.2.1 + github.com/BurntSushi/xgb v0.0.0-20200324125942-20f126ea2843 // indirect + github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046 // indirect + go.i3wm.org/i3/v4 v4.18.0 + golang.org/x/net v0.0.0-20200513185701-a91f0712d120 + gopkg.in/yaml.v2 v2.3.0 ) diff --git a/go.sum b/go.sum index eb17012..09729c7 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,18 @@ -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802 h1:1BDTz0u9nC3//pOCMdNH+CiXJVYJh5UQNCOBG7jbELc= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/BurntSushi/xgbutil v0.0.0-20160919175755-f7c97cef3b4e h1:4ZrkT/RzpnROylmoQL57iVUL57wGKTR5O6KpVnbm2tA= +github.com/BurntSushi/xgb v0.0.0-20200324125942-20f126ea2843 h1:3iF31c7rp7nGZVDv7YQ+VxOgpipVfPKotLXykjZmwM8= +github.com/BurntSushi/xgb v0.0.0-20200324125942-20f126ea2843/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/BurntSushi/xgbutil v0.0.0-20160919175755-f7c97cef3b4e/go.mod h1:uw9h2sd4WWHOPdJ13MQpwK5qYWKYDumDqxWWIknEQ+k= +github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046 h1:O/r2Sj+8QcMF7V5IcmiE2sMFV2q3J47BEirxbXJAdzA= +github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046/go.mod h1:uw9h2sd4WWHOPdJ13MQpwK5qYWKYDumDqxWWIknEQ+k= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -go.i3wm.org/i3 v0.0.0-20181105220049-e2468ef5e1cd h1:PVrHRo3MP4x/+tbG01rmFnp1sJ1GC7laHJ56OedXYp0= -go.i3wm.org/i3 v0.0.0-20181105220049-e2468ef5e1cd/go.mod h1:7w17+r1F28Yxb1pHu8SxARJo+RGvJCAGOEf+GLw+2aQ= -golang.org/x/net v0.0.0-20180911220305-26e67e76b6c3 h1:czFLhve3vsQetD6JOJ8NZZvGQIXlnN3/yXxbT6/awxI= -golang.org/x/net v0.0.0-20180911220305-26e67e76b6c3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181102091132-c10e9556a7bc h1:ZMCWScCvS2fUVFw8LOpxyUUW5qiviqr4Dg5NdjLeiLU= +go.i3wm.org/i3/v4 v4.18.0 h1:yV9SCpYeyTqEuele2bw7rLmEfrGCRlid9enTqLjPuG0= +go.i3wm.org/i3/v4 v4.18.0/go.mod h1:FN6trxp2TXmfzQVa6JSKq5IJx/XUJsnPFKAz15cBWtw= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/net v0.0.0-20181102091132-c10e9556a7bc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 9e84eee..b6484f9 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -1,71 +1,28 @@ package config import ( - "encoding/json" "io/ioutil" - "strings" + "os" + "path/filepath" + "syscall" "github.com/burik666/yagostatus/ygs" - - "github.com/pkg/errors" - "gopkg.in/yaml.v2" ) // Config represents the main configuration. type Config struct { - Widgets []WidgetConfig `yaml:"widgets"` -} - -// WidgetConfig represents a widget configuration. -type WidgetConfig struct { - Name string `yaml:"widget"` - Workspaces []string `yaml:"workspaces"` - Template ygs.I3BarBlock `yaml:"-"` - Events []WidgetEventConfig `yaml:"events"` - - Params map[string]interface{} -} - -// Validate checks widget configuration. -func (c WidgetConfig) Validate() error { - if c.Name == "" { - return errors.New("Missing widget name") - } - for _, e := range c.Events { - if err := e.Validate(); err != nil { - return err - } - } - return nil -} - -// WidgetEventConfig represents a widget events. -type WidgetEventConfig struct { - Command string `yaml:"command"` - Button uint8 `yaml:"button"` - Modifiers []string `yaml:"modifiers,omitempty"` - Name string `yaml:"name,omitempty"` - Instance string `yaml:"instance,omitempty"` - Output bool `yaml:"output,omitempty"` + Signals struct { + StopSignal syscall.Signal `yaml:"stop"` + ContSignal syscall.Signal `yaml:"cont"` + } `yaml:"signals"` + Variables map[string]interface{} `yaml:"variables"` + Widgets []ygs.WidgetConfig `yaml:"widgets"` } -// Validate checks event parameters. -func (e WidgetEventConfig) Validate() error { - var availableWidgetEventModifiers = [...]string{"Shift", "Control", "Mod1", "Mod2", "Mod3", "Mod4", "Mod5"} - for _, mod := range e.Modifiers { - found := false - mod = strings.TrimLeft(mod, "!") - for _, m := range availableWidgetEventModifiers { - if mod == m { - found = true - break - } - } - if !found { - return errors.Errorf("Unknown '%s' modifier", mod) - } - } - return nil +// SnippetConfig represents the snippet configuration +type SnippetConfig struct { + Variables map[string]interface{} `yaml:"variables"` + Widgets []ygs.WidgetConfig `yaml:"widgets"` } // LoadFile loads and parses config from file. @@ -74,45 +31,15 @@ func LoadFile(filename string) (*Config, error) { if err != nil { return nil, err } - return Parse(data) + + return parse(data, filepath.Dir(filename), filepath.Base(filename)) } // Parse parses config. -func Parse(data []byte) (*Config, error) { - - var raw struct { - Widgets []map[string]interface{} `yaml:"widgets"` - } - - config := Config{} - if err := yaml.Unmarshal(data, &config); err != nil { - return nil, err - } - - if err := yaml.Unmarshal(data, &raw); err != nil { +func Parse(data []byte, source string) (*Config, error) { + wd, err := os.Getwd() + if err != nil { return nil, err } - - for widgetIndex := range config.Widgets { - widget := &config.Widgets[widgetIndex] - params := raw.Widgets[widgetIndex] - - tpl, ok := params["template"] - if ok { - if err := json.Unmarshal([]byte(tpl.(string)), &widget.Template); err != nil { - return nil, err - } - } - - widget.Params = params - if err := widget.Validate(); err != nil { - return nil, err - } - - delete(params, "widget") - delete(params, "workspaces") - delete(params, "template") - delete(params, "events") - } - return &config, nil + return parse(data, wd, source) } diff --git a/internal/pkg/config/parser.go b/internal/pkg/config/parser.go new file mode 100644 index 0000000..0ea0c73 --- /dev/null +++ b/internal/pkg/config/parser.go @@ -0,0 +1,306 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "path/filepath" + "reflect" + "sort" + "strconv" + "strings" + "syscall" + + "github.com/burik666/yagostatus/ygs" + + "gopkg.in/yaml.v2" +) + +func parse(data []byte, workdir string, source string) (*Config, error) { + + config := Config{} + config.Signals.StopSignal = syscall.SIGUSR1 + config.Signals.ContSignal = syscall.SIGCONT + + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, trimYamlErr(err, false) + } + + for wi := range config.Widgets { + config.Widgets[wi].File = source + config.Widgets[wi].Index = wi + } + + dict := make(map[string]string, len(config.Variables)) + for k, v := range config.Variables { + vb, err := json.Marshal(v) + if err != nil { + return nil, err + } + + var vraw ygs.Vary + + err = json.Unmarshal(vb, &vraw) + if err != nil { + return nil, err + } + + dict[fmt.Sprintf("${%s}", k)] = strings.TrimRight(vraw.String(), "\n") + } + + v := reflect.ValueOf(config.Widgets) + replaceRecursive(&v, dict) + +WIDGET: + for wi := 0; wi < len(config.Widgets); wi++ { + widget := &config.Widgets[wi] + + params := make(map[string]interface{}) + for k, v := range config.Widgets[wi].Params { + params[strings.ToLower(k)] = v + } + + config.Widgets[wi].Params = params + + if widget.WorkDir == "" { + widget.WorkDir = workdir + } + + for i := range widget.Events { + if widget.Events[i].WorkDir == "" { + widget.Events[i].WorkDir = workdir + } + } + + // for backward compatibility + if itpl, ok := params["template"]; ok { + tpl, ok := itpl.(string) + if !ok { + setError(widget, fmt.Errorf("invalid template"), false) + + continue WIDGET + } + + widget.Templates = append(widget.Templates, ygs.I3BarBlock{}) + if err := json.Unmarshal([]byte(tpl), &widget.Templates[0]); err != nil { + setError(widget, err, false) + + continue WIDGET + } + + delete(params, "template") + } + + if itpls, ok := params["templates"]; ok { + tpls, ok := itpls.(string) + if !ok { + setError(widget, fmt.Errorf("invalid templates"), false) + + continue WIDGET + } + + if err := json.Unmarshal([]byte(tpls), &widget.Templates); err != nil { + setError(widget, err, false) + + continue WIDGET + } + + delete(params, "templates") + } + + ok, err := parseSnippet(&config, wi, params) + if err != nil { + setError(widget, err, false) + + continue WIDGET + } + + if ok { + wi-- + continue WIDGET + } + + if err := widget.Validate(); err != nil { + setError(widget, err, true) + + continue WIDGET + } + } + + return &config, nil +} + +func parseSnippet(config *Config, wi int, params map[string]interface{}) (bool, error) { + widget := config.Widgets[wi] + + if len(widget.Name) > 0 && widget.Name[0] == '$' { + for i := range widget.IncludeStack { + if widget.Name == widget.IncludeStack[i] { + stack := append(widget.IncludeStack, widget.Name) + + return false, fmt.Errorf("recursive include: '%s'", strings.Join(stack, " -> ")) + } + } + + wd := widget.WorkDir + + filename := widget.Name[1:] + if !filepath.IsAbs(filename) { + filename = wd + "/" + filename + } + + data, err := ioutil.ReadFile(filename) + if err != nil { + return false, err + } + + var snippetConfig SnippetConfig + if err := yaml.Unmarshal(data, &snippetConfig); err != nil { + return false, err + } + + for k, v := range snippetConfig.Variables { + if _, ok := params[k]; !ok { + params[k] = v + } + } + + dict := make(map[string]string, len(params)) + for k, v := range params { + if _, ok := snippetConfig.Variables[k]; !ok { + return false, fmt.Errorf("unknown variable '%s'", k) + } + + vb, err := json.Marshal(v) + if err != nil { + return false, err + } + + var vraw ygs.Vary + + err = json.Unmarshal(vb, &vraw) + if err != nil { + return false, err + } + + dict[fmt.Sprintf("${%s}", k)] = strings.TrimRight(vraw.String(), "\n") + } + + v := reflect.ValueOf(snippetConfig.Widgets) + replaceRecursive(&v, dict) + + tpls, _ := json.Marshal(widget.Templates) + + wd = filepath.Dir(filename) + for i := range snippetConfig.Widgets { + snippetConfig.Widgets[i].WorkDir = wd + snippetConfig.Widgets[i].File = filename + snippetConfig.Widgets[i].Index = i + snippetConfig.Widgets[i].IncludeStack = append(widget.IncludeStack, widget.Name) + json.Unmarshal(tpls, &snippetConfig.Widgets[i].Templates) + + snipEvents := snippetConfig.Widgets[i].Events + for i := range snipEvents { + if snipEvents[i].WorkDir == "" { + snipEvents[i].WorkDir = wd + } + } + + for _, e := range widget.Events { + if e.Override { + sort.Strings(e.Modifiers) + + ne := make([]ygs.WidgetEventConfig, 0, len(snipEvents)) + + for _, se := range snipEvents { + sort.Strings(se.Modifiers) + + if e.Button == se.Button && + e.Name == se.Name && + e.Instance == se.Instance && + reflect.DeepEqual(e.Modifiers, se.Modifiers) { + + continue + } + + ne = append(ne, se) + } + snipEvents = append(ne, e) + } else { + snipEvents = append(snipEvents, e) + } + } + + snippetConfig.Widgets[i].Events = snipEvents + } + + config.Widgets = append(config.Widgets[:wi], config.Widgets[wi+1:]...) + config.Widgets = append(config.Widgets[:wi], append(snippetConfig.Widgets, config.Widgets[wi:]...)...) + + return true, nil + } + return false, nil +} + +func setError(widget *ygs.WidgetConfig, err error, trimLineN bool) { + *widget = ygs.ErrorWidget(trimYamlErr(err, trimLineN).Error()) +} + +func trimYamlErr(err error, trimLineN bool) error { + msg := strings.TrimPrefix(err.Error(), "yaml: ") + msg = strings.TrimPrefix(msg, "unmarshal errors:\n ") + if trimLineN { + msg = strings.TrimPrefix(msg, "line ") + msg = strings.TrimLeft(msg, "1234567890: ") + } + + return errors.New(msg) +} + +func replaceRecursive(v *reflect.Value, dict map[string]string) { + vv := *v + for vv.Kind() == reflect.Ptr || vv.Kind() == reflect.Interface { + vv = vv.Elem() + } + + switch vv.Kind() { + case reflect.Slice, reflect.Array: + for i := 0; i < vv.Len(); i++ { + vi := vv.Index(i) + replaceRecursive(&vi, dict) + } + case reflect.Map: + for _, i := range vv.MapKeys() { + vm := vv.MapIndex(i) + replaceRecursive(&vm, dict) + vv.SetMapIndex(i, vm) + } + case reflect.Struct: + t := vv.Type() + for i := 0; i < t.NumField(); i++ { + vf := v.Field(i) + replaceRecursive(&vf, dict) + } + case reflect.String: + st := vv.String() + for s, r := range dict { + st = strings.ReplaceAll(st, s, r) + } + + if n, err := strconv.ParseInt(st, 10, 64); err == nil { + vi := reflect.New(reflect.ValueOf(n).Type()).Elem() + vi.SetInt(n) + *v = vi + return + } + + if vv.CanSet() { + vv.SetString(st) + } else { + vn := reflect.New(vv.Type()).Elem() + vn.SetString(st) + *v = vn + } + } +} diff --git a/internal/pkg/executor/executor.go b/internal/pkg/executor/executor.go new file mode 100644 index 0000000..dce241d --- /dev/null +++ b/internal/pkg/executor/executor.go @@ -0,0 +1,216 @@ +package executor + +import ( + "bufio" + "bytes" + "encoding/json" + "io" + "io/ioutil" + "os" + "os/exec" + "regexp" + "strings" + "syscall" + + "github.com/burik666/yagostatus/internal/pkg/logger" + "github.com/burik666/yagostatus/ygs" +) + +type OutputFormat string + +const ( + OutputFormatAuto OutputFormat = "auto" + OutputFormatNone OutputFormat = "none" + OutputFormatText OutputFormat = "text" + OutputFormatJSON OutputFormat = "json" +) + +type Executor struct { + cmd *exec.Cmd + header *ygs.I3BarHeader +} + +func Exec(command string, args ...string) (*Executor, error) { + r := regexp.MustCompile("'.+'|\".+\"|\\S+") + m := r.FindAllString(command, -1) + name := m[0] + args = append(m[1:], args...) + + e := &Executor{} + + e.cmd = exec.Command(name, args...) + e.cmd.Env = os.Environ() + e.cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + Pgid: 0, + } + + return e, nil +} + +func (e *Executor) SetWD(wd string) { + if e.cmd != nil { + e.cmd.Dir = wd + } +} + +func (e *Executor) Run(logger logger.Logger, c chan<- []ygs.I3BarBlock, format OutputFormat) error { + stderr, err := e.cmd.StderrPipe() + if err != nil { + return err + } + + go (func() { + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + logger.Errorf("(stderr) %s", scanner.Text()) + } + })() + + defer stderr.Close() + + stdout, err := e.cmd.StdoutPipe() + if err != nil { + return err + } + + defer stdout.Close() + + if err := e.cmd.Start(); err != nil { + return err + } + + defer e.Wait() + + if format == OutputFormatNone { + return nil + } + + buf := &bufferCloser{} + outreader := io.TeeReader(stdout, buf) + + decoder := json.NewDecoder(outreader) + + var firstMessage interface{} + + err = decoder.Decode(&firstMessage) + if (err != nil) && format == OutputFormatJSON { + buf.Close() + + return err + } + + isJSON := false + switch firstMessage.(type) { + case map[string]interface{}: + isJSON = true + case []interface{}: + isJSON = true + } + + if err != nil || !isJSON || format == OutputFormatText { + _, err := io.Copy(ioutil.Discard, outreader) + if err != nil { + return err + } + + if buf.Len() > 0 { + c <- []ygs.I3BarBlock{ + { + FullText: strings.Trim(buf.String(), "\n "), + }, + } + } + + buf.Close() + + return nil + } + + buf.Close() + + firstMessageData, _ := json.Marshal(firstMessage) + + headerDecoder := json.NewDecoder(bytes.NewBuffer(firstMessageData)) + headerDecoder.DisallowUnknownFields() + + var header ygs.I3BarHeader + if err := headerDecoder.Decode(&header); err == nil { + e.header = &header + + _, err := decoder.Token() + if err != nil { + return err + } + } else { + var blocks []ygs.I3BarBlock + if err := json.Unmarshal(firstMessageData, &blocks); err != nil { + return err + } + c <- blocks + } + + for { + var blocks []ygs.I3BarBlock + if err := decoder.Decode(&blocks); err != nil { + if err == io.EOF { + return nil + } + + return err + } + c <- blocks + } +} + +func (e *Executor) Stdin() (io.WriteCloser, error) { + return e.cmd.StdinPipe() +} + +func (e *Executor) AddEnv(env ...string) { + e.cmd.Env = append(e.cmd.Env, env...) +} + +func (e *Executor) Wait() error { + if e.cmd != nil && e.cmd.Process != nil { + return e.cmd.Wait() + } + + return nil +} + +func (e *Executor) Signal(sig os.Signal) error { + if e.cmd != nil && e.cmd.Process != nil { + return e.cmd.Process.Signal(sig) + } + + return nil +} + +func (e *Executor) ProcessState() *os.ProcessState { + return e.cmd.ProcessState +} + +func (e *Executor) I3BarHeader() *ygs.I3BarHeader { + return e.header +} + +type bufferCloser struct { + bytes.Buffer + stoped bool +} + +func (b *bufferCloser) Write(p []byte) (n int, err error) { + if b.stoped { + return len(p), nil + } + + return b.Buffer.Write(p) +} + +func (b *bufferCloser) Close() error { + b.stoped = true + b.Reset() + + return nil +} diff --git a/internal/pkg/logger/looger.go b/internal/pkg/logger/looger.go new file mode 100644 index 0000000..ad65d9b --- /dev/null +++ b/internal/pkg/logger/looger.go @@ -0,0 +1,47 @@ +package logger + +import ( + "fmt" + "log" + "os" +) + +type Logger interface { + Infof(format string, v ...interface{}) + Errorf(format string, v ...interface{}) + Debugf(format string, v ...interface{}) + WithPrefix(prefix string) Logger +} + +func New(flags int) Logger { + return &stdLogger{ + std: log.New(os.Stderr, "", flags), + } +} + +type stdLogger struct { + std *log.Logger + prefix string +} + +func (l stdLogger) Outputf(calldepth int, subprefix string, format string, v ...interface{}) { + st := l.prefix + subprefix + fmt.Sprintf(format, v...) + l.std.Output(calldepth+1, st) +} + +func (l stdLogger) Infof(format string, v ...interface{}) { + l.Outputf(2, "INFO ", format, v...) +} + +func (l stdLogger) Errorf(format string, v ...interface{}) { + l.Outputf(2, "ERROR ", format, v...) +} + +func (l stdLogger) Debugf(format string, v ...interface{}) { + l.Outputf(2, "DEBUG", format, v...) +} + +func (l stdLogger) WithPrefix(prefix string) Logger { + l.prefix = prefix + " " + return &l +} diff --git a/internal/pkg/signals/signals.go b/internal/pkg/signals/signals.go new file mode 100644 index 0000000..805effc --- /dev/null +++ b/internal/pkg/signals/signals.go @@ -0,0 +1,15 @@ +package signals + +// #include +import "C" + +// SIGRTMIN signal +var SIGRTMIN int + +// SIGRTMAX signal +var SIGRTMAX int + +func init() { + SIGRTMIN = int(C.int(C.SIGRTMIN)) + SIGRTMAX = int(C.int(C.SIGRTMAX)) +} diff --git a/main.go b/main.go index 0359d5b..bb2422a 100644 --- a/main.go +++ b/main.go @@ -3,13 +3,13 @@ package main import ( "flag" - "fmt" "log" "os" "os/signal" "syscall" "github.com/burik666/yagostatus/internal/pkg/config" + "github.com/burik666/yagostatus/internal/pkg/logger" ) var builtinConfig = []byte(` @@ -29,64 +29,97 @@ widgets: command: /usr/bin/i3status - widget: clock format: Jan _2 Mon 15:04:05 # https://golang.org/pkg/time/#Time.Format - template: > - { + templates: > + [{ "color": "#ffffff", "separator": true, - "separator_block_width": 20 - } + "separator_block_width": 21 + }] `) func main() { - log.SetFlags(log.Ldate + log.Ltime + log.Lshortfile) + logger := logger.New(log.Ldate + log.Ltime + log.Lshortfile) var configFile string + flag.StringVar(&configFile, "config", "", `config file (default "yagostatus.yml")`) + versionFlag := flag.Bool("version", false, "print version information and exit") flag.Parse() if *versionFlag { - fmt.Printf("YaGoStatus %s\n", Version) + logger.Infof("YaGoStatus %s", Version) return } var cfg *config.Config + var cfgError, err error if configFile == "" { cfg, cfgError = config.LoadFile("yagostatus.yml") if os.IsNotExist(cfgError) { cfgError = nil - cfg, err = config.Parse(builtinConfig) + + cfg, err = config.Parse(builtinConfig, "builtin") if err != nil { - log.Fatalf("Failed to parse builtin config: %s", err) + logger.Errorf("Failed to parse builtin config: %s", err) + os.Exit(1) } } + + if cfgError != nil { + cfg = &config.Config{} + } } else { cfg, cfgError = config.LoadFile(configFile) if cfgError != nil { - log.Printf("Failed to load config file: %s", cfgError) cfg = &config.Config{} } } - yaGoStatus, err := NewYaGoStatus(*cfg) + yaGoStatus, err := NewYaGoStatus(*cfg, logger) if err != nil { - log.Fatalf("Failed to create yagostatus instance: %s", err) + logger.Errorf("Failed to create yagostatus instance: %s", err) + os.Exit(1) } + if cfgError != nil { + logger.Errorf("Failed to load config: %s", cfgError) yaGoStatus.errorWidget(cfgError.Error()) } - stopsignals := make(chan os.Signal, 1) - signal.Notify(stopsignals, syscall.SIGINT, syscall.SIGTERM) + stopContSignals := make(chan os.Signal, 1) + signal.Notify(stopContSignals, cfg.Signals.StopSignal, cfg.Signals.ContSignal) go func() { - yaGoStatus.Run() - stopsignals <- syscall.SIGTERM + for { + sig := <-stopContSignals + switch sig { + case cfg.Signals.StopSignal: + yaGoStatus.Stop() + case cfg.Signals.ContSignal: + yaGoStatus.Continue() + } + } + }() + + shutdownsignals := make(chan os.Signal, 1) + signal.Notify(shutdownsignals, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) + + go func() { + if err := yaGoStatus.Run(); err != nil { + logger.Errorf("Failed to run yagostatus: %s", err) + } + shutdownsignals <- syscall.SIGTERM }() - <-stopsignals - yaGoStatus.Stop() + <-shutdownsignals + + if err := yaGoStatus.Shutdown(); err != nil { + logger.Errorf("Failed to shutdown yagostatus: %s", err) + } + + logger.Infof("exit") } diff --git a/widgets/blank.go b/widgets/blank.go index 37b13d5..8bb237a 100644 --- a/widgets/blank.go +++ b/widgets/blank.go @@ -2,14 +2,22 @@ package widgets import ( + "github.com/burik666/yagostatus/internal/pkg/logger" "github.com/burik666/yagostatus/ygs" ) +// BlankWidgetParams are widget parameters. +type BlankWidgetParams struct{} + // BlankWidget is a widgets template. type BlankWidget struct{} +func init() { + ygs.RegisterWidget("blank", NewBlankWidget, BlankWidgetParams{}) +} + // NewBlankWidget returns a new BlankWidget. -func NewBlankWidget(params map[string]interface{}) (ygs.Widget, error) { +func NewBlankWidget(params interface{}, wlogger logger.Logger) (ygs.Widget, error) { return &BlankWidget{}, nil } @@ -19,11 +27,21 @@ func (w *BlankWidget) Run(c chan<- []ygs.I3BarBlock) error { } // Event processes the widget events. -func (w *BlankWidget) Event(event ygs.I3BarClickEvent) {} +func (w *BlankWidget) Event(event ygs.I3BarClickEvent, blocks []ygs.I3BarBlock) error { + return nil +} -// Stop shutdowns the widget. -func (w *BlankWidget) Stop() {} +// Stop stops the widdget. +func (w *BlankWidget) Stop() error { + return nil +} -func init() { - ygs.RegisterWidget("blank", NewBlankWidget) +// Continue continues the widdget. +func (w *BlankWidget) Continue() error { + return nil +} + +// Shutdown shutdowns the widget. +func (w *BlankWidget) Shutdown() error { + return nil } diff --git a/widgets/clock.go b/widgets/clock.go index 32ea8ed..6ac4f3a 100644 --- a/widgets/clock.go +++ b/widgets/clock.go @@ -1,60 +1,55 @@ package widgets import ( - "errors" "time" + "github.com/burik666/yagostatus/internal/pkg/logger" "github.com/burik666/yagostatus/ygs" ) +// ClockWidgetParams are widget parameters. +type ClockWidgetParams struct { + Interval uint + Format string +} + // ClockWidget implements a clock. type ClockWidget struct { - format string - interval time.Duration + BlankWidget + + params ClockWidgetParams } -// NewClockWidget returns a new ClockWidget. -func NewClockWidget(params map[string]interface{}) (ygs.Widget, error) { - w := &ClockWidget{} +func init() { + ygs.RegisterWidget("clock", NewClockWidget, ClockWidgetParams{ + Interval: 1, + Format: "Jan _2 Mon 15:04:05", + }) +} - v, ok := params["format"] - if !ok { - return nil, errors.New("missing 'format' setting") +// NewClockWidget returns a new ClockWidget. +func NewClockWidget(params interface{}, wlogger logger.Logger) (ygs.Widget, error) { + w := &ClockWidget{ + params: params.(ClockWidgetParams), } - w.format = v.(string) - v, ok = params["interval"] - if ok { - w.interval = time.Duration(v.(int)) * time.Second - } else { - w.interval = time.Second - } return w, nil } // Run starts the main loop. func (w *ClockWidget) Run(c chan<- []ygs.I3BarBlock) error { - ticker := time.NewTicker(w.interval) res := []ygs.I3BarBlock{ - ygs.I3BarBlock{}, - } - res[0].FullText = time.Now().Format(w.format) - c <- res - for { - select { - case t := <-ticker.C: - res[0].FullText = t.Format(w.format) - c <- res - } + {}, } -} + res[0].FullText = time.Now().Format(w.params.Format) -// Event processes the widget events. -func (w *ClockWidget) Event(event ygs.I3BarClickEvent) {} + c <- res -// Stop shutdowns the widget. -func (w *ClockWidget) Stop() {} + ticker := time.NewTicker(time.Duration(w.params.Interval) * time.Second) + for t := range ticker.C { + res[0].FullText = t.Format(w.params.Format) + c <- res + } -func init() { - ygs.RegisterWidget("clock", NewClockWidget) + return nil } diff --git a/widgets/exec.go b/widgets/exec.go index 69884a6..01dc47a 100644 --- a/widgets/exec.go +++ b/widgets/exec.go @@ -1,98 +1,238 @@ package widgets import ( - "encoding/json" "errors" - "log" + "fmt" "os" - "os/exec" - "strings" + "os/signal" + "sync" + "syscall" "time" + "github.com/burik666/yagostatus/internal/pkg/executor" + "github.com/burik666/yagostatus/internal/pkg/logger" + "github.com/burik666/yagostatus/internal/pkg/signals" "github.com/burik666/yagostatus/ygs" ) +// ExecWidgetParams are widget parameters. +type ExecWidgetParams struct { + Command string + Interval int + Retry *int + Silent bool + EventsUpdate bool `yaml:"events_update"` + Signal *int + OutputFormat executor.OutputFormat `yaml:"output_format"` + WorkDir string +} + // ExecWidget implements the exec widget. type ExecWidget struct { - command string - interval time.Duration - eventsUpdate bool - c chan<- []ygs.I3BarBlock + BlankWidget + + params ExecWidgetParams + + logger logger.Logger + + signal os.Signal + c chan<- []ygs.I3BarBlock + upd chan struct{} + tickerC *chan struct{} + env []string + + outputWG sync.WaitGroup +} + +func init() { + ygs.RegisterWidget("exec", NewExecWidget, ExecWidgetParams{}) } // NewExecWidget returns a new ExecWidget. -func NewExecWidget(params map[string]interface{}) (ygs.Widget, error) { - w := &ExecWidget{} +func NewExecWidget(params interface{}, wlogger logger.Logger) (ygs.Widget, error) { + w := &ExecWidget{ + params: params.(ExecWidgetParams), + logger: wlogger, + } - v, ok := params["command"] - if !ok { - return nil, errors.New("missing 'command' setting") + if len(w.params.Command) == 0 { + return nil, errors.New("missing 'command'") } - w.command = v.(string) - v, ok = params["interval"] - if !ok { - return nil, errors.New("missing 'interval' setting") + if w.params.Retry != nil && + *w.params.Retry > 0 && + w.params.Interval > 0 && + *w.params.Retry >= w.params.Interval { + return nil, errors.New("restart value should be less than interval") } - w.interval = time.Second * time.Duration(v.(int)) - v, ok = params["events_update"] - if ok { - w.eventsUpdate = v.(bool) - } else { - w.eventsUpdate = false + if w.params.Signal != nil { + sig := *w.params.Signal + if sig < 0 || signals.SIGRTMIN+sig > signals.SIGRTMAX { + return nil, fmt.Errorf("signal should be between 0 AND %d", signals.SIGRTMAX-signals.SIGRTMIN) + } + + w.signal = syscall.Signal(signals.SIGRTMIN + sig) } + w.upd = make(chan struct{}, 1) + w.upd <- struct{}{} + return w, nil } func (w *ExecWidget) exec() error { - cmd := exec.Command("sh", "-c", w.command) - cmd.Stderr = os.Stderr - output, err := cmd.Output() + exc, err := executor.Exec("sh", "-c", w.params.Command) if err != nil { return err } - var blocks []ygs.I3BarBlock - err = json.Unmarshal(output, &blocks) - if err != nil { - log.Printf("Failed to parse output: %s", err) - blocks = append(blocks, ygs.I3BarBlock{FullText: strings.Trim(string(output), "\n ")}) + exc.SetWD(w.params.WorkDir) + + exc.AddEnv(w.env...) + + c := make(chan []ygs.I3BarBlock) + + defer close(c) + + w.outputWG.Add(1) + go (func() { + defer w.outputWG.Done() + + for { + blocks, ok := <-c + if !ok { + return + } + w.c <- blocks + w.setEnv(blocks) + } + })() + + err = exc.Run(w.logger, c, w.params.OutputFormat) + if err == nil { + if state := exc.ProcessState(); state != nil && state.ExitCode() != 0 { + if w.params.Retry != nil { + go (func() { + time.Sleep(time.Second * time.Duration(*w.params.Retry)) + w.upd <- struct{}{} + w.resetTicker() + })() + } + + return fmt.Errorf("process exited unexpectedly: %s", state.String()) + } } - w.c <- blocks - return nil + return err } // Run starts the main loop. func (w *ExecWidget) Run(c chan<- []ygs.I3BarBlock) error { w.c = c - if w.interval == 0 { - return w.exec() + if w.params.Interval == 0 && w.signal == nil && w.params.Retry == nil { + err := w.exec() + if w.params.Silent { + if err != nil { + w.logger.Errorf("exec failed: %s", err) + } + + return nil + } + + return err + } + + if w.params.Interval > 0 { + w.resetTicker() } - ticker := time.NewTicker(w.interval) + if w.params.Interval == -1 { + go (func() { + for { + w.upd <- struct{}{} + } + })() + } + + if w.signal != nil { + sigc := make(chan os.Signal, 1) + signal.Notify(sigc, w.signal) + + go (func() { + for { + <-sigc + w.upd <- struct{}{} + } + })() + } - for ; true; <-ticker.C { + for range w.upd { err := w.exec() if err != nil { - return err + if !w.params.Silent { + w.outputWG.Wait() + + c <- []ygs.I3BarBlock{{ + FullText: err.Error(), + Color: "#ff0000", + }} + } + + w.logger.Errorf("exec failed: %s", err) } } + return nil } // Event processes the widget events. -func (w *ExecWidget) Event(event ygs.I3BarClickEvent) { - if w.eventsUpdate { - w.exec() +func (w *ExecWidget) Event(event ygs.I3BarClickEvent, blocks []ygs.I3BarBlock) error { + w.setEnv(blocks) + + if w.params.EventsUpdate { + w.upd <- struct{}{} } + + return nil } -// Stop shutdowns the widget. -func (w *ExecWidget) Stop() {} +func (w *ExecWidget) setEnv(blocks []ygs.I3BarBlock) { + env := make([]string, 0) -func init() { - ygs.RegisterWidget("exec", NewExecWidget) + for i, block := range blocks { + suffix := "" + if i > 0 { + suffix = fmt.Sprintf("_%d", i) + } + env = append(env, block.Env(suffix)...) + } + + w.env = env +} + +func (w *ExecWidget) resetTicker() { + if w.tickerC != nil { + *w.tickerC <- struct{}{} + } + + if w.params.Interval > 0 { + tickerC := make(chan struct{}, 1) + w.tickerC = &tickerC + + go (func() { + ticker := time.NewTicker(time.Duration(w.params.Interval) * time.Second) + + defer ticker.Stop() + + for { + select { + case <-tickerC: + return + case <-ticker.C: + w.upd <- struct{}{} + } + } + })() + } } diff --git a/widgets/http.go b/widgets/http.go index 4a01a4c..39cdd22 100644 --- a/widgets/http.go +++ b/widgets/http.go @@ -1,130 +1,235 @@ package widgets import ( + "context" "encoding/json" "errors" "fmt" "io" "io/ioutil" - "log" + "net" "net/http" + "sync" + "github.com/burik666/yagostatus/internal/pkg/logger" "github.com/burik666/yagostatus/ygs" "golang.org/x/net/websocket" ) +// HTTPWidgetParams are widget parameters. +type HTTPWidgetParams struct { + Network string + Listen string + Path string +} + // HTTPWidget implements the http server widget. type HTTPWidget struct { - c chan<- []ygs.I3BarBlock - conn *websocket.Conn - listen string - path string + BlankWidget + + params HTTPWidgetParams + + logger logger.Logger + + c chan<- []ygs.I3BarBlock + instance *httpInstance + + clients map[*websocket.Conn]chan interface{} + cm sync.RWMutex +} + +type httpInstance struct { + l net.Listener + server *http.Server + mux *http.ServeMux + paths map[string]struct{} +} + +var instances map[string]*httpInstance + +func init() { + ygs.RegisterWidget("http", NewHTTPWidget, HTTPWidgetParams{ + Network: "tcp", + }) + + instances = make(map[string]*httpInstance, 1) } // NewHTTPWidget returns a new HTTPWidget. -func NewHTTPWidget(params map[string]interface{}) (ygs.Widget, error) { - w := &HTTPWidget{} - v, ok := params["listen"] - if !ok { - return nil, errors.New("missing 'listen' setting") +func NewHTTPWidget(params interface{}, wlogger logger.Logger) (ygs.Widget, error) { + w := &HTTPWidget{ + params: params.(HTTPWidgetParams), + logger: wlogger, + } + + if len(w.params.Listen) == 0 { + return nil, errors.New("missing 'listen'") } - w.listen = v.(string) - v, ok = params["path"] - if !ok { - return nil, errors.New("missing 'path' setting") + if len(w.params.Path) == 0 { + return nil, errors.New("missing 'path'") } - w.path = v.(string) - if serveMuxes == nil { - serveMuxes = make(map[string]*http.ServeMux, 1) + if w.params.Network != "tcp" && w.params.Network != "unix" { + return nil, errors.New("invalid 'net' (may be 'tcp' or 'unix')") } + instanceKey := w.params.Listen + instance, ok := instances[instanceKey] + if ok { + if _, ok := instance.paths[w.params.Path]; ok { + return nil, fmt.Errorf("path '%s' already in use", w.params.Path) + } + } else { + mux := http.NewServeMux() + instance = &httpInstance{ + mux: mux, + paths: make(map[string]struct{}, 1), + server: &http.Server{ + Addr: w.params.Listen, + Handler: mux, + }, + } + + instances[w.params.Listen] = instance + w.instance = instance + } + + instance.mux.HandleFunc(w.params.Path, w.httpHandler) + instance.paths[instanceKey] = struct{}{} + + w.clients = make(map[*websocket.Conn]chan interface{}) return w, nil } -var serveMuxes map[string]*http.ServeMux - // Run starts the main loop. func (w *HTTPWidget) Run(c chan<- []ygs.I3BarBlock) error { w.c = c - mux, ok := serveMuxes[w.listen] - if ok { - mux.HandleFunc(w.path, w.httpHandler) + if w.instance == nil { return nil } - mux = http.NewServeMux() - mux.HandleFunc(w.path, w.httpHandler) - httpServer := &http.Server{ - Addr: w.listen, - Handler: mux, + l, err := net.Listen(w.params.Network, w.params.Listen) + if err != nil { + return err } - serveMuxes[w.listen] = mux - return httpServer.ListenAndServe() + + w.instance.l = l + + err = w.instance.server.Serve(l) + if errors.Is(err, http.ErrServerClosed) { + return nil + } + + return err } // Event processes the widget events. -func (w *HTTPWidget) Event(event ygs.I3BarClickEvent) { - if w.conn != nil { - websocket.JSON.Send(w.conn, event) +func (w *HTTPWidget) Event(event ygs.I3BarClickEvent, blocks []ygs.I3BarBlock) error { + return w.broadcast(event) +} + +func (w *HTTPWidget) Shutdown() error { + if w.instance == nil { + return nil } + return w.instance.server.Shutdown(context.Background()) } func (w *HTTPWidget) httpHandler(response http.ResponseWriter, request *http.Request) { if request.Method == "GET" { - ws := websocket.Handler(w.wsHandler) - ws.ServeHTTP(response, request) + serv := websocket.Server{ + Handshake: func(cfg *websocket.Config, r *http.Request) error { + return nil + }, + Handler: w.wsHandler, + } + + serv.ServeHTTP(response, request) + return } + if request.Method == "POST" { body, err := ioutil.ReadAll(request.Body) if err != nil { - log.Printf("%s", err) + w.logger.Errorf("%s", err) } + var messages []ygs.I3BarBlock if err := json.Unmarshal(body, &messages); err != nil { - log.Printf("%s", err) + w.logger.Errorf("%s", err) response.WriteHeader(http.StatusBadRequest) fmt.Fprintf(response, "%s", err) } + w.c <- messages + return } response.WriteHeader(http.StatusBadRequest) - response.Write([]byte("Bad request method, allow GET for websocket and POST for HTTP update")) + + _, err := response.Write([]byte("bad request method, allow GET for websocket and POST for HTTP update")) + if err != nil { + w.logger.Errorf("failed to write response: %s", err) + } } func (w *HTTPWidget) wsHandler(ws *websocket.Conn) { - var messages []ygs.I3BarBlock - w.conn = ws + defer ws.Close() + + ch := make(chan interface{}) + + w.cm.RLock() + w.clients[ws] = ch + w.cm.RUnlock() + + var blocks []ygs.I3BarBlock + + go func() { + for { + msg, ok := <-ch + if !ok { + return + } + + if err := websocket.JSON.Send(ws, msg); err != nil { + w.logger.Errorf("failed to send msg: %s", err) + } + } + }() + for { - if err := websocket.JSON.Receive(ws, &messages); err != nil { + if err := websocket.JSON.Receive(ws, &blocks); err != nil { if err == io.EOF { - if w.conn == ws { - w.c <- nil - w.conn = nil - } break } - log.Printf("%s", err) - } - if w.conn != ws { + w.logger.Errorf("invalid message: %s", err) break } - w.c <- messages + + w.c <- blocks } - ws.Close() + + w.cm.Lock() + delete(w.clients, ws) + w.cm.Unlock() + + close(ch) } -// Stop shutdowns the widget. -func (w *HTTPWidget) Stop() {} +func (w *HTTPWidget) broadcast(msg interface{}) error { + w.cm.RLock() + defer w.cm.RUnlock() -func init() { - ygs.RegisterWidget("http", NewHTTPWidget) + for _, ch := range w.clients { + ch <- msg + } + + return nil } diff --git a/widgets/static.go b/widgets/static.go index b80e34d..55d3aa3 100644 --- a/widgets/static.go +++ b/widgets/static.go @@ -4,24 +4,39 @@ import ( "encoding/json" "errors" + "github.com/burik666/yagostatus/internal/pkg/logger" "github.com/burik666/yagostatus/ygs" ) +// StaticWidgetParams are widget parameters. +type StaticWidgetParams struct { + Blocks string +} + // StaticWidget implements a static widget. type StaticWidget struct { + BlankWidget + + params StaticWidgetParams + blocks []ygs.I3BarBlock } +func init() { + ygs.RegisterWidget("static", NewStaticWidget, StaticWidgetParams{}) +} + // NewStaticWidget returns a new StaticWidget. -func NewStaticWidget(params map[string]interface{}) (ygs.Widget, error) { - w := &StaticWidget{} +func NewStaticWidget(params interface{}, wlogger logger.Logger) (ygs.Widget, error) { + w := &StaticWidget{ + params: params.(StaticWidgetParams), + } - v, ok := params["blocks"] - if !ok { - return nil, errors.New("missing 'blocks' setting") + if len(w.params.Blocks) == 0 { + return nil, errors.New("missing 'blocks'") } - if err := json.Unmarshal([]byte(v.(string)), &w.blocks); err != nil { + if err := json.Unmarshal([]byte(w.params.Blocks), &w.blocks); err != nil { return nil, err } @@ -33,13 +48,3 @@ func (w *StaticWidget) Run(c chan<- []ygs.I3BarBlock) error { c <- w.blocks return nil } - -// Event processes the widget events. -func (w *StaticWidget) Event(event ygs.I3BarClickEvent) {} - -// Stop shutdowns the widget. -func (w *StaticWidget) Stop() {} - -func init() { - ygs.RegisterWidget("static", NewStaticWidget) -} diff --git a/widgets/wrapper.go b/widgets/wrapper.go index 3f62f59..9ec5195 100644 --- a/widgets/wrapper.go +++ b/widgets/wrapper.go @@ -1,104 +1,146 @@ package widgets import ( - "bufio" "encoding/json" "errors" + "fmt" "io" - "os" - "os/exec" - "regexp" + "syscall" + "github.com/burik666/yagostatus/internal/pkg/executor" + "github.com/burik666/yagostatus/internal/pkg/logger" "github.com/burik666/yagostatus/ygs" ) +// WrapperWidgetParams are widget parameters. +type WrapperWidgetParams struct { + Command string + WorkDir string +} + // WrapperWidget implements the wrapper of other status commands. type WrapperWidget struct { - stdin io.WriteCloser - cmd *exec.Cmd - command string - args []string + params WrapperWidgetParams + + logger logger.Logger + + exc *executor.Executor + stdin io.WriteCloser + + eventBracketWritten bool +} + +func init() { + ygs.RegisterWidget("wrapper", NewWrapperWidget, WrapperWidgetParams{}) } // NewWrapperWidget returns a new WrapperWidget. -func NewWrapperWidget(params map[string]interface{}) (ygs.Widget, error) { - w := &WrapperWidget{} +func NewWrapperWidget(params interface{}, wlogger logger.Logger) (ygs.Widget, error) { + w := &WrapperWidget{ + params: params.(WrapperWidgetParams), + logger: wlogger, + } + + if len(w.params.Command) == 0 { + return nil, errors.New("missing 'command'") + } - v, ok := params["command"] - if !ok { - return nil, errors.New("missing 'command' setting") + exc, err := executor.Exec(w.params.Command) + if err != nil { + return nil, err } - r := regexp.MustCompile("'.+'|\".+\"|\\S+") - m := r.FindAllString(v.(string), -1) - w.command = m[0] - w.args = m[1:] + + exc.SetWD(w.params.WorkDir) + + w.exc = exc return w, nil } // Run starts the main loop. func (w *WrapperWidget) Run(c chan<- []ygs.I3BarBlock) error { - w.cmd = exec.Command(w.command, w.args...) - w.cmd.Stderr = os.Stderr - stdout, err := w.cmd.StdoutPipe() - if err != nil { - return err - } + var err error - w.stdin, err = w.cmd.StdinPipe() + w.stdin, err = w.exc.Stdin() if err != nil { return err } - if err := w.cmd.Start(); err != nil { - return err + defer w.stdin.Close() + + err = w.exc.Run(w.logger, c, executor.OutputFormatJSON) + if err == nil { + err = errors.New("process exited unexpectedly") + + if state := w.exc.ProcessState(); state != nil { + return fmt.Errorf("%w: %s", err, state.String()) + } } - w.stdin.Write([]byte("[")) - reader := bufio.NewReader(stdout) - decoder := json.NewDecoder(reader) + return err +} - var firstMessage interface{} - if err := decoder.Decode(&firstMessage); err != nil { - return err +// Event processes the widget events. +func (w *WrapperWidget) Event(event ygs.I3BarClickEvent, blocks []ygs.I3BarBlock) error { + if w.stdin == nil { + return nil } - firstMessageData, _ := json.Marshal(firstMessage) - - var header ygs.I3BarHeader - if err := json.Unmarshal(firstMessageData, &header); err == nil { - decoder.Token() - } else { - var blocks []ygs.I3BarBlock - if err := json.Unmarshal(firstMessageData, &blocks); err != nil { - return err + + if header := w.exc.I3BarHeader(); header != nil && header.ClickEvents { + if !w.eventBracketWritten { + w.eventBracketWritten = true + if _, err := w.stdin.Write([]byte("[")); err != nil { + return err + } } - c <- blocks - } - for { - var blocks []ygs.I3BarBlock - err := decoder.Decode(&blocks) + msg, err := json.Marshal(event) if err != nil { - if err == io.EOF { - break - } return err } - c <- blocks + + msg = append(msg, []byte(",\n")...) + + if _, err := w.stdin.Write(msg); err != nil { + return err + } } - return w.cmd.Wait() + + return nil } -// Event processes the widget events. -func (w *WrapperWidget) Event(event ygs.I3BarClickEvent) { - msg, _ := json.Marshal(event) - w.stdin.Write(msg) - w.stdin.Write([]byte(",\n")) +// Stop stops the widdget. +func (w *WrapperWidget) Stop() error { + if header := w.exc.I3BarHeader(); header != nil { + if header.StopSignal != 0 { + return w.exc.Signal(syscall.Signal(header.StopSignal)) + } + } + + return w.exc.Signal(syscall.SIGSTOP) } -// Stop shutdowns the widget. -func (w *WrapperWidget) Stop() {} +// Continue continues the widdget. +func (w *WrapperWidget) Continue() error { + if header := w.exc.I3BarHeader(); header != nil { + if header.ContSignal != 0 { + return w.exc.Signal(syscall.Signal(header.ContSignal)) + } + } + + return w.exc.Signal(syscall.SIGCONT) +} -func init() { - ygs.RegisterWidget("wrapper", NewWrapperWidget) +// Shutdown shutdowns the widget. +func (w *WrapperWidget) Shutdown() error { + if w.exc != nil { + err := w.exc.Signal(syscall.SIGTERM) + if err != nil { + return err + } + + return w.exc.Wait() + } + + return nil } diff --git a/yagostatus.go b/yagostatus.go index b217358..25d8cb1 100644 --- a/yagostatus.go +++ b/yagostatus.go @@ -2,87 +2,121 @@ package main import ( "bufio" - "bytes" "encoding/json" "fmt" "io" - "log" "os" - "os/exec" + "runtime/debug" "strings" "sync" "github.com/burik666/yagostatus/internal/pkg/config" + "github.com/burik666/yagostatus/internal/pkg/executor" + "github.com/burik666/yagostatus/internal/pkg/logger" _ "github.com/burik666/yagostatus/widgets" "github.com/burik666/yagostatus/ygs" - "go.i3wm.org/i3" + "go.i3wm.org/i3/v4" ) +type widgetContainer struct { + instance ygs.Widget + output []ygs.I3BarBlock + config ygs.WidgetConfig + ch chan []ygs.I3BarBlock + logger logger.Logger +} + // YaGoStatus is the main struct. type YaGoStatus struct { - widgets []ygs.Widget - widgetsOutput [][]ygs.I3BarBlock - widgetsConfig []config.WidgetConfig + widgets []widgetContainer upd chan int workspaces []i3.Workspace visibleWorkspaces []string + + cfg config.Config + + logger logger.Logger } // NewYaGoStatus returns a new YaGoStatus instance. -func NewYaGoStatus(cfg config.Config) (*YaGoStatus, error) { - status := &YaGoStatus{} - for _, w := range cfg.Widgets { - widget, err := ygs.NewWidget(w.Name, w.Params) - if err != nil { - status.errorWidget(err.Error()) - continue - } +func NewYaGoStatus(cfg config.Config, l logger.Logger) (*YaGoStatus, error) { + status := &YaGoStatus{ + cfg: cfg, + logger: l, + } - status.AddWidget(widget, w) + for wi := range cfg.Widgets { + status.addWidget(cfg.Widgets[wi]) } return status, nil } func (status *YaGoStatus) errorWidget(text string) { - log.Print(text) - blocks, _ := json.Marshal([]ygs.I3BarBlock{ - ygs.I3BarBlock{ - FullText: text, - Color: "#ff0000", - }, - }) - - widget, err := ygs.NewWidget("static", map[string]interface{}{ - "blocks": string(blocks), - }) - if err != nil { - log.Fatalf("Failed to create error widget: %s", err) - } - - status.AddWidget(widget, config.WidgetConfig{}) + status.addWidget(ygs.ErrorWidget(text)) } -// AddWidget adds widget to statusbar. -func (status *YaGoStatus) AddWidget(widget ygs.Widget, config config.WidgetConfig) { - status.widgets = append(status.widgets, widget) - status.widgetsOutput = append(status.widgetsOutput, nil) - status.widgetsConfig = append(status.widgetsConfig, config) +func (status *YaGoStatus) addWidget(wcfg ygs.WidgetConfig) { + wlogger := status.logger.WithPrefix(fmt.Sprintf("[%s#%d]", wcfg.File, wcfg.Index+1)) + + (func() { + defer (func() { + if r := recover(); r != nil { + wlogger.Errorf("NewWidget panic: %s", r) + debug.PrintStack() + status.errorWidget("widget panic") + } + })() + + widget, err := ygs.NewWidget(wcfg, wlogger) + if err != nil { + wlogger.Errorf("Failed to create widget: %s", err) + status.errorWidget(err.Error()) + + return + } + + status.widgets = append(status.widgets, widgetContainer{ + instance: widget, + config: wcfg, + ch: make(chan []ygs.I3BarBlock), + logger: wlogger, + }) + })() } -func (status *YaGoStatus) processWidgetEvents(widgetIndex int, outputIndex int, event ygs.I3BarClickEvent) error { - defer status.widgets[widgetIndex].Event(event) - for _, we := range status.widgetsConfig[widgetIndex].Events { - if (we.Button == 0 || we.Button == event.Button) && - (we.Name == "" || we.Name == event.Name) && - (we.Instance == "" || we.Instance == event.Instance) && - checkModifiers(we.Modifiers, event.Modifiers) { - cmd := exec.Command("sh", "-c", we.Command) - cmd.Stderr = os.Stderr - cmd.Env = append(os.Environ(), +func (status *YaGoStatus) processWidgetEvents(wi int, outputIndex int, event ygs.I3BarClickEvent) error { + defer (func() { + if r := recover(); r != nil { + status.widgets[wi].logger.Errorf("widget event panic: %s", r) + debug.PrintStack() + status.widgets[wi].output = []ygs.I3BarBlock{{ + FullText: "widget panic", + Color: "#ff0000", + }} + } + + if err := status.widgets[wi].instance.Event(event, status.widgets[wi].output); err != nil { + status.widgets[wi].logger.Errorf("Failed to process widget event: %s", err) + } + })() + + for _, widgetEvent := range status.widgets[wi].config.Events { + if (widgetEvent.Button == 0 || widgetEvent.Button == event.Button) && + (widgetEvent.Name == "" || widgetEvent.Name == event.Name) && + (widgetEvent.Instance == "" || widgetEvent.Instance == event.Instance) && + checkModifiers(widgetEvent.Modifiers, event.Modifiers) { + exc, err := executor.Exec("sh", "-c", widgetEvent.Command) + if err != nil { + return err + } + + exc.SetWD(widgetEvent.WorkDir) + + exc.AddEnv( fmt.Sprintf("I3_%s=%s", "NAME", event.Name), fmt.Sprintf("I3_%s=%s", "INSTANCE", event.Instance), fmt.Sprintf("I3_%s=%d", "BUTTON", event.Button), @@ -94,209 +128,308 @@ func (status *YaGoStatus) processWidgetEvents(widgetIndex int, outputIndex int, fmt.Sprintf("I3_%s=%d", "HEIGHT", event.Height), fmt.Sprintf("I3_%s=%s", "MODIFIERS", strings.Join(event.Modifiers, ",")), ) - cmdStdin, err := cmd.StdinPipe() + + block := status.widgets[wi].output[outputIndex] + block.Name = event.Name + block.Instance = event.Instance + + exc.AddEnv(block.Env("")...) + stdin, err := exc.Stdin() if err != nil { return err } - eventJSON, _ := json.Marshal(event) - cmdStdin.Write(eventJSON) - cmdStdin.Write([]byte("\n")) - cmdStdin.Close() - cmdOutput, err := cmd.Output() + eventJSON, err := json.Marshal(event) if err != nil { return err } - if we.Output { - var blocks []ygs.I3BarBlock - if err := json.Unmarshal(cmdOutput, &blocks); err == nil { - for bi := range blocks { - block := &blocks[bi] - mergeBlocks(block, status.widgetsConfig[widgetIndex].Template) - block.Name = fmt.Sprintf("yagostatus-%d-%s", widgetIndex, block.Name) - block.Instance = fmt.Sprintf("yagostatus-%d-%d-%s", widgetIndex, outputIndex, block.Instance) - } - status.widgetsOutput[widgetIndex] = blocks - } else { - status.widgetsOutput[widgetIndex][outputIndex].FullText = strings.Trim(string(cmdOutput), "\n\r") - } - status.upd <- widgetIndex + + eventJSON = append(eventJSON, []byte("\n")...) + + _, err = stdin.Write(eventJSON) + if err != nil { + return err + } + + err = stdin.Close() + if err != nil { + return err + } + + err = exc.Run( + status.widgets[wi].logger, + status.widgets[wi].ch, + executor.OutputFormat(widgetEvent.OutputFormat), + ) + if err != nil { + return err } } } + return nil } -func (status *YaGoStatus) eventReader() { +func (status *YaGoStatus) addWidgetOutput(wi int, blocks []ygs.I3BarBlock) { + output := make([]ygs.I3BarBlock, len(blocks)) + + tplc := len(status.widgets[wi].config.Templates) + for blockIndex := range blocks { + block := blocks[blockIndex] + + if tplc == 1 { + block.Apply(status.widgets[wi].config.Templates[0]) + } else { + if blockIndex < tplc { + block.Apply(status.widgets[wi].config.Templates[blockIndex]) + } + } + + block.Name = fmt.Sprintf("yagostatus-%d-%s", wi, block.Name) + block.Instance = fmt.Sprintf("yagostatus-%d-%d-%s", wi, blockIndex, block.Instance) + + output[blockIndex] = block + } + + status.widgets[wi].output = output + + status.upd <- wi +} + +func (status *YaGoStatus) eventReader() error { reader := bufio.NewReader(os.Stdin) + for { line, err := reader.ReadString('\n') if err != nil { if err != io.EOF { - log.Fatal(err) + return err } + break } + line = strings.Trim(line, "[], \n") if line == "" { continue } + var event ygs.I3BarClickEvent if err := json.Unmarshal([]byte(line), &event); err != nil { - log.Printf("%s (%s)", err, line) + status.logger.Errorf("%s (%s)", err, line) + continue } - for widgetIndex, widgetOutputs := range status.widgetsOutput { - for outputIndex, output := range widgetOutputs { + + for wi := range status.widgets { + for outputIndex, output := range status.widgets[wi].output { if (event.Name != "" && event.Name == output.Name) && (event.Instance != "" && event.Instance == output.Instance) { e := event e.Name = strings.Join(strings.Split(e.Name, "-")[2:], "-") e.Instance = strings.Join(strings.Split(e.Instance, "-")[3:], "-") - if err := status.processWidgetEvents(widgetIndex, outputIndex, e); err != nil { - log.Print(err) - status.widgetsOutput[widgetIndex][outputIndex] = ygs.I3BarBlock{ - FullText: fmt.Sprintf("Event error: %s", err.Error()), + + if err := status.processWidgetEvents(wi, outputIndex, e); err != nil { + status.widgets[wi].logger.Errorf("event error: %s", err) + + status.widgets[wi].output[outputIndex] = ygs.I3BarBlock{ + FullText: fmt.Sprintf("event error: %s", err.Error()), Color: "#ff0000", Name: event.Name, Instance: event.Instance, } - break } + + break } } } } + + return nil } // Run starts the main loop. -func (status *YaGoStatus) Run() { +func (status *YaGoStatus) Run() error { status.upd = make(chan int) - for widgetIndex, widget := range status.widgets { - c := make(chan []ygs.I3BarBlock) - go func(widgetIndex int, c chan []ygs.I3BarBlock) { - for { - select { - case out := <-c: - output := make([]ygs.I3BarBlock, len(out)) - copy(output, out) - for outputIndex := range output { - mergeBlocks(&output[outputIndex], status.widgetsConfig[widgetIndex].Template) - output[outputIndex].Name = fmt.Sprintf("yagostatus-%d-%s", widgetIndex, output[outputIndex].Name) - output[outputIndex].Instance = fmt.Sprintf("yagostatus-%d-%d-%s", widgetIndex, outputIndex, output[outputIndex].Instance) - } - status.widgetsOutput[widgetIndex] = output - status.upd <- widgetIndex - } - } - }(widgetIndex, c) + go (func() { status.updateWorkspaces() - go (func() { - recv := i3.Subscribe(i3.WorkspaceEventType) - for recv.Next() { - e := recv.Event().(*i3.WorkspaceEvent) - if e.Change == "empty" { - continue - } - status.updateWorkspaces() - status.upd <- -1 + recv := i3.Subscribe(i3.WorkspaceEventType) + for recv.Next() { + e := recv.Event().(*i3.WorkspaceEvent) + if e.Change == "empty" { + continue } - })() + status.updateWorkspaces() + status.upd <- -1 + } + })() + + for wi := range status.widgets { + go func(wi int) { + for out := range status.widgets[wi].ch { + status.addWidgetOutput(wi, out) + } + }(wi) - go func(widget ygs.Widget, c chan []ygs.I3BarBlock) { - if err := widget.Run(c); err != nil { - log.Print(err) - c <- []ygs.I3BarBlock{ygs.I3BarBlock{ + go func(wi int) { + defer (func() { + if r := recover(); r != nil { + status.widgets[wi].logger.Errorf("widget panic: %s", r) + debug.PrintStack() + status.widgets[wi].ch <- []ygs.I3BarBlock{{ + FullText: "widget panic", + Color: "#ff0000", + }} + } + })() + if err := status.widgets[wi].instance.Run(status.widgets[wi].ch); err != nil { + status.widgets[wi].logger.Errorf("Widget done: %s", err) + status.widgets[wi].ch <- []ygs.I3BarBlock{{ FullText: err.Error(), Color: "#ff0000", }} } - }(widget, c) + }(wi) } - fmt.Print("{\"version\":1, \"click_events\": true}\n[\n[]") + encoder := json.NewEncoder(os.Stdout) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", " ") + + if err := encoder.Encode(ygs.I3BarHeader{ + Version: 1, + ClickEvents: true, + StopSignal: int(status.cfg.Signals.StopSignal), + ContSignal: int(status.cfg.Signals.ContSignal), + }); err != nil { + status.logger.Errorf("Failed to encode I3BarHeader: %s", err) + } + + fmt.Print("\n[\n[]") + go func() { - buf := &bytes.Buffer{} - encoder := json.NewEncoder(buf) - encoder.SetEscapeHTML(false) - encoder.SetIndent("", " ") - for { - select { - case <-status.upd: - var result []ygs.I3BarBlock - for widgetIndex, widgetOutput := range status.widgetsOutput { - if checkWorkspaceConditions(status.widgetsConfig[widgetIndex].Workspaces, status.visibleWorkspaces) { - result = append(result, widgetOutput...) - } + for range status.upd { + var result []ygs.I3BarBlock + for wi := range status.widgets { + if checkWorkspaceConditions(status.widgets[wi].config.Workspaces, status.visibleWorkspaces) { + result = append(result, status.widgets[wi].output...) } - buf.Reset() - encoder.Encode(result) - fmt.Print(",") - fmt.Print(string(buf.Bytes())) + } + fmt.Print(",") + err := encoder.Encode(result) + if err != nil { + status.logger.Errorf("Failed to encode result: %s", err) } } }() - status.eventReader() + + return status.eventReader() } -// Stop shutdowns widgets and main loop. -func (status *YaGoStatus) Stop() { +// Shutdown shutdowns widgets and main loop. +func (status *YaGoStatus) Shutdown() error { var wg sync.WaitGroup - for _, widget := range status.widgets { + + for wi := range status.widgets { wg.Add(1) - go func(widget ygs.Widget) { - widget.Stop() - wg.Done() - }(widget) + + go func(wi int) { + defer wg.Done() + defer (func() { + if r := recover(); r != nil { + status.widgets[wi].logger.Errorf("widget panic: %s", r) + debug.PrintStack() + } + })() + if err := status.widgets[wi].instance.Shutdown(); err != nil { + status.widgets[wi].logger.Errorf("Failed to shutdown widget: %s", err) + } + }(wi) } + wg.Wait() + + return nil +} + +// Stop stops widgets and main loop. +func (status *YaGoStatus) Stop() { + for wi := range status.widgets { + go func(wi int) { + defer (func() { + if r := recover(); r != nil { + status.widgets[wi].logger.Errorf("widget panic: %s", r) + debug.PrintStack() + } + })() + if err := status.widgets[wi].instance.Stop(); err != nil { + status.widgets[wi].logger.Errorf("Failed to stop widget: %s", err) + } + }(wi) + } +} + +// Continue continues widgets and main loop. +func (status *YaGoStatus) Continue() { + for wi := range status.widgets { + go func(wi int) { + defer (func() { + if r := recover(); r != nil { + status.widgets[wi].logger.Errorf("widget panic: %s", r) + debug.PrintStack() + } + })() + if err := status.widgets[wi].instance.Continue(); err != nil { + status.widgets[wi].logger.Errorf("Failed to continue widget: %s", err) + } + }(wi) + } } func (status *YaGoStatus) updateWorkspaces() { var err error + status.workspaces, err = i3.GetWorkspaces() + if err != nil { - log.Printf("Failed to get workspaces: %s", err) + status.logger.Errorf("Failed to get workspaces: %s", err) } + var vw []string + for i := range status.workspaces { if status.workspaces[i].Visible { vw = append(vw, status.workspaces[i].Name) } } - status.visibleWorkspaces = vw -} - -func mergeBlocks(b *ygs.I3BarBlock, tpl ygs.I3BarBlock) { - var resmap map[string]interface{} - jb, _ := json.Marshal(*b) - jtpl, _ := json.Marshal(tpl) - json.Unmarshal(jtpl, &resmap) - json.Unmarshal(jb, &resmap) - - jb, _ = json.Marshal(resmap) - json.Unmarshal(jb, b) + status.visibleWorkspaces = vw } func checkModifiers(conditions []string, values []string) bool { for _, c := range conditions { isNegative := c[0] == '!' c = strings.TrimLeft(c, "!") + found := false + for _, v := range values { if c == v { found = true break } } + if found && isNegative { return false } - if (!found) && !isNegative { + + if !found && !isNegative { return false } } + return true } @@ -304,23 +437,30 @@ func checkWorkspaceConditions(conditions []string, values []string) bool { if len(conditions) == 0 { return true } + pass := 0 + for _, c := range conditions { isNegative := c[0] == '!' c = strings.TrimLeft(c, "!") + found := false + for _, v := range values { if c == v { found = true break } } + if found && !isNegative { return true } + if !found && isNegative { pass++ } } + return len(conditions) == pass } diff --git a/yagostatus.yml b/yagostatus.yml index d643c31..15d6cc9 100644 --- a/yagostatus.yml +++ b/yagostatus.yml @@ -16,9 +16,9 @@ widgets: - widget: clock format: Jan _2 Mon 15:04:05 # https://golang.org/pkg/time/#Time.Format - template: > - { + templates: > + [{ "color": "#ffffff", "separator": true, - "separator_block_width": 20 - } + "separator_block_width": 21 + }] diff --git a/ygs/parser.go b/ygs/parser.go new file mode 100644 index 0000000..8912212 --- /dev/null +++ b/ygs/parser.go @@ -0,0 +1,158 @@ +package ygs + +import ( + "encoding/json" + "fmt" + "reflect" + "strconv" + "strings" +) + +func (b *I3BarBlock) FromJSON(data []byte, strict bool) error { + type dataWrapped I3BarBlock + + var block dataWrapped + + // copy + tmp, err := json.Marshal(b) + if err != nil { + return err + } + + if err := json.Unmarshal(tmp, &block); err != nil { + return err + } + + if block.Custom == nil { + block.Custom = make(map[string]Vary) + } + + if err := parseBlock(&block, block.Custom, data, strict); err != nil { + return err + } + + *b = I3BarBlock(block) + + return nil +} + +func (b *I3BarBlock) ToVaryMap() map[string]Vary { + tmp, _ := json.Marshal(b) + + varyMap := make(map[string]Vary) + + json.Unmarshal(tmp, &varyMap) + + return varyMap +} + +func parseBlock(block interface{}, custom map[string]Vary, data []byte, strict bool) error { + var jfields map[string]Vary + + if err := json.Unmarshal(data, &jfields); err != nil { + return err + } + + val := reflect.ValueOf(block).Elem() + fieldsByJSONTag := make(map[string]reflect.Value) + + for i := 0; i < val.NumField(); i++ { + typeField := val.Type().Field(i) + if tag, ok := typeField.Tag.Lookup("json"); ok { + tagName := strings.Split(tag, ",")[0] + if tagName == "-" || tagName == "" { + continue + } + + fieldsByJSONTag[tagName] = val.Field(i) + } + } + + for k, v := range jfields { + f, ok := fieldsByJSONTag[k] + if !ok { + if len(k) == 0 { + continue + } + + if strict && k[0] != byte('_') { + return fmt.Errorf("uknown field: %s", k) + } + + custom[k] = v + + continue + } + + var val reflect.Value + + if f.Type().Kind() == reflect.Ptr { + val = reflect.New(f.Type().Elem()) + } else { + val = reflect.New(f.Type()).Elem() + } + + sv := string(v) + + switch reflect.Indirect(val).Kind() { + case reflect.String: + s, err := strconv.Unquote(sv) + if strict && err != nil { + return fmt.Errorf("invalid value for %s (string): %s", k, sv) + } else { + if err != nil { + s = sv + } + } + + reflect.Indirect(val).SetString(s) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + s := sv + if !strict { + s = strings.Trim(sv, "\"") + } + + if n, err := strconv.ParseInt(s, 10, 64); err == nil { + reflect.Indirect(val).SetInt(n) + } else { + return fmt.Errorf("invalid value for %s (int): %s", k, sv) + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + s := sv + if !strict { + s = strings.Trim(sv, "\"") + } + + if n, err := strconv.ParseUint(s, 10, 64); err == nil { + reflect.Indirect(val).SetUint(n) + } else { + return fmt.Errorf("invalid value for %s (uint): %s", k, sv) + } + + case reflect.Bool: + if strict { + switch sv { + case "true": + reflect.Indirect(val).SetBool(true) + case "false": + reflect.Indirect(val).SetBool(false) + default: + return fmt.Errorf("invalid value for %s: %s", k, sv) + } + } else { + s := strings.Trim(strings.ToLower(sv), "\"") + if s == "false" || s == "0" || s == "f" { + reflect.Indirect(val).SetBool(false) + } else { + reflect.Indirect(val).SetBool(true) + } + } + default: + panic("unsuported type") + } + + f.Set(val) + } + + return nil +} diff --git a/ygs/protocol.go b/ygs/protocol.go index 02db634..e797537 100644 --- a/ygs/protocol.go +++ b/ygs/protocol.go @@ -1,5 +1,11 @@ package ygs +import ( + "bytes" + "encoding/json" + "fmt" +) + // I3BarHeader represents the header of an i3bar message. type I3BarHeader struct { Version uint8 `json:"version"` @@ -10,24 +16,24 @@ type I3BarHeader struct { // I3BarBlock represents a block of i3bar message. type I3BarBlock struct { - FullText string `json:"full_text"` - ShortText string `json:"short_text,omitempty"` - Color string `json:"color,omitempty"` - BorderColor string `json:"border,omitempty"` - BorderTop *uint16 `json:"border_top,omitempty"` - BorderBottom *uint16 `json:"border_bottom,omitempty"` - BorderLeft *uint16 `json:"border_left,omitempty"` - BorderRight *uint16 `json:"border_right,omitempty"` - BackgroundColor string `json:"background,omitempty"` - Markup string `json:"markup,omitempty"` - MinWidth string `json:"min_width,omitempty"` - Align string `json:"align,omitempty"` - Name string `json:"name,omitempty"` - Instance string `json:"instance,omitempty"` - Urgent bool `json:"urgent,omitempty"` - Separator *bool `json:"separator,omitempty"` - SeparatorBlockWidth uint16 `json:"separator_block_width,omitempty"` - Custom map[string]interface{} `json:"-"` + FullText string `json:"full_text,omitempty"` + ShortText string `json:"short_text,omitempty"` + Color string `json:"color,omitempty"` + BorderColor string `json:"border,omitempty"` + BorderTop *uint16 `json:"border_top,omitempty"` + BorderBottom *uint16 `json:"border_bottom,omitempty"` + BorderLeft *uint16 `json:"border_left,omitempty"` + BorderRight *uint16 `json:"border_right,omitempty"` + BackgroundColor string `json:"background,omitempty"` + Markup string `json:"markup,omitempty"` + MinWidth string `json:"min_width,omitempty"` + Align string `json:"align,omitempty"` + Name string `json:"name,omitempty"` + Instance string `json:"instance,omitempty"` + Urgent bool `json:"urgent,omitempty"` + Separator *bool `json:"separator,omitempty"` + SeparatorBlockWidth uint16 `json:"separator_block_width,omitempty"` + Custom map[string]Vary `json:"-"` } // I3BarClickEvent represents a user click event message. @@ -43,3 +49,70 @@ type I3BarClickEvent struct { Height uint16 `json:"height"` Modifiers []string `json:"modifiers"` } + +// UnmarshalJSON unmarshals json with custom keys (with _ prefix). +func (b *I3BarBlock) UnmarshalJSON(data []byte) error { + return b.FromJSON(data, true) +} + +// MarshalJSON marshals json with custom keys (with _ prefix). +func (b I3BarBlock) MarshalJSON() ([]byte, error) { + type dataWrapped I3BarBlock + + wd := dataWrapped(b) + + if len(wd.Custom) == 0 { + buf := &bytes.Buffer{} + encoder := json.NewEncoder(buf) + encoder.SetEscapeHTML(false) + err := encoder.Encode(wd) + + return buf.Bytes(), err + } + + var resmap map[string]interface{} + + var tmp []byte + + tmp, _ = json.Marshal(wd) + if err := json.Unmarshal(tmp, &resmap); err != nil { + return nil, err + } + + tmp, _ = json.Marshal(wd.Custom) + if err := json.Unmarshal(tmp, &resmap); err != nil { + return nil, err + } + + buf := &bytes.Buffer{} + encoder := json.NewEncoder(buf) + encoder.SetEscapeHTML(false) + err := encoder.Encode(resmap) + + return buf.Bytes(), err +} + +func (b *I3BarBlock) Apply(tpl I3BarBlock) { + jb, _ := json.Marshal(b) + *b = tpl + + json.Unmarshal(jb, b) +} + +func (b I3BarBlock) Env(suffix string) []string { + env := make([]string, 0) + for k, v := range b.Custom { + env = append(env, fmt.Sprintf("I3_%s%s=%s", k, suffix, v)) + } + + ob, _ := json.Marshal(b) + + var rawOutput map[string]Vary + json.Unmarshal(ob, &rawOutput) + + for k, v := range rawOutput { + env = append(env, fmt.Sprintf("I3_%s%s=%s", k, suffix, v.String())) + } + + return env +} diff --git a/ygs/utils.go b/ygs/utils.go index fb3bdff..f779ad0 100644 --- a/ygs/utils.go +++ b/ygs/utils.go @@ -1,28 +1,102 @@ package ygs import ( - "log" + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" - "github.com/pkg/errors" + "github.com/burik666/yagostatus/internal/pkg/logger" + "gopkg.in/yaml.v2" ) -type newWidgetFunc = func(map[string]interface{}) (Widget, error) +type newWidgetFunc = func(interface{}, logger.Logger) (Widget, error) -var registeredWidgets = make(map[string]newWidgetFunc) +type widget struct { + newFunc newWidgetFunc + defaultParams interface{} +} + +var registeredWidgets = make(map[string]widget) // RegisterWidget registers widget. -func RegisterWidget(name string, newFunc newWidgetFunc) { +func RegisterWidget(name string, newFunc newWidgetFunc, defaultParams interface{}) { if _, ok := registeredWidgets[name]; ok { - log.Fatalf("Widget '%s' already registered", name) + panic(fmt.Sprintf("widget '%s' already registered", name)) + } + + def := reflect.ValueOf(defaultParams) + if def.Kind() != reflect.Struct { + panic("defaultParams should be a struct") + } + + registeredWidgets[name] = widget{ + newFunc: newFunc, + defaultParams: defaultParams, } - registeredWidgets[name] = newFunc } // NewWidget creates new widget by name. -func NewWidget(name string, params widgetParams) (Widget, error) { - newFunc, ok := registeredWidgets[name] +func NewWidget(widgetConfig WidgetConfig, wlogger logger.Logger) (Widget, error) { + name := widgetConfig.Name + widget, ok := registeredWidgets[name] if !ok { - return nil, errors.Errorf("Widget '%s' not found", name) + return nil, fmt.Errorf("widget '%s' not found", name) + } + + def := reflect.ValueOf(widget.defaultParams) + + params := reflect.New(def.Type()) + pe := params.Elem() + pe.Set(def) + + b, err := yaml.Marshal(widgetConfig.Params) + if err != nil { + return nil, err + } + + if err := yaml.UnmarshalStrict(b, params.Interface()); err != nil { + return nil, trimYamlErr(err, true) } - return newFunc(params) + + if _, ok := widgetConfig.Params["workdir"]; !ok { + for i := 0; i < pe.NumField(); i++ { + fn := pe.Type().Field(i).Name + if strings.ToLower(fn) == "workdir" { + pe.Field(i).SetString(widgetConfig.WorkDir) + } + } + } + + return widget.newFunc(pe.Interface(), wlogger) +} + +// ErrorWidget creates new widget with error message. +func ErrorWidget(text string) WidgetConfig { + blocks, _ := json.Marshal([]I3BarBlock{ + { + FullText: text, + Color: "#ff0000", + }, + }) + + return WidgetConfig{ + Name: "static", + Params: map[string]interface{}{ + "blocks": string(blocks), + }, + File: "bultin", + } + +} + +func trimYamlErr(err error, trimLineN bool) error { + msg := strings.TrimPrefix(err.Error(), "yaml: unmarshal errors:\n ") + if trimLineN { + msg = strings.TrimPrefix(msg, "line ") + msg = strings.TrimLeft(msg, "1234567890: ") + } + + return errors.New(msg) } diff --git a/ygs/vary.go b/ygs/vary.go new file mode 100644 index 0000000..4c6a4d2 --- /dev/null +++ b/ygs/vary.go @@ -0,0 +1,31 @@ +package ygs + +import ( + "errors" + "strconv" +) + +type Vary []byte + +func (v Vary) MarshalJSON() ([]byte, error) { + if v == nil { + return []byte("null"), nil + } + return v, nil +} + +func (v *Vary) UnmarshalJSON(data []byte) error { + if v == nil { + return errors.New("ygs.Vary: UnmarshalJSON on nil pointer") + } + *v = append((*v)[0:0], data...) + return nil +} + +func (v Vary) String() string { + s, err := strconv.Unquote(string(v)) + if err != nil { + return string(v) + } + return s +} diff --git a/ygs/widget.go b/ygs/widget.go index d9b9754..2ebdd4d 100644 --- a/ygs/widget.go +++ b/ygs/widget.go @@ -1,71 +1,11 @@ // Package ygs contains the YaGoStatus structures. package ygs -import ( - "bytes" - "encoding/json" -) - -type widgetParams = map[string]interface{} - // Widget represents a widget struct. type Widget interface { Run(chan<- []I3BarBlock) error - Event(I3BarClickEvent) - Stop() -} - -// UnmarshalJSON unmarshals json with custom keys (with _ prefix). -func (b *I3BarBlock) UnmarshalJSON(data []byte) error { - type dataWrapped I3BarBlock - - var wr dataWrapped - - if err := json.Unmarshal(data, &wr); err != nil { - return err - } - - if err := json.Unmarshal(data, &wr.Custom); err != nil { - return err - } - for k := range wr.Custom { - if k[0] != '_' { - delete(wr.Custom, k) - } - } - - *b = I3BarBlock(wr) - - return nil -} - -// MarshalJSON marshals json with custom keys (with _ prefix). -func (b I3BarBlock) MarshalJSON() ([]byte, error) { - type dataWrapped I3BarBlock - var wd dataWrapped - wd = dataWrapped(b) - - if len(wd.Custom) == 0 { - buf := &bytes.Buffer{} - encoder := json.NewEncoder(buf) - encoder.SetEscapeHTML(false) - err := encoder.Encode(wd) - return buf.Bytes(), err - } - - var resmap map[string]interface{} - - var tmp []byte - - tmp, _ = json.Marshal(wd) - json.Unmarshal(tmp, &resmap) - - tmp, _ = json.Marshal(wd.Custom) - json.Unmarshal(tmp, &resmap) - - buf := &bytes.Buffer{} - encoder := json.NewEncoder(buf) - encoder.SetEscapeHTML(false) - err := encoder.Encode(resmap) - return buf.Bytes(), err + Event(I3BarClickEvent, []I3BarBlock) error + Stop() error + Continue() error + Shutdown() error } diff --git a/ygs/widget_config.go b/ygs/widget_config.go new file mode 100644 index 0000000..5df552d --- /dev/null +++ b/ygs/widget_config.go @@ -0,0 +1,36 @@ +package ygs + +import ( + "errors" +) + +// WidgetConfig represents a widget configuration. +type WidgetConfig struct { + Name string `yaml:"widget"` + Workspaces []string `yaml:"workspaces"` + Templates []I3BarBlock `yaml:"-"` + Events []WidgetEventConfig `yaml:"events"` + WorkDir string `yaml:"-"` + Index int `yaml:"-"` + File string `yaml:"-"` + Variables map[string]string `yaml:"variables"` + + Params map[string]interface{} `yaml:",inline"` + + IncludeStack []string `yaml:"-"` +} + +// Validate checks widget configuration. +func (c WidgetConfig) Validate() error { + if c.Name == "" { + return errors.New("missing widget name") + } + + for ei := range c.Events { + if err := c.Events[ei].Validate(); err != nil { + return err + } + } + + return nil +} diff --git a/ygs/widget_event_config.go b/ygs/widget_event_config.go new file mode 100644 index 0000000..3ef9545 --- /dev/null +++ b/ygs/widget_event_config.go @@ -0,0 +1,45 @@ +package ygs + +import ( + "fmt" + "strings" +) + +// WidgetEventConfig represents a widget events. +type WidgetEventConfig struct { + Command string `yaml:"command"` + Button uint8 `yaml:"button"` + Modifiers []string `yaml:"modifiers,omitempty"` + Name string `yaml:"name,omitempty"` + Instance string `yaml:"instance,omitempty"` + OutputFormat string `yaml:"output_format,omitempty"` + Override bool `yaml:"override"` + WorkDir string `yaml:"workdir"` +} + +// Validate checks event parameters. +func (e *WidgetEventConfig) Validate() error { + var availableWidgetEventModifiers = [...]string{"Shift", "Control", "Mod1", "Mod2", "Mod3", "Mod4", "Mod5"} + + for _, mod := range e.Modifiers { + found := false + mod = strings.TrimLeft(mod, "!") + + for _, m := range availableWidgetEventModifiers { + if mod == m { + found = true + break + } + } + + if !found { + return fmt.Errorf("unknown '%s' modifier", mod) + } + } + + if e.OutputFormat == "" { + e.OutputFormat = "none" + } + + return nil +}