diff --git a/.gitignore b/.gitignore index 53c37a1..7e3cf98 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -dist \ No newline at end of file +dist +config*.toml +icons \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..f944eb2 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Package", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "." + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 990ec3b..78bd825 100644 --- a/README.md +++ b/README.md @@ -12,52 +12,73 @@ Works on Windows and Linux. ## Configuration -Default config will be autogenerated for you on the first run. -You can access it from from: `Click Tray Icon > Options`. +Default config file will be autogenerated for you on the first run. +You can access it from from: `Click Tray Icon > Configure`. On Linux, the config folder location is `~/.config/kanata-tray`. On Windows, it's `C:\Users\\AppData\Roaming\kanata-tray` +### Examples + An example of customized configuration file: ```toml -# default: [] -configurations = [ - "~/.config/kanata/kanata.kbd", - "~/.config/kanata/test.kbd", -] - -# default: [] -executables = [ - "~/.config/kanata/kanata", - "~/.config/kanata/kanata-debug", -] - -[layer_icons] -base = "hello.ico" -qwerty = "qwerty.ico" -"*" = "other_layers.ico" +'$schema' = 'https://raw.githubusercontent.com/rszyma/kanata-tray/v0.2.0/doc/config_schema.json' [general] -include_executables_from_system_path = false # default: true -include_configs_from_default_locations = false # default: true -launch_on_start = true # default: true -tcp_port = 5829 # default: 5829 +allow_concurrent_presets = false + +[defaults] +kanata_executable = '~/bin/kanata' # if empty or omitted, system $PATH will be searched. +kanata_config = '' # if empty or not omitted, kanata default config locations will be used. +tcp_port = 5829 # if not specified, defaults to 5829 + +[defaults.layer_icons] +mouse = 'mouse.png' +qwerty = 'qwerty.ico' +'*' = 'other_layers.ico' + +[presets.'main cfg'] +kanata_config = '~/.config/kanata/test.kbd' +autorun = true +# kanata_executable = '' +# layer_icons = { } +# tcp_port = 1234 + +[presets.'test cfg'] +kanata_config = '~/.config/kanata/test.kbd' + ``` +### Explanation + +`presets` - a config item, that adds an entry to tray menu. Each preset can have different settings for running kanata with: +`kanata_config`, `kanata_executable`, `autorun`, `layer_icons`, `tcp_port`. -Notes: +`preset.autorun` - when set to true, preset will run at kanata-tray startup. + +`preset.layer_icons` - maps kanata layer names to custom icons. Custom icons should be placed in `icons` folder in config directory, next to `config.toml`. Accepted icon types on Linux are `.ico`, `.png`, `.jpg`; on Windows only `.ico` is supported. You can assign an icon to special identifier `'*'` to change icon for other layers not specified in `[layer_icons]`. + +`defaults` - a config item, that allows to overwrite default values for all presets. +It accepts same configuration options that `presets` do. + +`general.allow_concurrent_presets` - when enabled, allows running multiple presets at the same time. +When disabled, switching presets will stop currently running preset (if any). +Disabled by default. + +Other notes: - You can use `~` in paths to substitute to your "home" directory. -- `layer_icons` maps kanata layer names to custom icons. Custom icons should be placed in `icons` folder in config directory, next to `config.toml`. Accepted icon types on Linux are `.ico`, `.png`, `.jpg`; on Windows only `.ico` is supported. You can assign an icon to special identifier `"*"` to change icon for other layers not specified in `[layer_icons]`. -- On Windows, when providing paths, you need to replace every `\` character with `\\`. Example: `C:\\Users\\ global -> preset_wildcard -> global_wildcard -> default +// +// Returns nil if resolution yields no icon. Caller should then use global default icon. +func (c LayerIcons) IconForLayerName(presetName string, layerName string) []byte { + // preset + preset, ok := c.presetIcons[presetName] + if ok { + if layerIcon, ok := preset.layerIcons[layerName]; ok { + fmt.Printf("Setting icon: preset:%s, layer:%s\n", presetName, layerName) + return layerIcon + } } + // global + layerIcon, ok := c.defaultIcons.layerIcons[layerName] + if ok { + fmt.Printf("Setting icon: preset:*, layer:%s\n", layerName) + return layerIcon + } + // preset_wildcard + if preset != nil && preset.wildcardIcon != nil { + fmt.Printf("Setting icon: preset:%s, layer:*\n", presetName) + return preset.wildcardIcon + } + // global_wildcard + if c.defaultIcons.wildcardIcon != nil { + fmt.Printf("Setting icon: preset:*, layer:*\n") + return c.defaultIcons.wildcardIcon + } + // default + return nil } -func (c LayerIcons) MappedLayers() []string { +func (c LayerIcons) MappedLayers(presetName string) []string { var res []string - for layerName := range c.layerIcons { + for layerName := range c.defaultIcons.layerIcons { + res = append(res, layerName) + } + presetIcons, ok := c.presetIcons[presetName] + if !ok { + // return only layers name in "defaults" section + return res + } + for layerName := range presetIcons.layerIcons { res = append(res, layerName) } return res } -func ResolveIcons(configFolder string, unvalidatedLayerIcons map[string]string, defaultFallbackIcon []byte) LayerIcons { +func ResolveIcons(configFolder string, cfg *config.Config) LayerIcons { customIconsFolder := filepath.Join(configFolder, "icons") - var layerIcons = make(map[string][]byte) - for layerName, unvalidatedIconPath := range unvalidatedLayerIcons { - var path string - if filepath.IsAbs(unvalidatedIconPath) { - path = unvalidatedIconPath + var icons = LayerIcons{ + presetIcons: make(map[string]*LayerIconsForPreset), + defaultIcons: LayerIconsForPreset{ + layerIcons: make(map[string][]byte), + wildcardIcon: nil, + }, + } + for layerName, unvalidatedIconPath := range cfg.PresetDefaults.LayerIcons { + data, err := readIconInFolder(unvalidatedIconPath, customIconsFolder) + if err != nil { + fmt.Printf("defaults - custom icon file can't be read: %v\n", err) + } else if layerName == "*" { + icons.defaultIcons.wildcardIcon = data } else { - path = filepath.Join(customIconsFolder, unvalidatedIconPath) + icons.defaultIcons.layerIcons[layerName] = data } - content, err := os.ReadFile(path) - if err != nil { - fmt.Printf("Custom icon file '%s' can't be accessed: %v\n", path, err) - continue + } + + for m := cfg.Presets.Front(); m != nil; m = m.Next() { + presetName := m.Key + preset := m.Value + for layerName, unvalidatedIconPath := range preset.LayerIcons { + data, err := readIconInFolder(unvalidatedIconPath, customIconsFolder) + if err != nil { + fmt.Printf("Preset '%s' - custom icon file can't be read: %v\n", presetName, err) + } else if layerName == "*" { + icons.presetIcons[presetName].wildcardIcon = data + } else { + icons.presetIcons[presetName].layerIcons[layerName] = data + } } - layerIcons[layerName] = content } + return icons +} - var fallbackIcon []byte - if v, ok := layerIcons["*"]; ok { - fallbackIcon = v - delete(layerIcons, "*") +func readIconInFolder(filePath string, folder string) ([]byte, error) { + var path string + if filepath.IsAbs(filePath) { + path = filePath } else { - fallbackIcon = defaultFallbackIcon + path = filepath.Join(folder, filePath) } - - return LayerIcons{ - layerIcons: layerIcons, - fallbackIcon: fallbackIcon, + content, err := os.ReadFile(path) + if err != nil { + return nil, err } + return content, nil } diff --git a/app/menu_template.go b/app/menu_template.go index bb9ab3c..5692097 100644 --- a/app/menu_template.go +++ b/app/menu_template.go @@ -3,87 +3,84 @@ package app import ( "fmt" "os" - "os/exec" - "path/filepath" "strings" - "github.com/kirsle/configdir" "github.com/rszyma/kanata-tray/config" ) -type MenuTemplate struct { - Configurations []MenuEntry - Executables []MenuEntry -} - -type MenuEntry struct { +type PresetMenuEntry struct { IsSelectable bool - Title string - Tooltip string - Value string + Preset config.Preset + PresetName string } -func MenuTemplateFromConfig(cfg config.Config) MenuTemplate { - var result MenuTemplate +type KanataStatus string + +const ( + statusIdle KanataStatus = "Kanata Status: Not Running (click to run)" + statusStarting KanataStatus = "Kanata Status: Starting..." + statusRunning KanataStatus = "Kanata Status: Running (click to stop)" + statusCrashed KanataStatus = "Kanata Status: Crashed (click to restart)" +) - if cfg.General.IncludeConfigsFromDefaultLocations { - defaultKanataConfig := filepath.Join(configdir.LocalConfig("kanata"), "kanata.kbd") - cfg.Configurations = append(cfg.Configurations, defaultKanataConfig) +func (m *PresetMenuEntry) Title(status KanataStatus) string { + switch status { + case statusIdle: + return "Preset: " + m.PresetName + case statusRunning: + return "> Preset: " + m.PresetName + case statusCrashed: + return "[ERR] Preset: " + m.PresetName } - for i := range cfg.Configurations { - path := cfg.Configurations[i] - expandedPath, err := resolveFilePath(path) - entry := MenuEntry{ - IsSelectable: true, - Title: "Config: " + path, - Tooltip: "Switch to kanata config: " + path, - Value: expandedPath, + return "Preset: " + m.PresetName +} + +func (m *PresetMenuEntry) Tooltip() string { + return "Switch to preset: " + m.PresetName +} + +func MenuTemplateFromConfig(cfg config.Config) ([]PresetMenuEntry, error) { + presets := []PresetMenuEntry{} + + for m := cfg.Presets.Front(); m != nil; m = m.Next() { + presetName := m.Key + preset := m.Value + + // TODO: resolve path here? and put it in value? + // + // Resolve later could be better, since cfg can be also an empty value. + // expandedPath, err := resolveFilePath(*p.CfgPath) + // + // We could also validate path ONLY if it's non empty. + // Because if it's empty, kanata can still search default locations. + // + // But what about kanata executable path? should it be resolved later too? + // Probably not. If we can catch an error here it would be good, because + // we would be able to display it as an error in menu, whereas checking + // when trying to run would only display an error in console. But it's very + // likely that users want to hide console, that's why they use kanata-tray + // in the first place. + + var err error + preset.KanataConfig, err = expandHomeDir(preset.KanataConfig) + if err != nil { + return nil, err } + preset.KanataExecutable, err = expandHomeDir(preset.KanataExecutable) if err != nil { - entry.IsSelectable = false - entry.Title = "[ERR] " + entry.Title - entry.Tooltip = fmt.Sprintf("error: %s", err) - fmt.Printf("Error for kanata config file '%s': %v\n", path, err) + return nil, err } - result.Configurations = append(result.Configurations, entry) - } - if cfg.General.IncludeExecutablesFromSystemPath { - globalKanataPath, err := exec.LookPath("kanata") - if err == nil { - cfg.Executables = append(cfg.Executables, globalKanataPath) - } - } - for i := range cfg.Executables { - path := cfg.Executables[i] - expandedPath, err := resolveFilePath(path) - entry := MenuEntry{ + entry := PresetMenuEntry{ IsSelectable: true, - Title: "Exe: " + path, - Tooltip: "Switch to kanata executable: " + path, - Value: expandedPath, + Preset: *preset, + PresetName: presetName, } - if err != nil { - entry.IsSelectable = false - entry.Title = "[ERR] " + entry.Title - entry.Tooltip = fmt.Sprintf("error: %s", err) - fmt.Printf("Error for kanata exe '%s': %v\n", path, err) - } - result.Executables = append(result.Executables, entry) - } - return result -} - -func resolveFilePath(path string) (string, error) { - path, err := expandHomeDir(path) - if err != nil { - return "", fmt.Errorf("expandHomeDir: %v", err) - } - if _, err := os.Stat(path); os.IsNotExist(err) { - return "", fmt.Errorf("file doesn't exist") + presets = append(presets, entry) } - return path, nil + + return presets, nil } func expandHomeDir(path string) (string, error) { @@ -97,3 +94,14 @@ func expandHomeDir(path string) (string, error) { } return path, nil } + +// func resolveFilePath(path string) (string, error) { +// path, err := expandHomeDir(path) +// if err != nil { +// return "", fmt.Errorf("expandHomeDir: %v", err) +// } +// if _, err := os.Stat(path); os.IsNotExist(err) { +// return "", fmt.Errorf("file doesn't exist") +// } +// return path, nil +// } diff --git a/config/config.go b/config/config.go index 27316db..67c970e 100644 --- a/config/config.go +++ b/config/config.go @@ -1,33 +1,117 @@ package config import ( + "bytes" "fmt" "os" + "strings" + "github.com/elliotchance/orderedmap/v2" "github.com/k0kubun/pp/v3" + "github.com/kr/pretty" "github.com/pelletier/go-toml/v2" + tomlu "github.com/pelletier/go-toml/v2/unstable" ) type Config struct { - Configurations []string `toml:"configurations"` - Executables []string `toml:"executables"` - LayerIcons map[string]string `toml:"layer_icons"` - General GeneralConfigOptions `toml:"general"` + PresetDefaults Preset + General GeneralConfigOptions + Presets *OrderedMap[string, *Preset] +} + +type Preset struct { + Autorun bool + KanataExecutable string + KanataConfig string + TcpPort int + LayerIcons map[string]string +} + +func (m *Preset) GoString() string { + pp.Default.SetColoringEnabled(false) + return pp.Sprintf("%s", m) } type GeneralConfigOptions struct { - IncludeExecutablesFromSystemPath bool `toml:"include_executables_from_system_path"` - IncludeConfigsFromDefaultLocations bool `toml:"include_configs_from_default_locations"` - LaunchOnStart bool `toml:"launch_on_start"` - TcpPort int `toml:"tcp_port"` + AllowConcurrentPresets bool +} + +// ========= +// All golang toml parsers suck :/ + +type config struct { + PresetDefaults *preset `toml:"defaults"` + General *generalConfigOptions `toml:"general"` + Presets map[string]preset `toml:"presets"` +} + +type preset struct { + Autorun *bool `toml:"autorun"` + KanataExecutable *string `toml:"kanata_executable"` + KanataConfig *string `toml:"kanata_config"` + TcpPort *int `toml:"tcp_port"` + LayerIcons map[string]string `toml:"layer_icons"` +} + +func (p *preset) applyDefaults(defaults *preset) { + if p.Autorun == nil { + p.Autorun = defaults.Autorun + } + if p.KanataExecutable == nil { + p.KanataExecutable = defaults.KanataExecutable + } + if p.KanataConfig == nil { + p.KanataConfig = defaults.KanataConfig + } + if p.TcpPort == nil { + p.TcpPort = defaults.TcpPort + } + // This is intended because we layer icons are handled specially. + // + // if p.LayerIcons == nil { + // p.LayerIcons = defaults.LayerIcons + // } +} + +func (p *preset) intoExported() *Preset { + result := &Preset{} + if p.Autorun != nil { + result.Autorun = *p.Autorun + } + if p.KanataExecutable != nil { + result.KanataExecutable = *p.KanataExecutable + } + if p.KanataConfig != nil { + result.KanataConfig = *p.KanataConfig + } + if p.TcpPort != nil { + result.TcpPort = *p.TcpPort + } + if p.LayerIcons != nil { + result.LayerIcons = p.LayerIcons + } + return result +} + +type generalConfigOptions struct { + AllowConcurrentPresets *bool `toml:"allow_concurrent_presets"` } func ReadConfigOrCreateIfNotExist(configFilePath string) (*Config, error) { - var cfg *Config = &Config{} - err := toml.Unmarshal([]byte(defaultCfg), &cfg) + var cfg *config = &config{} + // Golang map don't keep track of insertion order, so we need to get the + // order of declarations in toml separately. + layersNames, err := layersOrder([]byte(defaultCfg)) + if err != nil { + panic(fmt.Errorf("default config failed layersOrder: %v", err)) + } + err = toml.Unmarshal([]byte(defaultCfg), &cfg) if err != nil { - return nil, fmt.Errorf("failed to parse default config: %v", err) + panic(fmt.Errorf("failed to parse default config: %v", err)) } + // temporarily remove default presets + presetsFromDefaultConfig := cfg.Presets + cfg.Presets = nil // Does the file not exist? if _, err := os.Stat(configFilePath); os.IsNotExist(err) { @@ -35,39 +119,161 @@ func ReadConfigOrCreateIfNotExist(configFilePath string) (*Config, error) { os.WriteFile(configFilePath, []byte(defaultCfg), os.FileMode(0600)) } else { // Load the existing file. - fh, err := os.Open(configFilePath) + content, err := os.ReadFile(configFilePath) if err != nil { - return nil, fmt.Errorf("failed to open file '%s': %v", configFilePath, err) + return nil, fmt.Errorf("failed to read file '%s': %v", configFilePath, err) } - defer fh.Close() - decoder := toml.NewDecoder(fh) - err = decoder.Decode(&cfg) + err = toml.NewDecoder(bytes.NewReader(content)).Decode(&cfg) if err != nil { return nil, fmt.Errorf("failed to parse config file '%s': %v", configFilePath, err) } + lnames, err := layersOrder(content) + if err != nil { + panic("default config failed layersOrder") + } + if len(lnames) != 0 { + layersNames = lnames + } + } + + if cfg.Presets == nil { + cfg.Presets = presetsFromDefaultConfig + } + + defaults := cfg.PresetDefaults + + var cfg2 *Config = &Config{ + PresetDefaults: *defaults.intoExported(), + General: GeneralConfigOptions{ + AllowConcurrentPresets: *cfg.General.AllowConcurrentPresets, + }, + Presets: NewOrderedMap[string, *Preset](), } - pp.Println("%v", cfg) - return cfg, nil + for _, layerName := range layersNames { + v, ok := cfg.Presets[layerName] + if !ok { + panic("layer names should match") + } + v.applyDefaults(defaults) + exported := v.intoExported() + cfg2.Presets.Set(layerName, exported) + } + + pretty.Println("loaded config:", cfg2) + return cfg2, nil +} + +// Returns an array of layer names from config in order of declaration. +func layersOrder(cfgContent []byte) ([]string, error) { + layerNamesInOrder := []string{} + + p := tomlu.Parser{} + p.Reset([]byte(cfgContent)) + + // iterate over all top level expressions + for p.NextExpression() { + e := p.Expression() + + if e.Kind != tomlu.Table { + continue + } + + // Let's look at the key. It's an iterator over the multiple dotted parts of the key. + it := e.Key() + parts := keyAsStrings(it) + + // we're only considering keys that look like `presets.XXX` + if len(parts) != 2 { + continue + } + if parts[0] != "presets" { + continue + } + + layerNamesInOrder = append(layerNamesInOrder, string(parts[1])) + } + + return layerNamesInOrder, nil + +} + +// helper to transfor a key iterator to a slice of strings +func keyAsStrings(it tomlu.Iterator) []string { + var parts []string + for it.Next() { + n := it.Node() + parts = append(parts, string(n.Data)) + } + return parts +} + +// var _ tomlu.Unmarshaler = (*OrderedMap[string, preset])(nil) + +// func (m *OrderedMap[string, preset]) UnmarshalTOML(node *tomlu.Node) error { +// fmt.Println(node) +// m = NewOrderedMap[string, preset]() +// // m.Set("asdf", preset{}) +// for iter, ok := node.Key(), true; ok; ok = iter.Next() { +// n := iter.Node() +// fmt.Printf("n.Data: %v\n", n.Data) +// // m.Set(k, v) +// } + +// return nil +// } + +type OrderedMap[K string, V fmt.GoStringer] struct { + *orderedmap.OrderedMap[K, V] +} + +func NewOrderedMap[K string, V fmt.GoStringer]() *OrderedMap[K, V] { + return &OrderedMap[K, V]{ + OrderedMap: orderedmap.NewOrderedMap[K, V](), + } +} + +// impl `fmt.GoStringer` +func (m *OrderedMap[K, V]) GoString() string { + indent := " " + keys := []K{} + values := []V{} + for it := m.Front(); it != nil; it = it.Next() { + keys = append(keys, it.Key) + values = append(values, it.Value) + } + builder := strings.Builder{} + builder.WriteString("{") + for i := range keys { + key := keys[i] + value := values[i] + valueLines := strings.Split(value.GoString(), "\n") + for i, vl := range valueLines { + if i == 0 { + continue + } + valueLines[i] = fmt.Sprintf("%s%s", indent, vl) + } + indentedVal := strings.Join(valueLines, "\n") + builder.WriteString(fmt.Sprintf("\n%s\"%s\": %s", indent, key, indentedVal)) + } + builder.WriteString("\n}") + return builder.String() } var defaultCfg = ` # See https://github.com/rszyma/kanata-tray for help with configuration. +"$schema" = "https://raw.githubusercontent.com/rszyma/kanata-tray/v0.1.0/doc/config_schema.json" -configurations = [ - -] +general.allow_concurrent_presets = false +defaults.tcp_port = 5829 -executables = [ - -] +[defaults.layer_icons] -[layer_icons] +[presets.'Default Preset'] +kanata_executable = '' +kanata_config = '' +autorun = false -[general] -include_executables_from_system_path = true -include_configs_from_default_locations = true -launch_on_start = true -tcp_port = 5829 ` diff --git a/doc/config_schema.json b/doc/config_schema.json new file mode 100644 index 0000000..be991ed --- /dev/null +++ b/doc/config_schema.json @@ -0,0 +1,104 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "preset": { + "type": "object", + "properties": { + "kanata_executable": { + "type": "string", + "description": "A path to a kanata executable." + }, + "kanata_config": { + "type": "string", + "description": "A path to a kanata configuration file. It will be passed as `--cfg=` arg to kanata." + }, + "autorun": { + "type": "boolean", + "description": "Whether the preset will be automatically ran at kanata-tray startup." + }, + "tcp_port": { + "type": "integer", + "description": "A TCP port number. This should generally be between 1000 and 65535. It will be passed as `--port=` arg to kanata." + }, + "layer_icons": { + "type": "object", + "additionalProperties": { + "type": "string", + "description": "A layer name to icon path mapping." + }, + "description": "A map of layer name to icon path mappings." + } + }, + "additionalProperties": false + }, + "preset_with_name": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the preset." + }, + "kanata_executable": { + "type": "string", + "description": "A path to a kanata executable." + }, + "kanata_config": { + "type": "string", + "description": "A path to a kanata configuration file. It will be passed as `--cfg=` arg to kanata." + }, + "autorun": { + "type": "boolean", + "description": "Whether the preset will be automatically ran at kanata-tray startup." + }, + "tcp_port": { + "type": "integer", + "description": "A TCP port number. This should generally be between 1000 and 65535. It will be passed as `--port=` arg to kanata." + }, + "layer_icons": { + "type": "object", + "additionalProperties": { + "type": "string", + "description": "A layer name to icon path mapping." + }, + "description": "A map of layer name to icon path mappings." + } + }, + "additionalProperties": false, + "required": [ + "name" + ] + } + }, + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "general": { + "type": "object", + "properties": { + "allow_concurrent_presets": { + "type": "boolean", + "description": "Toggle for running presets concurrently or stopping before switching to a new one." + } + }, + "additionalProperties": false, + "description": "Options that apply to kanata-tray behavior in general." + }, + "defaults": { + "$ref": "#/definitions/preset" + }, + "presets": { + "type": "array", + "items": { + "$ref": "#/definitions/preset_with_name" + }, + "additionalProperties": false, + "description": "An array of presets that will be available in kanata-tray menu. Each item must have a 'name' field." + } + }, + "additionalProperties": false, + "required": [ + "defaults" + ] +} \ No newline at end of file diff --git a/go.mod b/go.mod index 7e5cd44..f7682fb 100644 --- a/go.mod +++ b/go.mod @@ -9,14 +9,17 @@ require ( ) require ( + github.com/kr/text v0.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.16 // indirect - golang.org/x/text v0.3.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect + golang.org/x/text v0.14.0 // indirect ) require ( + github.com/elliotchance/orderedmap/v2 v2.2.0 github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect - github.com/getlantern/errors v1.0.3 // indirect + github.com/getlantern/errors v1.0.4 // indirect github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 // indirect github.com/getlantern/hex v0.0.0-20220104173244-ad7e4b9194dc // indirect github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 // indirect @@ -25,12 +28,13 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-stack/stack v1.8.1 // indirect github.com/k0kubun/pp/v3 v3.2.0 + github.com/kr/pretty v0.3.1 github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect - github.com/pelletier/go-toml/v2 v2.1.1 - go.opentelemetry.io/otel v1.22.0 // indirect - go.opentelemetry.io/otel/metric v1.22.0 // indirect - go.opentelemetry.io/otel/trace v1.22.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.0 + go.opentelemetry.io/otel v1.24.0 // indirect + go.opentelemetry.io/otel/metric v1.24.0 // indirect + go.opentelemetry.io/otel/trace v1.24.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.26.0 // indirect - golang.org/x/sys v0.16.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/sys v0.18.0 // indirect ) diff --git a/go.sum b/go.sum index dc0a602..4b680f3 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,17 @@ github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/elliotchance/orderedmap/v2 v2.2.0 h1:7/2iwO98kYT4XkOjA9mBEIwvi4KpGB4cyHeOFOnj4Vk= +github.com/elliotchance/orderedmap/v2 v2.2.0/go.mod h1:85lZyVbpGaGvHvnKa7Qhx7zncAdBIBq6u56Hb1PRU5Q= github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY= github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 h1:oEZYEpZo28Wdx+5FZo4aU7JFXu0WG/4wJWese5reQSA= github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201/go.mod h1:Y9WZUHEb+mpra02CbQ/QczLUe6f0Dezxaw5DCJlJQGo= github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A= github.com/getlantern/errors v1.0.1/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A= -github.com/getlantern/errors v1.0.3 h1:Ne4Ycj7NI1BtSyAfVeAT/DNoxz7/S2BUc3L2Ht1YSHE= -github.com/getlantern/errors v1.0.3/go.mod h1:m8C7H1qmouvsGpwQqk/6NUpIVMpfzUPn608aBZDYV04= +github.com/getlantern/errors v1.0.4 h1:i2iR1M9GKj4WuingpNqJ+XQEw6i6dnAgKAmLj6ZB3X0= +github.com/getlantern/errors v1.0.4/go.mod h1:/Foq8jtSDGP8GOXzAjeslsC4Ar/3kB+UiQH+WyV4pzY= github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc= github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 h1:NlQedYmPI3pRAXJb+hLVVDGqfvvXGRPV8vp7XOjKAZ0= github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65/go.mod h1:+ZU1h+iOVqWReBpky6d5Y2WL0sF2Llxu+QcxJFs2+OU= @@ -41,51 +44,62 @@ github.com/k0kubun/pp/v3 v3.2.0/go.mod h1:ODtJQbQcIRfAD3N+theGCV1m/CBxweERz2dapd github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f h1:dKccXx7xA56UNqOcFIbuqFjAWPVtP688j5QMgmo6OHU= github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f/go.mod h1:4rEELDSfUAlBSyUjPG0JnaNGjf13JySHFeRdD/3dLP0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= -github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= -github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo= +github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.opentelemetry.io/otel v1.9.0/go.mod h1:np4EoPGzoPs3O67xUVNoPPcmSvsfOxNlNA4F4AC+0Eo= -go.opentelemetry.io/otel v1.22.0 h1:xS7Ku+7yTFvDfDraDIJVpw7XPyuHlB9MCiqqX5mcJ6Y= -go.opentelemetry.io/otel v1.22.0/go.mod h1:eoV4iAi3Ea8LkAEI9+GFT44O6T/D0GWAVFyZVCC6pMI= -go.opentelemetry.io/otel/metric v1.22.0 h1:lypMQnGyJYeuYPhOM/bgjbFM6WE44W1/T45er4d8Hhg= -go.opentelemetry.io/otel/metric v1.22.0/go.mod h1:evJGjVpZv0mQ5QBRJoBF64yMuOf4xCWdXjK8pzFvliY= +go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= +go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= go.opentelemetry.io/otel/trace v1.9.0/go.mod h1:2737Q0MuG8q1uILYm2YYVkAyLtOofiTNGg6VODnOiPo= -go.opentelemetry.io/otel/trace v1.22.0 h1:Hg6pPujv0XG9QaVbGOBVHunyuLcCC3jN7WEhPx83XD0= -go.opentelemetry.io/otel/trace v1.22.0/go.mod h1:RbbHXVqKES9QhzZq/fE5UnOSILqRt40a21sPw2He1xo= +go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= +go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= -go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= -go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= -go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= -go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -104,13 +118,14 @@ golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= diff --git a/main.go b/main.go index 7e9984f..f787f32 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "flag" "fmt" "os" @@ -12,7 +13,6 @@ import ( "github.com/rszyma/kanata-tray/app" "github.com/rszyma/kanata-tray/config" - "github.com/rszyma/kanata-tray/icons" "github.com/rszyma/kanata-tray/runner" ) @@ -64,14 +64,20 @@ func mainImpl() error { if err != nil { return fmt.Errorf("loading config failed: %v", err) } + menuTemplate, err := app.MenuTemplateFromConfig(*cfg) + if err != nil { + return fmt.Errorf("failed to create menu from config: %v", err) + } + layerIcons := app.ResolveIcons(configFolder, cfg) - menuTemplate := app.MenuTemplateFromConfig(*cfg) - layerIcons := app.ResolveIcons(configFolder, cfg.LayerIcons, icons.Default) - runner := runner.NewKanataRunner() + // Actually we don't really use ctx right now to control kanata-tray termination + // so normal contex without cancel will do. + ctx := context.Background() + runner := runner.NewRunner(ctx) onReady := func() { - app := app.NewSystrayApp(&menuTemplate, layerIcons, cfg.General.TcpPort) - go app.StartProcessingLoop(&runner, cfg.General.LaunchOnStart, configFolder) + app := app.NewSystrayApp(menuTemplate, layerIcons, cfg.General.AllowConcurrentPresets) + go app.StartProcessingLoop(runner, configFolder) } onExit := func() { diff --git a/runner/kanata/kanata.go b/runner/kanata/kanata.go new file mode 100644 index 0000000..ee87f43 --- /dev/null +++ b/runner/kanata/kanata.go @@ -0,0 +1,161 @@ +package kanata + +import ( + "context" + "fmt" + "os" + "os/exec" + "time" + + "github.com/rszyma/kanata-tray/os_specific" + "github.com/rszyma/kanata-tray/runner/tcp_client" +) + +// This struct represents a kanata process slot. +// It can be reused multiple times. +// Reusing with different kanata configs/presets is allowed. +type Kanata struct { + // Prevents race condition when restarting kanata. + // This must be written to, to free an internal slot. + processSlotCh chan struct{} + + retCh chan error // Returns the error returned by `cmd.Wait()` + cmd *exec.Cmd + logFile *os.File + tcpClient *tcp_client.KanataTcpClient +} + +func NewKanataInstance() *Kanata { + return &Kanata{ + processSlotCh: make(chan struct{}, 1), + + retCh: make(chan error), + cmd: nil, + logFile: nil, + tcpClient: tcp_client.NewTcpClient(), + } +} + +func (r *Kanata) RunNonblocking(ctx context.Context, kanataExecutable string, kanataConfig string, tcpPort int) error { + if kanataExecutable == "" { + var err error + kanataExecutable, err = exec.LookPath("kanata") + if err != nil { + return err + } + } + + cfgArg := "" + if kanataConfig != "" { + cfgArg = "-c=" + kanataConfig + } + + cmd := exec.CommandContext(ctx, kanataExecutable, cfgArg, "--port", fmt.Sprint(tcpPort)) + cmd.SysProcAttr = os_specific.ProcessAttr + + go func() { + // We're waiting for previous process to be marked as finished. + // We will know that happens when the process slot becomes writable. + r.processSlotCh <- struct{}{} + + if r.logFile != nil { + r.logFile.Close() + } + var err error + r.logFile, err = os.CreateTemp("", "kanata_lastrun_*.log") + if err != nil { + r.retCh <- fmt.Errorf("failed to create temp log file: %v", err) + return + } + + r.cmd = cmd + r.cmd.Stdout = r.logFile + r.cmd.Stderr = r.logFile + + fmt.Printf("Running command: %s\n", r.cmd.String()) + + err = r.cmd.Start() + if err != nil { + r.retCh <- fmt.Errorf("failed to start process: %v", err) + return + } + + fmt.Printf("Started kanata (pid=%d)\n", r.cmd.Process.Pid) + + tcpConnectionCtx, cancelTcpConnection := context.WithCancel(ctx) + // Need to wait until kanata boot up and setups the TCP server. + // 2000 ms is a default boot delay in kanata. + time.Sleep(time.Millisecond * 2100) + + go func() { + r.tcpClient.Reconnect <- struct{}{} // this shoudn't block, because reconnect chan should have 1-len buffer + // Loop in order to reconnect when kanata disconnects us. + // We might be disconnected if an older version of kanata is used. + for { + select { + case <-tcpConnectionCtx.Done(): + return + case <-r.tcpClient.Reconnect: + err := r.tcpClient.Connect(tcpConnectionCtx, tcpPort) + if err != nil { + fmt.Printf("Failed to connect to kanata via TCP: %v\n", err) + } + } + } + }() + + // Send request for layer names. We may or may not get response + // depending on kanata version). The support for it was implemented in: + // https://github.com/jtroo/kanata/commit/d66c3c77bcb3acbf58188272177d64bed4130b6e + err = r.SendClientMessage(tcp_client.ClientMessage{RequestLayerNames: struct{}{}}) + if err != nil { + fmt.Printf("Failed to send ClientMessage: %v\n", err) + } + + err = r.cmd.Wait() // block until kanata exits + + r.cmd = nil + cancelTcpConnection() + if ctx.Err() != nil { + // A non-nil ctx err means that the kill was issued from outside, + // not the process itself (e.g. crash). + r.retCh <- nil + } else { + // kanata crashed or terminated itself + r.retCh <- err + } + <-r.processSlotCh + }() + + return nil +} + +func (r *Kanata) LogFile() (string, error) { + if r.logFile == nil { + return "", fmt.Errorf("log file doesn't exist") + } + return r.logFile.Name(), nil +} + +func (r *Kanata) RetCh() <-chan error { + return r.retCh +} + +func (r *Kanata) ServerMessageCh() <-chan tcp_client.ServerMessage { + return r.tcpClient.ServerMessageCh() +} + +// If currently there's no opened TCP connection, an error will be returned. +func (r *Kanata) SendClientMessage(msg tcp_client.ClientMessage) error { + timeout := 200 * time.Millisecond + timer := time.NewTimer(timeout) + select { + case <-timer.C: + return fmt.Errorf("timeouted after %d ms", timeout.Milliseconds()) + case r.tcpClient.ClientMessageCh <- msg: + if !timer.Stop() { + <-timer.C + } + } + return nil +} diff --git a/runner/runner.go b/runner/runner.go index 20af482..4e7c31a 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -3,175 +3,164 @@ package runner import ( "context" "fmt" - "os" - "os/exec" - "time" + "sort" + "sync" - "github.com/rszyma/kanata-tray/os_specific" + "github.com/rszyma/kanata-tray/runner/kanata" + "github.com/rszyma/kanata-tray/runner/tcp_client" ) -type KanataRunner struct { - RetCh chan error // Returns the error returned by `cmd.Wait()` - ProcessSlotCh chan struct{} // prevent race condition when restarting kanata +// An item and the preset name for the associated runner. +type ItemAndPresetName[T any] struct { + Item T + PresetName string +} - ctx context.Context - cmd *exec.Cmd - logFile *os.File - manualTermination bool - tcpClient *KanataTcpClient +type Runner struct { + retCh chan ItemAndPresetName[error] + serverMessageCh chan ItemAndPresetName[tcp_client.ServerMessage] + clientMessageChannels map[string]chan tcp_client.ClientMessage + // Maps preset names to runner indices in `runnerPool` and contexts in `instanceWatcherCtxs`. + activeKanataInstances map[string]int + // Number of items in channel denotes the number of running kanata instances. + kanataInstancePool []*kanata.Kanata + instanceWatcherCtxs []context.Context + // Need to have mutex to ensure values in `kanataInstancePool` are not being overwritten + // while a value from `activeKanataInstances` is still "borrowed". + instancesMappingLock sync.Mutex + runnersLimit int } -func NewKanataRunner() KanataRunner { - return KanataRunner{ - RetCh: make(chan error), - // 1 denotes max numer of running kanata processes allowed at a time - ProcessSlotCh: make(chan struct{}, 1), - - ctx: context.Background(), - cmd: nil, - logFile: nil, - manualTermination: false, - tcpClient: NewTcpClient(), +func NewRunner(ctx context.Context) *Runner { + activeInstancesLimit := 10 + return &Runner{ + retCh: make(chan ItemAndPresetName[error]), + serverMessageCh: make(chan ItemAndPresetName[tcp_client.ServerMessage]), + clientMessageChannels: make(map[string]chan tcp_client.ClientMessage), + activeKanataInstances: make(map[string]int), + kanataInstancePool: []*kanata.Kanata{}, + instanceWatcherCtxs: []context.Context{}, + runnersLimit: activeInstancesLimit, } } -// Terminates running kanata process, if there is one. -func (r *KanataRunner) Stop() error { - if r.cmd != nil { - if r.cmd.ProcessState != nil { - // process was already killed from outside? - } else { - r.manualTermination = true - fmt.Println("Killing the currently running kanata process...") - err := r.cmd.Process.Kill() - if err != nil { - return fmt.Errorf("cmd.Process.Kill failed: %v", err) +// Run a new kanata instance from a preset. Blocks until the process is started. +// Calling Run when there's a previous preset running with the the same +// presetName will block until the previous process finishes. +// To stop running preset, caller needs to cancel ctx. +func (r *Runner) Run(ctx context.Context, presetName string, kanataExecutable string, kanataConfig string, tcpPort int) error { + r.instancesMappingLock.Lock() + defer r.instancesMappingLock.Unlock() + + var instanceIndex int + + // First check if there's an instance for the given preset already running. + // If yes, then reuse it. Otherwise reuse free instance if any is available, + // or create a new Kanata instance. + + if i, ok := r.activeKanataInstances[presetName]; ok { + // reuse (restart) at index + instanceIndex = i + } else if len(r.activeKanataInstances) < len(r.kanataInstancePool) { + // reuse first free instance + activeInstanceIndices := []int{} + for _, i := range r.activeKanataInstances { + activeInstanceIndices = append(activeInstanceIndices, i) + } + sort.Ints(activeInstanceIndices) + for i := 0; i < len(r.kanataInstancePool); i++ { + if i >= len(activeInstanceIndices) { + instanceIndex = i + break + } + if activeInstanceIndices[i] > i { + // kanataInstancePool at index `i` is unused + instanceIndex = i + break } } - } - return nil -} - -func (r *KanataRunner) CleanupLogs() error { - if r.cmd != nil && r.cmd.ProcessState == nil { - return fmt.Errorf("tried to cleanup logs while kanata process is still running") - } - - if r.logFile != nil { - os.RemoveAll(r.logFile.Name()) - r.logFile.Close() - r.logFile = nil + } else { + // create new instance + if len(r.activeKanataInstances) >= r.runnersLimit { + return fmt.Errorf("active instances limit exceeded") + } + r.kanataInstancePool = append(r.kanataInstancePool, kanata.NewKanataInstance()) + instanceIndex = len(r.kanataInstancePool) - 1 } - return nil -} - -func (r *KanataRunner) RunNonblocking(kanataExecutablePath string, kanataConfigPath string, tcpPort int) error { - err := r.Stop() + instance := r.kanataInstancePool[instanceIndex] + err := instance.RunNonblocking(ctx, kanataExecutable, kanataConfig, tcpPort) if err != nil { - return fmt.Errorf("failed to stop the previous process: %v", err) + return fmt.Errorf("failed to run kanata: %v", err) } - - cmd := exec.CommandContext(r.ctx, kanataExecutablePath, "-c", kanataConfigPath, "--port", fmt.Sprint(tcpPort)) - cmd.SysProcAttr = os_specific.ProcessAttr + r.activeKanataInstances[presetName] = instanceIndex + r.clientMessageChannels[presetName] = make(chan tcp_client.ClientMessage) go func() { - // We're waiting for previous process to be marked as finished in processing loop. - // We will know that happens when the process slot becomes writable. - r.ProcessSlotCh <- struct{}{} - - err = r.CleanupLogs() - if err != nil { - // This is non-critical, we can probably continue operating normally. - fmt.Printf("WARN: process logs cleanup failed: %v\n", err) - } - - r.logFile, err = os.CreateTemp("", "kanata_lastrun_*.log") - if err != nil { - r.RetCh <- fmt.Errorf("failed to create temp file: %v", err) - return - } - - r.cmd = cmd - r.cmd.Stdout = r.logFile - r.cmd.Stderr = r.logFile - - fmt.Printf("Running command: %s\n", r.cmd.String()) - - err = r.cmd.Start() - if err != nil { - r.RetCh <- fmt.Errorf("failed to start process: %v", err) - return - } + <-ctx.Done() + r.instancesMappingLock.Lock() + defer r.instancesMappingLock.Unlock() + delete(r.activeKanataInstances, presetName) + delete(r.clientMessageChannels, presetName) + }() - fmt.Printf("Started kanata (pid=%d)\n", r.cmd.Process.Pid) - - tcpConnectionCtx, cancelTcpConnection := context.WithCancel(r.ctx) - // Need to wait until kanata boot up and setups the TCP server. - // 2000 ms is default boot delay in kanata. - time.Sleep(time.Millisecond * 2100) - - go func() { - r.tcpClient.reconnect <- struct{}{} // this shoudn't block, because reconnect chan should have 1-len buffer - // Loop in order to reconnect when kanata disconnects us. - // We might be disconnected if an older version of kanata is used. - for { - select { - case <-tcpConnectionCtx.Done(): - return - case <-r.tcpClient.reconnect: - err := r.tcpClient.Connect(tcpConnectionCtx, tcpPort) - if err != nil { - fmt.Printf("Failed to connect to kanata via TCP: %v\n", err) - } + go func() { + retCh := instance.RetCh() + serverMessageCh := instance.ServerMessageCh() + clientMesasgeCh := r.clientMessageChannels[presetName] + for { + select { + case ret := <-retCh: + r.retCh <- ItemAndPresetName[error]{ + Item: ret, + PresetName: presetName, } + return + case msg := <-serverMessageCh: + r.serverMessageCh <- ItemAndPresetName[tcp_client.ServerMessage]{ + Item: msg, + PresetName: presetName, + } + case msg := <-clientMesasgeCh: + instance.SendClientMessage(msg) } - }() - - // Send request for layer names. We may or may not get response - // depending on kanata version). The support for it was implemented in: - // https://github.com/jtroo/kanata/commit/d66c3c77bcb3acbf58188272177d64bed4130b6e - err = r.SendClientMessage(ClientMessage{RequestLayerNames: struct{}{}}) - if err != nil { - fmt.Printf("Failed to send ClientMessage: %v\n", err) - } - - err = r.cmd.Wait() - r.cmd = nil - cancelTcpConnection() - if r.manualTermination { - r.manualTermination = false - r.RetCh <- nil - } else { - r.RetCh <- err } }() return nil } -func (r *KanataRunner) LogFile() (string, error) { - if r.logFile == nil { - return "", fmt.Errorf("log file doesn't exist") +// An error will be returned if a preset doesn't exists or there's currently no +// opened TCP connection for the given preset. +// +// FIXME: message can be sent to a wrong kanata process during live-reloading +// if a preset has been changed but there's a preset with the same name as in +// previous kanata-tray configuration. Unlikely to ever happen though +// (also live-reloading is not implemented at the time of writing). +func (r *Runner) SendClientMessage(presetName string, msg tcp_client.ClientMessage) error { + r.instancesMappingLock.Lock() + defer r.instancesMappingLock.Unlock() + presetIndex, ok := r.activeKanataInstances[presetName] + if !ok { + return fmt.Errorf("preset with the given name not found") } - return r.logFile.Name(), nil + return r.kanataInstancePool[presetIndex].SendClientMessage(msg) } -func (r *KanataRunner) ServerMessageCh() chan ServerMessage { - return r.tcpClient.ServerMessageCh +func (r *Runner) RetCh() <-chan ItemAndPresetName[error] { + return r.retCh } -// If currently there's no opened TCP connection, an error will be returned. -func (r *KanataRunner) SendClientMessage(msg ClientMessage) error { - timeout := 200 * time.Millisecond - timer := time.NewTimer(timeout) - select { - case <-timer.C: - return fmt.Errorf("timeouted after %d ms", timeout.Milliseconds()) - case r.tcpClient.clientMessageCh <- msg: - if !timer.Stop() { - <-timer.C - } +func (r *Runner) ServerMessageCh() <-chan ItemAndPresetName[tcp_client.ServerMessage] { + return r.serverMessageCh +} + +func (r *Runner) LogFile(presetName string) (string, error) { + r.instancesMappingLock.Lock() + defer r.instancesMappingLock.Unlock() + presetIndex, ok := r.activeKanataInstances[presetName] + if !ok { + return "", fmt.Errorf("preset with the given name not found") } - return nil + return r.kanataInstancePool[presetIndex].LogFile() } diff --git a/runner/tcp_client.go b/runner/tcp_client/tcp_client.go similarity index 83% rename from runner/tcp_client.go rename to runner/tcp_client/tcp_client.go index c748fa6..9ed77ac 100644 --- a/runner/tcp_client.go +++ b/runner/tcp_client/tcp_client.go @@ -1,4 +1,4 @@ -package runner +package tcp_client import ( "bufio" @@ -12,11 +12,10 @@ import ( ) type KanataTcpClient struct { - ServerMessageCh chan ServerMessage // shouldn't be written to from outside + ClientMessageCh chan ClientMessage + Reconnect chan struct{} - clientMessageCh chan ClientMessage - - reconnect chan struct{} + serverMessageCh chan ServerMessage // shouldn't be written to from outside mu sync.Mutex // allow only 1 conn at a time conn net.Conn @@ -25,9 +24,9 @@ type KanataTcpClient struct { func NewTcpClient() *KanataTcpClient { c := &KanataTcpClient{ - ServerMessageCh: make(chan ServerMessage), - clientMessageCh: make(chan ClientMessage), - reconnect: make(chan struct{}, 1), + ClientMessageCh: make(chan ClientMessage), + Reconnect: make(chan struct{}, 1), + serverMessageCh: make(chan ServerMessage), mu: sync.Mutex{}, dialer: net.Dialer{ Timeout: time.Second * 3, @@ -51,7 +50,7 @@ func (c *KanataTcpClient) Connect(ctx context.Context, port int) error { select { case <-ctxSend.Done(): return - case msg := <-c.clientMessageCh: + case msg := <-c.ClientMessageCh: msgBytes := msg.Bytes() _, err := c.conn.Write(msgBytes) if err != nil { @@ -72,7 +71,7 @@ func (c *KanataTcpClient) Connect(ctx context.Context, port int) error { // do not change the following condition (because of cross-version compability) if bytes.Contains(msgBytes, []byte("you sent an invalid message")) { fmt.Printf("Kanata disconnected us because we supposedly sent an 'invalid message' (kanata version is too old?)\n") - c.reconnect <- struct{}{} + c.Reconnect <- struct{}{} return } var msg ServerMessage @@ -81,7 +80,7 @@ func (c *KanataTcpClient) Connect(ctx context.Context, port int) error { fmt.Printf("tcp client: failed to unmarshal message '%s': %v\n", string(msgBytes), err) continue } - c.ServerMessageCh <- msg + c.serverMessageCh <- msg } if err := scanner.Err(); err != nil { fmt.Printf("tcp client: failed to read stream: %v\n", err) @@ -90,6 +89,10 @@ func (c *KanataTcpClient) Connect(ctx context.Context, port int) error { return nil } +func (c *KanataTcpClient) ServerMessageCh() <-chan ServerMessage { + return c.serverMessageCh +} + type ClientMessage struct { RequestLayerNames struct{} `json:"RequestLayerNames"` }