From 62d30d009a5c0fb559d5ef18b348497a65e91cc7 Mon Sep 17 00:00:00 2001 From: rszyma Date: Wed, 6 Mar 2024 23:33:24 +0100 Subject: [PATCH 01/14] doc: add config schema --- doc/config_schema.json | 65 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 doc/config_schema.json diff --git a/doc/config_schema.json b/doc/config_schema.json new file mode 100644 index 0000000..2347a3c --- /dev/null +++ b/doc/config_schema.json @@ -0,0 +1,65 @@ +{ + "$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": "An array of layer name to icon path mappings." + } + }, + "additionalProperties": false, + "description": "Preset defines the settings that kanata will be ran with when the preset gets selected in kanata-tray menu." + } + }, + "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", + "description": "You can override default preset fields here." + }, + "presets": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/preset" + }, + "description": "Defines presets that will be available in kanata-tray menu." + } + }, + "additionalProperties": false +} \ No newline at end of file From e26dce506b6cee4a7ed736d8b8cdef363ef76ff2 Mon Sep 17 00:00:00 2001 From: rszyma Date: Sat, 9 Mar 2024 14:37:53 +0100 Subject: [PATCH 02/14] feat: allow running multiple kanata instances concurrently --- .gitignore | 4 +- README.md | 10 + app/app.go | 288 ++++++++++++-------------- app/custom_icons.go | 126 ++++++++--- app/menu_template.go | 105 +++++----- config/config.go | 62 ++++-- main.go | 12 +- runner/kanata/kanata.go | 181 ++++++++++++++++ runner/runner.go | 259 +++++++++++------------ runner/{ => tcp_client}/tcp_client.go | 25 ++- 10 files changed, 648 insertions(+), 424 deletions(-) create mode 100644 runner/kanata/kanata.go rename runner/{ => tcp_client}/tcp_client.go (83%) diff --git a/.gitignore b/.gitignore index 53c37a1..ae1891f 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/README.md b/README.md index 990ec3b..61f1ee4 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,16 @@ You can access it from from: `Click Tray Icon > Options`. On Linux, the config folder location is `~/.config/kanata-tray`. On Windows, it's `C:\Users\\AppData\Roaming\kanata-tray` +### Config completion in editors + +In VSCode to get editor support for your kanata-tray config, install [Even Better TOML](https://marketplace.visualstudio.com/items?itemName=tamasfe.even-better-toml#completion-and-validation-with-json-schema) extension and the following line at the top of your `config.toml` file. +```toml +"$schema" = "https://raw.githubusercontent.com/rszyma/kanata-tray/v0.2.0/doc/config_schema.json" +``` +Make sure to replace version number in the schema link with whatever kanata-tray version you use. + +### Examples + An example of customized configuration file: ```toml diff --git a/app/app.go b/app/app.go index 7f7a2b2..6bb50e5 100644 --- a/app/app.go +++ b/app/app.go @@ -10,79 +10,42 @@ import ( runner_pkg "github.com/rszyma/kanata-tray/runner" ) -const ( - statusIdle = "Kanata Status: Not Running (click to run)" - statusRunning = "Kanata Status: Running (click to stop)" - statusCrashed = "Kanata Status: Crashed (click to restart)" -) - -const selectedItemPrefix = "> " - type SysTrayApp struct { - menuTemplate *MenuTemplate - layerIcons LayerIcons - selectedConfig int - selectedExec int - tcpPort int - - cfgChangeCh chan int - exeChangeCh chan int + concurrentPresets bool - // Menu items + presets []PresetMenuEntry + statuses []KanataStatus - mStatus *systray.MenuItem - runnerStatus string + layerIcons LayerIcons + tcpPort int - mOpenKanataLogFile *systray.MenuItem + presetClickedCh chan int // the value sent in channel is an index of preset - mConfigs []*systray.MenuItem - mExecs []*systray.MenuItem + // Menu items + mPresets []*systray.MenuItem mOptions *systray.MenuItem mQuit *systray.MenuItem } -func NewSystrayApp(menuTemplate *MenuTemplate, layerIcons LayerIcons, tcpPort int) *SysTrayApp { - t := &SysTrayApp{menuTemplate: menuTemplate, layerIcons: layerIcons, selectedConfig: -1, selectedExec: -1, tcpPort: tcpPort} +func NewSystrayApp(menuTemplate []PresetMenuEntry, layerIcons LayerIcons, allowConcurrentPresets bool, tcpPort int) *SysTrayApp { + t := &SysTrayApp{ + presets: menuTemplate, + layerIcons: layerIcons, + concurrentPresets: allowConcurrentPresets, + tcpPort: tcpPort, + } systray.SetIcon(icons.Default) systray.SetTitle("kanata-tray") systray.SetTooltip("kanata-tray") - t.mStatus = systray.AddMenuItem(statusIdle, statusIdle) - t.runnerStatus = statusIdle - - t.mOpenKanataLogFile = systray.AddMenuItem("See Crash Log", "open location of the kanata log") - t.mOpenKanataLogFile.Hide() - - systray.AddSeparator() - - for i, entry := range menuTemplate.Configurations { - menuItem := systray.AddMenuItem(entry.Title, entry.Tooltip) - t.mConfigs = append(t.mConfigs, menuItem) - if entry.IsSelectable { - if t.selectedConfig == -1 { - menuItem.SetTitle(selectedItemPrefix + entry.Title) - t.selectedConfig = i - } - } else { - menuItem.Disable() - } - } - - systray.AddSeparator() - - for i, entry := range menuTemplate.Executables { - menuItem := systray.AddMenuItem(entry.Title, entry.Tooltip) - t.mExecs = append(t.mExecs, menuItem) - if entry.IsSelectable { - if t.selectedExec == -1 { - menuItem.SetTitle(selectedItemPrefix + entry.Title) - t.selectedExec = i - } - } else { + for _, entry := range menuTemplate { + menuItem := systray.AddMenuItem(entry.Title(statusIdle), entry.Tooltip()) + if !entry.IsSelectable { menuItem.Disable() } + t.mPresets = append(t.mPresets, menuItem) } systray.AddSeparator() @@ -90,92 +53,65 @@ func NewSystrayApp(menuTemplate *MenuTemplate, layerIcons LayerIcons, tcpPort in t.mOptions = systray.AddMenuItem("Options", "Reveals kanata-tray config file") t.mQuit = systray.AddMenuItem("Exit tray", "Closes kanata (if running) and exits the tray") - t.cfgChangeCh = multipleMenuItemsClickListener(t.mConfigs) - t.exeChangeCh = multipleMenuItemsClickListener(t.mExecs) + t.presetClickedCh = multipleMenuItemsClickListener(t.mPresets) return t } -func (t *SysTrayApp) switchConfigAndRun(index int, runner *runner_pkg.KanataRunner) { - oldIndex := t.selectedConfig - t.selectedConfig = index - oldEntry := t.menuTemplate.Configurations[oldIndex] - newEntry := t.menuTemplate.Configurations[index] - fmt.Printf("Switching kanata config to '%s'\n", newEntry.Value) - - // Remove selectedItemPrefix from previously selected item's title. - t.mConfigs[oldIndex].SetTitle(oldEntry.Title) - - t.mConfigs[index].SetTitle(selectedItemPrefix + newEntry.Title) - - t.runWithSelectedOptions(runner) -} - -func (t *SysTrayApp) switchExeAndRun(index int, runner *runner_pkg.KanataRunner) { - oldIndex := t.selectedExec - t.selectedExec = index - oldEntry := t.menuTemplate.Executables[oldIndex] - newEntry := t.menuTemplate.Executables[index] - fmt.Printf("Switching kanata executable to '%s'\n", newEntry.Value) - - // Remove selectedItemPrefix from previously selected item's title. - t.mExecs[oldIndex].SetTitle(oldEntry.Title) - - t.mExecs[index].SetTitle(selectedItemPrefix + newEntry.Title) - - t.runWithSelectedOptions(runner) -} - -func (t *SysTrayApp) runWithSelectedOptions(runner *runner_pkg.KanataRunner) { - t.mOpenKanataLogFile.Hide() - - if t.selectedExec == -1 { - fmt.Println("failed to run: no kanata executables available") - return - } - - if t.selectedConfig == -1 { - fmt.Println("failed to run: no kanata configs available") - return +func (t *SysTrayApp) runPreset(presetIndex int, runner *runner_pkg.Runner) { + if t.concurrentPresets { + fmt.Printf("Switching preset to '%s'\n", t.presets[presetIndex].PresetName) + } else { + fmt.Printf("Running preset '%s'\n", t.presets[presetIndex].PresetName) } - + t.statuses[presetIndex] = statusStarting + t.mPresets[presetIndex].SetTitle(t.presets[presetIndex].Title(statusStarting)) systray.SetIcon(icons.Default) - execPath := t.menuTemplate.Executables[t.selectedExec].Value - configPath := t.menuTemplate.Configurations[t.selectedConfig].Value - err := runner.RunNonblocking(execPath, configPath, t.tcpPort) + kanataExecutable := t.presets[presetIndex].Preset.KanataExecutable + kanataConfig := t.presets[presetIndex].Preset.KanataConfig + err := runner.Run(t.presets[presetIndex].PresetName, kanataExecutable, kanataConfig, t.tcpPort) if err != nil { fmt.Printf("runner.Run failed with: %v\n", err) - t.runnerStatus = statusCrashed - t.mStatus.SetTitle(statusCrashed) + t.statuses[presetIndex] = statusCrashed + t.mPresets[presetIndex].SetTitle(t.presets[presetIndex].Title(statusCrashed)) return } - t.runnerStatus = statusRunning - t.mStatus.SetTitle(statusRunning) + t.statuses[presetIndex] = statusRunning + t.mPresets[presetIndex].SetTitle(t.presets[presetIndex].Title(statusStarting)) } -func (app *SysTrayApp) StartProcessingLoop(runner *runner_pkg.KanataRunner, runRightAway bool, configFolder string) { - if runRightAway { - app.runWithSelectedOptions(runner) - } else { - systray.SetIcon(icons.Pause) +func (app *SysTrayApp) StartProcessingLoop(runner *runner_pkg.Runner, allowConcurrentPresets bool, configFolder string) { + systray.SetIcon(icons.Pause) + for i, preset := range app.presets { + if preset.Preset.Autorun { + app.runPreset(i, runner) + if allowConcurrentPresets { + // Execute only the first preset if multi-exec is disabled. + break + } + } } serverMessageCh := runner.ServerMessageCh() + serverRetCh := runner.RetCh() for { select { case event := <-serverMessageCh: // fmt.Printf("Received an event from kanata: %v\n", pp.Sprint(event)) - if event.LayerChange != nil { - icon := app.layerIcons.IconForLayerName(event.LayerChange.NewLayer) + if event.Item.LayerChange != nil { + icon := app.layerIcons.IconForLayerName(event.PresetName, event.Item.LayerChange.NewLayer) + if icon == nil { + icon = icons.Default + } systray.SetIcon(icon) } - if event.LayerNames != nil { - mappedLayers := app.layerIcons.MappedLayers() + if event.Item.LayerNames != nil { + mappedLayers := app.layerIcons.MappedLayers(event.PresetName) for _, mappedLayerName := range mappedLayers { found := false - for _, kanataLayerName := range event.LayerNames.Names { + for _, kanataLayerName := range event.Item.LayerNames.Names { if mappedLayerName == kanataLayerName { found = true break @@ -186,67 +122,105 @@ func (app *SysTrayApp) StartProcessingLoop(runner *runner_pkg.KanataRunner, runR } } } - case err := <-runner.RetCh: + case ret := <-serverRetCh: + err := ret.Item + i, err1 := app.indexFromPresetName(ret.PresetName) + if err1 != nil { + fmt.Printf("ERROR: Preset not found: %s\n", ret.PresetName) + continue + } if err != nil { fmt.Printf("Kanata process terminated with an error: %v\n", err) - app.runnerStatus = statusCrashed - app.mStatus.SetTitle(statusCrashed) - app.mOpenKanataLogFile.Show() + app.statuses[i] = statusCrashed + app.mPresets[i].SetTitle(app.presets[i].Title(statusCrashed)) systray.SetIcon(icons.Crash) } else { fmt.Println("Kanata process terminated successfully") - } - <-runner.ProcessSlotCh // free 1 slot - case <-app.mStatus.ClickedCh: - switch app.runnerStatus { - case statusIdle: - // run kanata - app.runWithSelectedOptions(runner) - case statusRunning: - // stop kanata - err := runner.Stop() - if err != nil { - fmt.Printf("Failed to stop kanata process: %v", err) + app.statuses[i] = statusIdle + app.mPresets[i].SetTitle(app.presets[i].Title(statusIdle)) + if app.isAnyPresetRunning() { + systray.SetIcon(icons.Default) } else { - app.runnerStatus = statusIdle - app.mStatus.SetTitle(statusIdle) + // no running presets systray.SetIcon(icons.Pause) } - case statusCrashed: - // restart kanata - fmt.Println("Restarting kanata") - app.runWithSelectedOptions(runner) } - case <-app.mOpenKanataLogFile.ClickedCh: - logFile, err := runner.LogFile() - if err != nil { - fmt.Printf("Can't open log file: %v\n", err) - } else { - fmt.Printf("Opening log file '%s'\n", logFile) - open.Start(logFile) - } - case i := <-app.cfgChangeCh: - app.switchConfigAndRun(i, runner) - case i := <-app.exeChangeCh: - app.switchExeAndRun(i, runner) + + // TODO: is this even needed anymore? We don't even validate if that's process slot for the preset in `ret.PresetName`. + <-runner.ProcessSlotCh // free 1 slot + // case <-app.mStatus.ClickedCh: + // switch app.runnerStatus { + // case statusIdle: + // // run kanata + // app.runPreset(runner) + // case statusRunning: + // // stop kanata + // err := runner.StopNonblocking() + // if err != nil { + // fmt.Printf("Failed to stop kanata process: %v", err) + // } else { + // app.runnerStatus = statusIdle + // app.mStatus.SetTitle(statusIdle) + // systray.SetIcon(icons.Pause) + // } + // case statusCrashed: + // // restart kanata + // fmt.Println("Restarting kanata") + // app.runPreset(runner) + // } + // case <-app.mOpenKanataLogFile.ClickedCh: + // logFile, err := runner.LogFile() + // if err != nil { + // fmt.Printf("Can't open log file: %v\n", err) + // } else { + // fmt.Printf("Opening log file '%s'\n", logFile) + // open.Start(logFile) + // } + case i := <-app.presetClickedCh: + app.runPreset(i, runner) case <-app.mOptions.ClickedCh: open.Start(configFolder) case <-app.mQuit.ClickedCh: fmt.Println("Exiting...") - err := runner.Stop() - if err != nil { - fmt.Printf("failed to stop kanata process: %v", err) + for _, preset := range app.presets { + err := runner.StopNonblocking(preset.PresetName) + if err != nil { + fmt.Printf("failed to stop kanata process: %v", err) + } } - err = runner.CleanupLogs() - if err != nil { - fmt.Printf("failed to cleanup logs: %v", err) + for _, preset := range app.presets { + // When ProcessSlotCh becomes writable, it mean that the process + // was successfully stopped. + runner.ProcessSlotCh <- runner_pkg.ItemAndPresetName[struct{}]{ + Item: struct{}{}, + PresetName: preset.PresetName, + } } + systray.Quit() return } } } +func (t *SysTrayApp) indexFromPresetName(presetName string) (int, error) { + for i, p := range t.presets { + if p.PresetName == presetName { + return i, nil + } + } + return 0, fmt.Errorf("not found") +} + +func (t *SysTrayApp) isAnyPresetRunning() bool { + for _, status := range t.statuses { + if status == statusRunning { + return true + } + } + return false +} + // Returns a channel that sends an index of item that was clicked. // TODO: pass ctx and cleanup on ctx cancel. func multipleMenuItemsClickListener(menuItems []*systray.MenuItem) chan int { diff --git a/app/custom_icons.go b/app/custom_icons.go index d4bd25b..088c8b7 100644 --- a/app/custom_icons.go +++ b/app/custom_icons.go @@ -4,59 +4,123 @@ import ( "fmt" "os" "path/filepath" + + "github.com/rszyma/kanata-tray/config" ) type LayerIcons struct { + presetIcons map[string]*LayerIconsForPreset + defaultIcons LayerIconsForPreset +} + +func newLayerIcons() LayerIcons { + return LayerIcons{ + presetIcons: make(map[string]*LayerIconsForPreset), + defaultIcons: LayerIconsForPreset{ + layerIcons: make(map[string][]byte), + wildcardIcon: []byte{}, + }, + } +} + +type LayerIconsForPreset struct { layerIcons map[string][]byte - fallbackIcon []byte + wildcardIcon []byte // can be nil } -func (c LayerIcons) IconForLayerName(layerName string) []byte { - if v, ok := c.layerIcons[layerName]; ok { - fmt.Printf("Icon for layer '%s'\n", layerName) - return v - } else { - fmt.Printf("Fallback icon for layer '%s'\n", layerName) - return c.fallbackIcon +// Order of resolution: +// preset -> 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.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("Custom icon file can't be accessed: %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 presetName := range cfg.Presets { + for layerName, unvalidatedIconPath := range cfg.Presets[presetName].LayerIcons { + data, err := readIconInFolder(unvalidatedIconPath, customIconsFolder) + if err != nil { + fmt.Printf("Custom icon file can't be accessed: %v\n", 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, fmt.Errorf("'%s': %v\n", path, err) } + return content, nil } diff --git a/app/menu_template.go b/app/menu_template.go index bb9ab3c..3dea30a 100644 --- a/app/menu_template.go +++ b/app/menu_template.go @@ -3,76 +3,71 @@ 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 - if cfg.General.IncludeConfigsFromDefaultLocations { - defaultKanataConfig := filepath.Join(configdir.LocalConfig("kanata"), "kanata.kbd") - cfg.Configurations = append(cfg.Configurations, defaultKanataConfig) - } - 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, - } - 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) - } - result.Configurations = append(result.Configurations, entry) - } +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.IncludeExecutablesFromSystemPath { - globalKanataPath, err := exec.LookPath("kanata") - if err == nil { - cfg.Executables = append(cfg.Executables, globalKanataPath) - } +func (m *PresetMenuEntry) Title(status KanataStatus) string { + switch status { + case statusIdle: + return "Config: " + m.PresetName + case statusRunning: + return "> Config: " + m.PresetName + case statusCrashed: + return "[ERR] Config: " + m.PresetName } - for i := range cfg.Executables { - path := cfg.Executables[i] - expandedPath, err := resolveFilePath(path) - entry := MenuEntry{ + return "Config: " + m.PresetName +} + +func (m *PresetMenuEntry) Tooltip() string { + return "Switch to kanata config: " + m.PresetName +} + +func MenuTemplateFromConfig(cfg config.Config) []PresetMenuEntry { + presets := []PresetMenuEntry{} + + for presetName, preset := range cfg.Presets { + // 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. + + entry := PresetMenuEntry{ IsSelectable: true, - Title: "Exe: " + path, - Tooltip: "Switch to kanata executable: " + path, - Value: expandedPath, - } - 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) + Preset: preset, + PresetName: presetName, } - result.Executables = append(result.Executables, entry) + + presets = append(presets, entry) } - return result + return presets } func resolveFilePath(path string) (string, error) { diff --git a/config/config.go b/config/config.go index 27316db..27e7e14 100644 --- a/config/config.go +++ b/config/config.go @@ -8,23 +8,46 @@ import ( "github.com/pelletier/go-toml/v2" ) +type partialConfigJustDefaults struct { + PresetDefaults Preset `toml:"defaults"` +} + type Config struct { - Configurations []string `toml:"configurations"` - Executables []string `toml:"executables"` - LayerIcons map[string]string `toml:"layer_icons"` - General GeneralConfigOptions `toml:"general"` + partialConfigJustDefaults + General GeneralConfigOptions `toml:"general"` + Presets map[string]Preset `toml:"presets"` } 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 `toml:"allow_concurrent_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"` +} + +var defaults *partialConfigJustDefaults = nil + +func (c *Preset) UnmarshalTOML(text []byte) error { + if defaults != nil { + c = &defaults.PresetDefaults + } + return toml.Unmarshal(text, c) } func ReadConfigOrCreateIfNotExist(configFilePath string) (*Config, error) { + defaults = &partialConfigJustDefaults{} + err := toml.Unmarshal([]byte(defaultCfg), defaults) + if err != nil { + return nil, fmt.Errorf("failed to parse default config: %v", err) + } + var cfg *Config = &Config{} - err := toml.Unmarshal([]byte(defaultCfg), &cfg) + err = toml.Unmarshal([]byte(defaultCfg), &cfg) if err != nil { return nil, fmt.Errorf("failed to parse default config: %v", err) } @@ -47,27 +70,24 @@ func ReadConfigOrCreateIfNotExist(configFilePath string) (*Config, error) { } } + pp.Println("%v", defaults) pp.Println("%v", cfg) return cfg, nil } 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 = [ - -] - -executables = [ - -] +general.allow_concurrent_presets = false -[layer_icons] +[defaults.layer_icons] -[general] -include_executables_from_system_path = true -include_configs_from_default_locations = true -launch_on_start = true +[presets.'Default Preset'] +kanata_executable = '' +kanata_config = '' +autorun = false tcp_port = 5829 + ` diff --git a/main.go b/main.go index 7e9984f..910fe4c 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" ) @@ -66,12 +66,14 @@ func mainImpl() error { } menuTemplate := app.MenuTemplateFromConfig(*cfg) - layerIcons := app.ResolveIcons(configFolder, cfg.LayerIcons, icons.Default) - runner := runner.NewKanataRunner() + layerIcons := app.ResolveIcons(configFolder, cfg) + + ctx := context.Background() // actually we don't really use ctx right now + runner := runner.NewRunner(ctx, cfg.General.AllowConcurrentPresets) 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, 12313) + go app.StartProcessingLoop(runner, cfg.General.AllowConcurrentPresets, configFolder) } onExit := func() { diff --git a/runner/kanata/kanata.go b/runner/kanata/kanata.go new file mode 100644 index 0000000..93d1150 --- /dev/null +++ b/runner/kanata/kanata.go @@ -0,0 +1,181 @@ +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 from from outside to free the internal slot. + ProcessSlotCh chan struct{} + + retCh chan error // Returns the error returned by `cmd.Wait()` + ctx context.Context + cmd *exec.Cmd + logFile *os.File + manualTermination bool + tcpClient *tcp_client.KanataTcpClient +} + +func NewKanataInstance(ctx context.Context) *Kanata { + return &Kanata{ + ProcessSlotCh: make(chan struct{}, 1), + + retCh: make(chan error), + ctx: ctx, + cmd: nil, + logFile: nil, + manualTermination: false, + tcpClient: tcp_client.NewTcpClient(), + } +} + +// Terminates the running kanata process, if there is one. It doesn't wait for +// the process to actually stop. If you want to be sure that the process has been +// stopped, try writing to `ProcessSlotCh`. Make sure to pop back the item +// from `ProcessSlotCh` if you want to be able to reuse this struct after that. +func (r *Kanata) StopNonblocking() 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) + } + } + } + return nil +} + +func (r *Kanata) RunNonblocking(kanataExecutable string, kanataConfig string, tcpPort int) error { + err := r.StopNonblocking() + if err != nil { + return fmt.Errorf("failed to stop the previous process: %v", err) + } + + if kanataExecutable == "" { + var err error + kanataExecutable, err = exec.LookPath("kanata") + if err != nil { + return err + } + } + + cfgArg := "" + if kanataConfig != "" { + cfgArg = "-c " + kanataConfig + } + + cmd := exec.CommandContext(r.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 in processing loop. + // We will know that happens when the process slot becomes writable. + r.ProcessSlotCh <- struct{}{} + + if r.logFile != nil { + r.logFile.Close() + } + 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 + } + + 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 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() + r.cmd = nil + cancelTcpConnection() + if r.manualTermination { + r.manualTermination = false + r.retCh <- nil + } else { + r.retCh <- err + } + }() + + 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) 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..ffd185f 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -3,175 +3,148 @@ 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 - - ctx context.Context - cmd *exec.Cmd - logFile *os.File - manualTermination bool - tcpClient *KanataTcpClient +// An item and the preset name for the associated runner. +type ItemAndPresetName[T any] struct { + Item T + PresetName string } -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), +type Runner struct { + retCh chan ItemAndPresetName[error] + ProcessSlotCh chan ItemAndPresetName[struct{}] + serverMessageCh chan ItemAndPresetName[tcp_client.ServerMessage] + clientMessageCh chan ItemAndPresetName[tcp_client.ClientMessage] + // Maps preset names to runner indices in `runnerPool` and contexts in `instanceWatcherCtxs`. + activeKanataInstances map[string]int + 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 + concurrent bool + runnersLimit int + ctx context.Context +} - ctx: context.Background(), - cmd: nil, - logFile: nil, - manualTermination: false, - tcpClient: NewTcpClient(), +func NewRunner(ctx context.Context, concurrent bool) *Runner { + activeInstancesLimit := 10 + return &Runner{ + retCh: make(chan ItemAndPresetName[error], activeInstancesLimit), + ProcessSlotCh: make(chan ItemAndPresetName[struct{}], activeInstancesLimit), + serverMessageCh: make(chan ItemAndPresetName[tcp_client.ServerMessage], activeInstancesLimit), + clientMessageCh: make(chan ItemAndPresetName[tcp_client.ClientMessage], activeInstancesLimit), + activeKanataInstances: make(map[string]int), + kanataInstancePool: []*kanata.Kanata{}, + instanceWatcherCtxs: []context.Context{}, + concurrent: concurrent, + runnersLimit: 0, + ctx: ctx, } } -// 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. +// +// Depending on the value of `concurrent`, it will either add a new runner to pool +// (or reuse unused runner) or first stop the running instances, and then run +// the a one it's place. Will fail if active instances limit were to be exceeded. +func (r *Runner) Run(presetName string, kanataExecutable string, kanataConfig string, tcpPort int) error { + r.instancesMappingLock.Lock() + defer r.instancesMappingLock.Unlock() + + if r.concurrent { + // Stop all + for k, i := range r.activeKanataInstances { + _ = r.kanataInstancePool[i].StopNonblocking() + delete(r.activeKanataInstances, k) } } - 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") + 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 + _ = r.kanataInstancePool[i].StopNonblocking() + 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 activeInstanceIndices[i] != i { + // kanataInstancePool at index `i` is unused + instanceIndex = i + break + } + } + } else { + // create new instance + if r.runnersLimit >= len(r.activeKanataInstances) { + return fmt.Errorf("active instances limit exceeded") + } + r.kanataInstancePool = append(r.kanataInstancePool, kanata.NewKanataInstance(r.ctx)) + instanceIndex = len(r.kanataInstancePool) - 1 } - if r.logFile != nil { - os.RemoveAll(r.logFile.Name()) - r.logFile.Close() - r.logFile = nil + instance := r.kanataInstancePool[instanceIndex] + err := instance.RunNonblocking(kanataExecutable, kanataConfig, tcpPort) + if err != nil { + return fmt.Errorf("failed to run kanata: %v", err) } - + r.activeKanataInstances[presetName] = instanceIndex return nil } -func (r *KanataRunner) RunNonblocking(kanataExecutablePath string, kanataConfigPath string, tcpPort int) error { - err := r.Stop() +func (r *Runner) StopNonblocking(presetName string) error { + r.instancesMappingLock.Lock() + defer r.instancesMappingLock.Unlock() + i, ok := r.activeKanataInstances[presetName] + if !ok { + return fmt.Errorf("preset with the provided name is not running") + } + err := r.kanataInstancePool[i].StopNonblocking() if err != nil { - return fmt.Errorf("failed to stop the previous process: %v", err) + return err } - - cmd := exec.CommandContext(r.ctx, kanataExecutablePath, "-c", kanataConfigPath, "--port", fmt.Sprint(tcpPort)) - cmd.SysProcAttr = os_specific.ProcessAttr - - 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 - } - - 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) - } - } - } - }() - - // 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 - } - }() - + delete(r.activeKanataInstances, presetName) // should this be before or after checking for error? 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 nam 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 - } - } - return nil +func (r *Runner) ServerMessageCh() <-chan ItemAndPresetName[tcp_client.ServerMessage] { + return r.serverMessageCh } 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"` } From 0e4de9ac8cacc76fa805071bda9ae910eda27294 Mon Sep 17 00:00:00 2001 From: rszyma Date: Sat, 9 Mar 2024 14:44:01 +0100 Subject: [PATCH 03/14] update deps --- go.mod | 16 ++++++++-------- go.sum | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index 7e5cd44..a22a3fd 100644 --- a/go.mod +++ b/go.mod @@ -10,13 +10,13 @@ require ( require ( 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 + golang.org/x/text v0.14.0 // indirect ) require ( 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 @@ -27,10 +27,10 @@ require ( github.com/k0kubun/pp/v3 v3.2.0 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 + 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..98dcba6 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFB 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= @@ -49,6 +51,8 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec 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= @@ -71,21 +75,30 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1 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 v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= 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/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/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 +117,18 @@ 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.6.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.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= From 8a65f929bc8cb0dde0f63a1ec707e3fafe6a9277 Mon Sep 17 00:00:00 2001 From: rszyma Date: Sat, 9 Mar 2024 23:24:44 +0100 Subject: [PATCH 04/14] fix: config not loading properly --- app/custom_icons.go | 16 ++---- app/menu_template.go | 26 ++++++--- config/config.go | 122 +++++++++++++++++++++++++++++++++---------- go.mod | 3 ++ go.sum | 6 +++ main.go | 10 ++-- 6 files changed, 130 insertions(+), 53 deletions(-) diff --git a/app/custom_icons.go b/app/custom_icons.go index 088c8b7..c7f3f5f 100644 --- a/app/custom_icons.go +++ b/app/custom_icons.go @@ -13,16 +13,6 @@ type LayerIcons struct { defaultIcons LayerIconsForPreset } -func newLayerIcons() LayerIcons { - return LayerIcons{ - presetIcons: make(map[string]*LayerIconsForPreset), - defaultIcons: LayerIconsForPreset{ - layerIcons: make(map[string][]byte), - wildcardIcon: []byte{}, - }, - } -} - type LayerIconsForPreset struct { layerIcons map[string][]byte wildcardIcon []byte // can be nil @@ -89,7 +79,7 @@ func ResolveIcons(configFolder string, cfg *config.Config) LayerIcons { for layerName, unvalidatedIconPath := range cfg.PresetDefaults.LayerIcons { data, err := readIconInFolder(unvalidatedIconPath, customIconsFolder) if err != nil { - fmt.Printf("Custom icon file can't be accessed: %v\n", err) + fmt.Printf("defaults - custom icon file can't be read: %v\n", err) } else if layerName == "*" { icons.defaultIcons.wildcardIcon = data } else { @@ -100,7 +90,7 @@ func ResolveIcons(configFolder string, cfg *config.Config) LayerIcons { for layerName, unvalidatedIconPath := range cfg.Presets[presetName].LayerIcons { data, err := readIconInFolder(unvalidatedIconPath, customIconsFolder) if err != nil { - fmt.Printf("Custom icon file can't be accessed: %v\n", err) + fmt.Printf("Preset '%s' - custom icon file can't be read: %v\n", presetName, err) } else if layerName == "*" { icons.presetIcons[presetName].wildcardIcon = data } else { @@ -120,7 +110,7 @@ func readIconInFolder(filePath string, folder string) ([]byte, error) { } content, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("'%s': %v\n", path, err) + return nil, err } return content, nil } diff --git a/app/menu_template.go b/app/menu_template.go index 3dea30a..b90756f 100644 --- a/app/menu_template.go +++ b/app/menu_template.go @@ -26,20 +26,20 @@ const ( func (m *PresetMenuEntry) Title(status KanataStatus) string { switch status { case statusIdle: - return "Config: " + m.PresetName + return "Preset: " + m.PresetName case statusRunning: - return "> Config: " + m.PresetName + return "> Preset: " + m.PresetName case statusCrashed: - return "[ERR] Config: " + m.PresetName + return "[ERR] Preset: " + m.PresetName } - return "Config: " + m.PresetName + return "Preset: " + m.PresetName } func (m *PresetMenuEntry) Tooltip() string { - return "Switch to kanata config: " + m.PresetName + return "Switch to preset: " + m.PresetName } -func MenuTemplateFromConfig(cfg config.Config) []PresetMenuEntry { +func MenuTemplateFromConfig(cfg config.Config) ([]PresetMenuEntry, error) { presets := []PresetMenuEntry{} for presetName, preset := range cfg.Presets { @@ -58,16 +58,26 @@ func MenuTemplateFromConfig(cfg config.Config) []PresetMenuEntry { // 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 { + return nil, err + } + entry := PresetMenuEntry{ IsSelectable: true, - Preset: preset, + Preset: *preset, PresetName: presetName, } presets = append(presets, entry) } - return presets + return presets, nil } func resolveFilePath(path string) (string, error) { diff --git a/config/config.go b/config/config.go index 27e7e14..6b10956 100644 --- a/config/config.go +++ b/config/config.go @@ -8,49 +8,94 @@ import ( "github.com/pelletier/go-toml/v2" ) -type partialConfigJustDefaults struct { - PresetDefaults Preset `toml:"defaults"` +type Config struct { + PresetDefaults Preset + General GeneralConfigOptions + Presets map[string]*Preset } -type Config struct { - partialConfigJustDefaults - General GeneralConfigOptions `toml:"general"` - Presets map[string]Preset `toml:"presets"` +type Preset struct { + Autorun bool + KanataExecutable string + KanataConfig string + TcpPort int + LayerIcons map[string]string } type GeneralConfigOptions struct { - AllowConcurrentPresets bool `toml:"allow_concurrent_presets"` + AllowConcurrentPresets bool } -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"` +// ========= +// All golang toml parsers suck :/ + +type config struct { + PresetDefaults *preset `toml:"defaults"` + General *generalConfigOptions `toml:"general"` + Presets map[string]preset `toml:"presets"` } -var defaults *partialConfigJustDefaults = nil +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 (c *Preset) UnmarshalTOML(text []byte) error { - if defaults != nil { - c = &defaults.PresetDefaults +func (p *preset) applyDefaults(defaults *preset) { + if p.Autorun == nil { + p.Autorun = defaults.Autorun } - return toml.Unmarshal(text, c) + 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 ReadConfigOrCreateIfNotExist(configFilePath string) (*Config, error) { - defaults = &partialConfigJustDefaults{} - err := toml.Unmarshal([]byte(defaultCfg), defaults) - if err != nil { - return nil, fmt.Errorf("failed to parse default config: %v", err) +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 +} - var cfg *Config = &Config{} - err = toml.Unmarshal([]byte(defaultCfg), &cfg) +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) if err != nil { return nil, 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) { @@ -70,9 +115,28 @@ func ReadConfigOrCreateIfNotExist(configFilePath string) (*Config, error) { } } - pp.Println("%v", defaults) - pp.Println("%v", cfg) - return cfg, nil + if cfg.Presets == nil { + cfg.Presets = presetsFromDefaultConfig + } + + defaults := cfg.PresetDefaults + + var cfg2 *Config = &Config{ + PresetDefaults: *defaults.intoExported(), + General: GeneralConfigOptions{ + AllowConcurrentPresets: *cfg.General.AllowConcurrentPresets, + }, + Presets: map[string]*Preset{}, + } + + for k, v := range cfg.Presets { + v.applyDefaults(defaults) + exported := v.intoExported() + cfg2.Presets[k] = exported + } + + pp.Printf("loaded config: %v\n", cfg2) + return cfg2, nil } var defaultCfg = ` @@ -80,6 +144,7 @@ var defaultCfg = ` "$schema" = "https://raw.githubusercontent.com/rszyma/kanata-tray/v0.1.0/doc/config_schema.json" general.allow_concurrent_presets = false +defaults.tcp_port = 5829 [defaults.layer_icons] @@ -88,6 +153,5 @@ general.allow_concurrent_presets = false kanata_executable = '' kanata_config = '' autorun = false -tcp_port = 5829 ` diff --git a/go.mod b/go.mod index a22a3fd..081f485 100644 --- a/go.mod +++ b/go.mod @@ -11,10 +11,12 @@ require ( require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/naoina/go-stringutil v0.1.0 // indirect golang.org/x/text v0.14.0 // indirect ) require ( + github.com/BurntSushi/toml v1.3.2 github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect github.com/getlantern/errors v1.0.4 // indirect github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 // indirect @@ -24,6 +26,7 @@ require ( github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-stack/stack v1.8.1 // indirect + github.com/influxdata/toml v0.0.0-20180607005434-2a2e3012f7cf github.com/k0kubun/pp/v3 v3.2.0 github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect github.com/pelletier/go-toml/v2 v2.1.1 diff --git a/go.sum b/go.sum index 98dcba6..b919581 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 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= @@ -38,6 +40,8 @@ github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/influxdata/toml v0.0.0-20180607005434-2a2e3012f7cf h1:SDlFXYATjEbWThjvSTGdLmHyPozB8QsUFQs/LQ/bOcE= +github.com/influxdata/toml v0.0.0-20180607005434-2a2e3012f7cf/go.mod h1:zApaNFpP/bTpQItGZNNUMISDMDAnTXu9UqJ4yT3ocz8= github.com/k0kubun/pp/v3 v3.2.0 h1:h33hNTZ9nVFNP3u2Fsgz8JXiF5JINoZfFq4SvKJwNcs= github.com/k0kubun/pp/v3 v3.2.0/go.mod h1:ODtJQbQcIRfAD3N+theGCV1m/CBxweERz2dapdz1EwA= github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f h1:dKccXx7xA56UNqOcFIbuqFjAWPVtP688j5QMgmo6OHU= @@ -53,6 +57,8 @@ github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peK 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/naoina/go-stringutil v0.1.0 h1:rCUeRUHjBjGTSHl0VC00jUPLz8/F9dDzYI70Hzifhks= +github.com/naoina/go-stringutil v0.1.0/go.mod h1:XJ2SJL9jCtBh+P9q5btrd/Ylo8XwT/h1USek5+NqSA0= 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= diff --git a/main.go b/main.go index 910fe4c..dc24ebf 100644 --- a/main.go +++ b/main.go @@ -64,11 +64,15 @@ func mainImpl() error { if err != nil { return fmt.Errorf("loading config failed: %v", err) } - - menuTemplate := app.MenuTemplateFromConfig(*cfg) + menuTemplate, err := app.MenuTemplateFromConfig(*cfg) + if err != nil { + return fmt.Errorf("failed to create menu from config: %v", err) + } layerIcons := app.ResolveIcons(configFolder, cfg) - ctx := context.Background() // actually we don't really use ctx right now + // 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, cfg.General.AllowConcurrentPresets) onReady := func() { From edf29c62e37dbb5b4a396f043ea09fd3f7efcbc1 Mon Sep 17 00:00:00 2001 From: rszyma Date: Sat, 9 Mar 2024 23:38:42 +0100 Subject: [PATCH 05/14] fix some bugs --- app/app.go | 5 +++++ runner/runner.go | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/app.go b/app/app.go index 6bb50e5..889c1fb 100644 --- a/app/app.go +++ b/app/app.go @@ -29,6 +29,7 @@ type SysTrayApp struct { } func NewSystrayApp(menuTemplate []PresetMenuEntry, layerIcons LayerIcons, allowConcurrentPresets bool, tcpPort int) *SysTrayApp { + t := &SysTrayApp{ presets: menuTemplate, layerIcons: layerIcons, @@ -36,6 +37,10 @@ func NewSystrayApp(menuTemplate []PresetMenuEntry, layerIcons LayerIcons, allowC tcpPort: tcpPort, } + for range menuTemplate { + t.statuses = append(t.statuses, statusIdle) + } + systray.SetIcon(icons.Default) systray.SetTitle("kanata-tray") systray.SetTooltip("kanata-tray") diff --git a/runner/runner.go b/runner/runner.go index ffd185f..dac9972 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -44,7 +44,7 @@ func NewRunner(ctx context.Context, concurrent bool) *Runner { kanataInstancePool: []*kanata.Kanata{}, instanceWatcherCtxs: []context.Context{}, concurrent: concurrent, - runnersLimit: 0, + runnersLimit: activeInstancesLimit, ctx: ctx, } } @@ -93,7 +93,7 @@ func (r *Runner) Run(presetName string, kanataExecutable string, kanataConfig st } } else { // create new instance - if r.runnersLimit >= len(r.activeKanataInstances) { + if r.runnersLimit < len(r.activeKanataInstances) { return fmt.Errorf("active instances limit exceeded") } r.kanataInstancePool = append(r.kanataInstancePool, kanata.NewKanataInstance(r.ctx)) From 8afdbd7b6c3e112bd8f26344f7afb0047a30ab08 Mon Sep 17 00:00:00 2001 From: rszyma Date: Sun, 10 Mar 2024 00:59:22 +0100 Subject: [PATCH 06/14] fix more bugs --- app/app.go | 97 ++++++++++++++++++++++------------------- runner/kanata/kanata.go | 2 +- runner/runner.go | 12 ++++- 3 files changed, 64 insertions(+), 47 deletions(-) diff --git a/app/app.go b/app/app.go index 889c1fb..32d1e5b 100644 --- a/app/app.go +++ b/app/app.go @@ -19,11 +19,15 @@ type SysTrayApp struct { layerIcons LayerIcons tcpPort int - presetClickedCh chan int // the value sent in channel is an index of preset + statusClickedCh chan int // the value sent in channel is an index of preset + openLogsClickedCh chan int // the value sent in channel is an index of preset // Menu items - mPresets []*systray.MenuItem + mPresets []*systray.MenuItem + mPresetLogs []*systray.MenuItem + mPresetStatuses []*systray.MenuItem + mOptions *systray.MenuItem mQuit *systray.MenuItem } @@ -37,10 +41,6 @@ func NewSystrayApp(menuTemplate []PresetMenuEntry, layerIcons LayerIcons, allowC tcpPort: tcpPort, } - for range menuTemplate { - t.statuses = append(t.statuses, statusIdle) - } - systray.SetIcon(icons.Default) systray.SetTitle("kanata-tray") systray.SetTooltip("kanata-tray") @@ -51,6 +51,14 @@ func NewSystrayApp(menuTemplate []PresetMenuEntry, layerIcons LayerIcons, allowC menuItem.Disable() } t.mPresets = append(t.mPresets, menuItem) + + statusItem := menuItem.AddSubMenuItem(string(statusIdle), "kanata status for this preset") + t.mPresetStatuses = append(t.mPresetStatuses, statusItem) + t.statuses = append(t.statuses, statusIdle) + + openLogsItem := menuItem.AddSubMenuItem("Open Logs", "Open Logs") + // openLogsItem.Disable() + t.mPresetLogs = append(t.mPresetLogs, openLogsItem) } systray.AddSeparator() @@ -58,7 +66,8 @@ func NewSystrayApp(menuTemplate []PresetMenuEntry, layerIcons LayerIcons, allowC t.mOptions = systray.AddMenuItem("Options", "Reveals kanata-tray config file") t.mQuit = systray.AddMenuItem("Exit tray", "Closes kanata (if running) and exits the tray") - t.presetClickedCh = multipleMenuItemsClickListener(t.mPresets) + t.statusClickedCh = multipleMenuItemsClickListener(t.mPresetStatuses) + t.openLogsClickedCh = multipleMenuItemsClickListener(t.mPresetLogs) return t } @@ -69,21 +78,16 @@ func (t *SysTrayApp) runPreset(presetIndex int, runner *runner_pkg.Runner) { } else { fmt.Printf("Running preset '%s'\n", t.presets[presetIndex].PresetName) } - t.statuses[presetIndex] = statusStarting - t.mPresets[presetIndex].SetTitle(t.presets[presetIndex].Title(statusStarting)) - systray.SetIcon(icons.Default) - + t.setStatus(presetIndex, statusStarting) kanataExecutable := t.presets[presetIndex].Preset.KanataExecutable kanataConfig := t.presets[presetIndex].Preset.KanataConfig err := runner.Run(t.presets[presetIndex].PresetName, kanataExecutable, kanataConfig, t.tcpPort) if err != nil { fmt.Printf("runner.Run failed with: %v\n", err) - t.statuses[presetIndex] = statusCrashed - t.mPresets[presetIndex].SetTitle(t.presets[presetIndex].Title(statusCrashed)) + t.setStatus(presetIndex, statusCrashed) return } - t.statuses[presetIndex] = statusRunning - t.mPresets[presetIndex].SetTitle(t.presets[presetIndex].Title(statusStarting)) + t.setStatus(presetIndex, statusRunning) } func (app *SysTrayApp) StartProcessingLoop(runner *runner_pkg.Runner, allowConcurrentPresets bool, configFolder string) { @@ -153,36 +157,34 @@ func (app *SysTrayApp) StartProcessingLoop(runner *runner_pkg.Runner, allowConcu // TODO: is this even needed anymore? We don't even validate if that's process slot for the preset in `ret.PresetName`. <-runner.ProcessSlotCh // free 1 slot - // case <-app.mStatus.ClickedCh: - // switch app.runnerStatus { - // case statusIdle: - // // run kanata - // app.runPreset(runner) - // case statusRunning: - // // stop kanata - // err := runner.StopNonblocking() - // if err != nil { - // fmt.Printf("Failed to stop kanata process: %v", err) - // } else { - // app.runnerStatus = statusIdle - // app.mStatus.SetTitle(statusIdle) - // systray.SetIcon(icons.Pause) - // } - // case statusCrashed: - // // restart kanata - // fmt.Println("Restarting kanata") - // app.runPreset(runner) - // } - // case <-app.mOpenKanataLogFile.ClickedCh: - // logFile, err := runner.LogFile() - // if err != nil { - // fmt.Printf("Can't open log file: %v\n", err) - // } else { - // fmt.Printf("Opening log file '%s'\n", logFile) - // open.Start(logFile) - // } - case i := <-app.presetClickedCh: - app.runPreset(i, runner) + case i := <-app.statusClickedCh: + presetName := app.presets[i].PresetName + switch app.statuses[i] { + case statusIdle: + // run kanata + app.runPreset(i, runner) + case statusRunning: + // stop kanata + err := runner.StopNonblocking(presetName) + if err != nil { + fmt.Printf("Failed to stop kanata process: %v", err) + } else { + app.setStatus(i, statusIdle) + } + case statusCrashed: + // restart kanata + fmt.Println("Restarting kanata") + app.runPreset(i, runner) + } + case i := <-app.openLogsClickedCh: + presetName := app.presets[i].PresetName + logFile, err := runner.LogFile(presetName) + if err != nil { + fmt.Printf("Can't open log file for preset '%s': %v\n", presetName, err) + } else { + fmt.Printf("Opening log file for preset '%s': '%s'\n", presetName, logFile) + open.Start(logFile) + } case <-app.mOptions.ClickedCh: open.Start(configFolder) case <-app.mQuit.ClickedCh: @@ -226,6 +228,11 @@ func (t *SysTrayApp) isAnyPresetRunning() bool { return false } +func (t *SysTrayApp) setStatus(presetIndex int, status KanataStatus) { + t.statuses[presetIndex] = status + t.mPresetStatuses[presetIndex].SetTitle(string(status)) +} + // Returns a channel that sends an index of item that was clicked. // TODO: pass ctx and cleanup on ctx cancel. func multipleMenuItemsClickListener(menuItems []*systray.MenuItem) chan int { diff --git a/runner/kanata/kanata.go b/runner/kanata/kanata.go index 93d1150..4f530b7 100644 --- a/runner/kanata/kanata.go +++ b/runner/kanata/kanata.go @@ -76,7 +76,7 @@ func (r *Kanata) RunNonblocking(kanataExecutable string, kanataConfig string, tc cfgArg := "" if kanataConfig != "" { - cfgArg = "-c " + kanataConfig + cfgArg = "-c=" + kanataConfig } cmd := exec.CommandContext(r.ctx, kanataExecutable, cfgArg, "--port", fmt.Sprint(tcpPort)) diff --git a/runner/runner.go b/runner/runner.go index dac9972..d9d0113 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -136,7 +136,7 @@ func (r *Runner) SendClientMessage(presetName string, msg tcp_client.ClientMessa defer r.instancesMappingLock.Unlock() presetIndex, ok := r.activeKanataInstances[presetName] if !ok { - return fmt.Errorf("preset with the given nam not found") + return fmt.Errorf("preset with the given name not found") } return r.kanataInstancePool[presetIndex].SendClientMessage(msg) } @@ -148,3 +148,13 @@ func (r *Runner) RetCh() <-chan ItemAndPresetName[error] { 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 r.kanataInstancePool[presetIndex].LogFile() +} From 5faa30c5a1f59750f32ca2f34251bd3abb250224 Mon Sep 17 00:00:00 2001 From: rszyma Date: Sun, 10 Mar 2024 14:11:14 +0100 Subject: [PATCH 07/14] refactor: use ctx for cancelation --- app/app.go | 71 +++++++++++++++--------------- app/custom_icons.go | 2 +- app/menu_template.go | 22 +++++----- runner/kanata/kanata.go | 71 +++++++++++------------------- runner/runner.go | 97 ++++++++++++++++++++++------------------- 5 files changed, 124 insertions(+), 139 deletions(-) diff --git a/app/app.go b/app/app.go index 32d1e5b..92c5976 100644 --- a/app/app.go +++ b/app/app.go @@ -1,7 +1,9 @@ package app import ( + "context" "fmt" + "time" "github.com/getlantern/systray" "github.com/skratchdot/open-golang/open" @@ -13,8 +15,9 @@ import ( type SysTrayApp struct { concurrentPresets bool - presets []PresetMenuEntry - statuses []KanataStatus + presets []PresetMenuEntry + statuses []KanataStatus + presetCancelFuncs []context.CancelFunc // cancel functions can be nil layerIcons LayerIcons tcpPort int @@ -56,6 +59,8 @@ func NewSystrayApp(menuTemplate []PresetMenuEntry, layerIcons LayerIcons, allowC t.mPresetStatuses = append(t.mPresetStatuses, statusItem) t.statuses = append(t.statuses, statusIdle) + t.presetCancelFuncs = append(t.presetCancelFuncs, nil) + openLogsItem := menuItem.AddSubMenuItem("Open Logs", "Open Logs") // openLogsItem.Disable() t.mPresetLogs = append(t.mPresetLogs, openLogsItem) @@ -63,7 +68,7 @@ func NewSystrayApp(menuTemplate []PresetMenuEntry, layerIcons LayerIcons, allowC systray.AddSeparator() - t.mOptions = systray.AddMenuItem("Options", "Reveals kanata-tray config file") + t.mOptions = systray.AddMenuItem("Configure", "Reveals kanata-tray config file") t.mQuit = systray.AddMenuItem("Exit tray", "Closes kanata (if running) and exits the tray") t.statusClickedCh = multipleMenuItemsClickListener(t.mPresetStatuses) @@ -81,13 +86,17 @@ func (t *SysTrayApp) runPreset(presetIndex int, runner *runner_pkg.Runner) { t.setStatus(presetIndex, statusStarting) kanataExecutable := t.presets[presetIndex].Preset.KanataExecutable kanataConfig := t.presets[presetIndex].Preset.KanataConfig - err := runner.Run(t.presets[presetIndex].PresetName, kanataExecutable, kanataConfig, t.tcpPort) + ctx, cancel := context.WithCancel(context.Background()) + err := runner.Run(ctx, t.presets[presetIndex].PresetName, kanataExecutable, kanataConfig, t.tcpPort) if err != nil { fmt.Printf("runner.Run failed with: %v\n", err) t.setStatus(presetIndex, statusCrashed) + cancel() return } t.setStatus(presetIndex, statusRunning) + t.cancel(presetIndex) + t.presetCancelFuncs[presetIndex] = cancel } func (app *SysTrayApp) StartProcessingLoop(runner *runner_pkg.Runner, allowConcurrentPresets bool, configFolder string) { @@ -103,7 +112,7 @@ func (app *SysTrayApp) StartProcessingLoop(runner *runner_pkg.Runner, allowConcu } serverMessageCh := runner.ServerMessageCh() - serverRetCh := runner.RetCh() + retCh := runner.RetCh() for { select { @@ -131,7 +140,7 @@ func (app *SysTrayApp) StartProcessingLoop(runner *runner_pkg.Runner, allowConcu } } } - case ret := <-serverRetCh: + case ret := <-retCh: err := ret.Item i, err1 := app.indexFromPresetName(ret.PresetName) if err1 != nil { @@ -140,39 +149,29 @@ func (app *SysTrayApp) StartProcessingLoop(runner *runner_pkg.Runner, allowConcu } if err != nil { fmt.Printf("Kanata process terminated with an error: %v\n", err) - app.statuses[i] = statusCrashed - app.mPresets[i].SetTitle(app.presets[i].Title(statusCrashed)) + app.setStatus(i, statusCrashed) systray.SetIcon(icons.Crash) } else { fmt.Println("Kanata process terminated successfully") - app.statuses[i] = statusIdle - app.mPresets[i].SetTitle(app.presets[i].Title(statusIdle)) + app.setStatus(i, statusIdle) if app.isAnyPresetRunning() { systray.SetIcon(icons.Default) } else { - // no running presets systray.SetIcon(icons.Pause) } } - - // TODO: is this even needed anymore? We don't even validate if that's process slot for the preset in `ret.PresetName`. - <-runner.ProcessSlotCh // free 1 slot + app.cancel(i) case i := <-app.statusClickedCh: - presetName := app.presets[i].PresetName switch app.statuses[i] { case statusIdle: // run kanata app.runPreset(i, runner) case statusRunning: // stop kanata - err := runner.StopNonblocking(presetName) - if err != nil { - fmt.Printf("Failed to stop kanata process: %v", err) - } else { - app.setStatus(i, statusIdle) - } + app.cancel(i) + app.setStatus(i, statusIdle) case statusCrashed: - // restart kanata + // restart kanata (from crashed state) fmt.Println("Restarting kanata") app.runPreset(i, runner) } @@ -189,21 +188,13 @@ func (app *SysTrayApp) StartProcessingLoop(runner *runner_pkg.Runner, allowConcu open.Start(configFolder) case <-app.mQuit.ClickedCh: fmt.Println("Exiting...") - for _, preset := range app.presets { - err := runner.StopNonblocking(preset.PresetName) - if err != nil { - fmt.Printf("failed to stop kanata process: %v", err) + for _, cancel := range app.presetCancelFuncs { + if cancel != nil { + cancel() } } - for _, preset := range app.presets { - // When ProcessSlotCh becomes writable, it mean that the process - // was successfully stopped. - runner.ProcessSlotCh <- runner_pkg.ItemAndPresetName[struct{}]{ - Item: struct{}{}, - PresetName: preset.PresetName, - } - } - + time.Sleep(1 * time.Second) + // TODO: ensure all kanata processes stopped? systray.Quit() return } @@ -231,6 +222,16 @@ func (t *SysTrayApp) isAnyPresetRunning() bool { func (t *SysTrayApp) setStatus(presetIndex int, status KanataStatus) { t.statuses[presetIndex] = status t.mPresetStatuses[presetIndex].SetTitle(string(status)) + t.mPresets[presetIndex].SetTitle(t.presets[presetIndex].Title(status)) +} + +// Cancels (stops) preset at given index. +func (t *SysTrayApp) cancel(presetIndex int) { + cancel := t.presetCancelFuncs[presetIndex] + if cancel != nil { + cancel() + } + t.presetCancelFuncs[presetIndex] = nil } // Returns a channel that sends an index of item that was clicked. diff --git a/app/custom_icons.go b/app/custom_icons.go index c7f3f5f..ff7a478 100644 --- a/app/custom_icons.go +++ b/app/custom_icons.go @@ -38,7 +38,7 @@ func (c LayerIcons) IconForLayerName(presetName string, layerName string) []byte return layerIcon } // preset_wildcard - if preset.wildcardIcon != nil { + if preset != nil && preset.wildcardIcon != nil { fmt.Printf("Setting icon: preset:%s, layer:*\n", presetName) return preset.wildcardIcon } diff --git a/app/menu_template.go b/app/menu_template.go index b90756f..f2ceda3 100644 --- a/app/menu_template.go +++ b/app/menu_template.go @@ -80,17 +80,6 @@ func MenuTemplateFromConfig(cfg config.Config) ([]PresetMenuEntry, error) { return presets, 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 -} - func expandHomeDir(path string) (string, error) { if strings.Contains(path, "~") { dirname, err := os.UserHomeDir() @@ -102,3 +91,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/runner/kanata/kanata.go b/runner/kanata/kanata.go index 4f530b7..17f9050 100644 --- a/runner/kanata/kanata.go +++ b/runner/kanata/kanata.go @@ -16,56 +16,27 @@ import ( // Reusing with different kanata configs/presets is allowed. type Kanata struct { // Prevents race condition when restarting kanata. - // This must be written to from from outside to free the internal slot. + // This must be written to from from outside to free an internal slot. ProcessSlotCh chan struct{} - retCh chan error // Returns the error returned by `cmd.Wait()` - ctx context.Context - cmd *exec.Cmd - logFile *os.File - manualTermination bool - tcpClient *tcp_client.KanataTcpClient + retCh chan error // Returns the error returned by `cmd.Wait()` + cmd *exec.Cmd + logFile *os.File + tcpClient *tcp_client.KanataTcpClient } -func NewKanataInstance(ctx context.Context) *Kanata { +func NewKanataInstance() *Kanata { return &Kanata{ ProcessSlotCh: make(chan struct{}, 1), - retCh: make(chan error), - ctx: ctx, - cmd: nil, - logFile: nil, - manualTermination: false, - tcpClient: tcp_client.NewTcpClient(), + retCh: make(chan error), + cmd: nil, + logFile: nil, + tcpClient: tcp_client.NewTcpClient(), } } -// Terminates the running kanata process, if there is one. It doesn't wait for -// the process to actually stop. If you want to be sure that the process has been -// stopped, try writing to `ProcessSlotCh`. Make sure to pop back the item -// from `ProcessSlotCh` if you want to be able to reuse this struct after that. -func (r *Kanata) StopNonblocking() 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) - } - } - } - return nil -} - -func (r *Kanata) RunNonblocking(kanataExecutable string, kanataConfig string, tcpPort int) error { - err := r.StopNonblocking() - if err != nil { - return fmt.Errorf("failed to stop the previous process: %v", err) - } - +func (r *Kanata) RunNonblocking(ctx context.Context, kanataExecutable string, kanataConfig string, tcpPort int) error { if kanataExecutable == "" { var err error kanataExecutable, err = exec.LookPath("kanata") @@ -79,7 +50,7 @@ func (r *Kanata) RunNonblocking(kanataExecutable string, kanataConfig string, tc cfgArg = "-c=" + kanataConfig } - cmd := exec.CommandContext(r.ctx, kanataExecutable, cfgArg, "--port", fmt.Sprint(tcpPort)) + cmd := exec.CommandContext(ctx, kanataExecutable, cfgArg, "--port", fmt.Sprint(tcpPort)) cmd.SysProcAttr = os_specific.ProcessAttr go func() { @@ -90,9 +61,10 @@ func (r *Kanata) RunNonblocking(kanataExecutable string, kanataConfig string, tc 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 file: %v", err) + r.retCh <- fmt.Errorf("failed to create temp log file: %v", err) return } @@ -110,7 +82,7 @@ func (r *Kanata) RunNonblocking(kanataExecutable string, kanataConfig string, tc fmt.Printf("Started kanata (pid=%d)\n", r.cmd.Process.Pid) - tcpConnectionCtx, cancelTcpConnection := context.WithCancel(r.ctx) + 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) @@ -140,13 +112,16 @@ func (r *Kanata) RunNonblocking(kanataExecutable string, kanataConfig string, tc fmt.Printf("Failed to send ClientMessage: %v\n", err) } - err = r.cmd.Wait() + err = r.cmd.Wait() // block until kanata exits + r.cmd = nil cancelTcpConnection() - if r.manualTermination { - r.manualTermination = false + 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 } }() @@ -161,6 +136,10 @@ func (r *Kanata) LogFile() (string, error) { 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() } diff --git a/runner/runner.go b/runner/runner.go index d9d0113..cdb8737 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -17,10 +17,9 @@ type ItemAndPresetName[T any] struct { } type Runner struct { - retCh chan ItemAndPresetName[error] - ProcessSlotCh chan ItemAndPresetName[struct{}] - serverMessageCh chan ItemAndPresetName[tcp_client.ServerMessage] - clientMessageCh chan ItemAndPresetName[tcp_client.ClientMessage] + 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 kanataInstancePool []*kanata.Kanata @@ -28,45 +27,30 @@ type Runner struct { // Need to have mutex to ensure values in `kanataInstancePool` are not being overwritten // while a value from `activeKanataInstances` is still "borrowed". instancesMappingLock sync.Mutex - concurrent bool runnersLimit int - ctx context.Context } func NewRunner(ctx context.Context, concurrent bool) *Runner { activeInstancesLimit := 10 return &Runner{ - retCh: make(chan ItemAndPresetName[error], activeInstancesLimit), - ProcessSlotCh: make(chan ItemAndPresetName[struct{}], activeInstancesLimit), - serverMessageCh: make(chan ItemAndPresetName[tcp_client.ServerMessage], activeInstancesLimit), - clientMessageCh: make(chan ItemAndPresetName[tcp_client.ClientMessage], activeInstancesLimit), + 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{}, - concurrent: concurrent, runnersLimit: activeInstancesLimit, - ctx: ctx, } } -// Run a new kanata instance from a preset. -// Blocks until the process is started. -// -// Depending on the value of `concurrent`, it will either add a new runner to pool -// (or reuse unused runner) or first stop the running instances, and then run -// the a one it's place. Will fail if active instances limit were to be exceeded. -func (r *Runner) Run(presetName string, kanataExecutable string, kanataConfig string, tcpPort int) error { +// 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() - if r.concurrent { - // Stop all - for k, i := range r.activeKanataInstances { - _ = r.kanataInstancePool[i].StopNonblocking() - delete(r.activeKanataInstances, k) - } - } - var instanceIndex int // First check if there's an instance for the given preset already running. @@ -75,7 +59,6 @@ func (r *Runner) Run(presetName string, kanataExecutable string, kanataConfig st if i, ok := r.activeKanataInstances[presetName]; ok { // reuse (restart) at index - _ = r.kanataInstancePool[i].StopNonblocking() instanceIndex = i } else if len(r.activeKanataInstances) < len(r.kanataInstancePool) { // reuse first free instance @@ -85,7 +68,11 @@ func (r *Runner) Run(presetName string, kanataExecutable string, kanataConfig st } sort.Ints(activeInstanceIndices) for i := 0; i < len(r.kanataInstancePool); i++ { - if activeInstanceIndices[i] != i { + if i >= len(activeInstanceIndices) { + instanceIndex = i + break + } + if activeInstanceIndices[i] > i { // kanataInstancePool at index `i` is unused instanceIndex = i break @@ -93,34 +80,52 @@ func (r *Runner) Run(presetName string, kanataExecutable string, kanataConfig st } } else { // create new instance - if r.runnersLimit < len(r.activeKanataInstances) { + if len(r.activeKanataInstances) >= r.runnersLimit { return fmt.Errorf("active instances limit exceeded") } - r.kanataInstancePool = append(r.kanataInstancePool, kanata.NewKanataInstance(r.ctx)) + r.kanataInstancePool = append(r.kanataInstancePool, kanata.NewKanataInstance()) instanceIndex = len(r.kanataInstancePool) - 1 } instance := r.kanataInstancePool[instanceIndex] - err := instance.RunNonblocking(kanataExecutable, kanataConfig, tcpPort) + err := instance.RunNonblocking(ctx, kanataExecutable, kanataConfig, tcpPort) if err != nil { return fmt.Errorf("failed to run kanata: %v", err) } r.activeKanataInstances[presetName] = instanceIndex - return nil -} + r.clientMessageChannels[presetName] = make(chan tcp_client.ClientMessage) + + go func() { + <-ctx.Done() + r.instancesMappingLock.Lock() + defer r.instancesMappingLock.Unlock() + delete(r.activeKanataInstances, presetName) + delete(r.clientMessageChannels, presetName) + }() + + 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) + } + } + }() -func (r *Runner) StopNonblocking(presetName string) error { - r.instancesMappingLock.Lock() - defer r.instancesMappingLock.Unlock() - i, ok := r.activeKanataInstances[presetName] - if !ok { - return fmt.Errorf("preset with the provided name is not running") - } - err := r.kanataInstancePool[i].StopNonblocking() - if err != nil { - return err - } - delete(r.activeKanataInstances, presetName) // should this be before or after checking for error? return nil } From f4da602de15edf03ebacc66c23f4bef6a4c0f06d Mon Sep 17 00:00:00 2001 From: rszyma Date: Wed, 13 Mar 2024 00:15:59 +0100 Subject: [PATCH 08/14] wip: keep an order of presets --- .gitignore | 2 +- app/custom_icons.go | 1 + config/config.go | 9 ++++--- doc/config_schema.json | 57 +++++++++++++++++++++++++++++++++++------- go.mod | 4 +-- go.sum | 26 +++---------------- 6 files changed, 59 insertions(+), 40 deletions(-) diff --git a/.gitignore b/.gitignore index ae1891f..7e3cf98 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ dist -config.toml +config*.toml icons \ No newline at end of file diff --git a/app/custom_icons.go b/app/custom_icons.go index ff7a478..d4fd785 100644 --- a/app/custom_icons.go +++ b/app/custom_icons.go @@ -86,6 +86,7 @@ func ResolveIcons(configFolder string, cfg *config.Config) LayerIcons { icons.defaultIcons.layerIcons[layerName] = data } } + for presetName := range cfg.Presets { for layerName, unvalidatedIconPath := range cfg.Presets[presetName].LayerIcons { data, err := readIconInFolder(unvalidatedIconPath, customIconsFolder) diff --git a/config/config.go b/config/config.go index 6b10956..ab5596b 100644 --- a/config/config.go +++ b/config/config.go @@ -4,6 +4,7 @@ import ( "fmt" "os" + "github.com/elliotchance/orderedmap/v2" "github.com/k0kubun/pp/v3" "github.com/pelletier/go-toml/v2" ) @@ -11,7 +12,7 @@ import ( type Config struct { PresetDefaults Preset General GeneralConfigOptions - Presets map[string]*Preset + Presets orderedmap.OrderedMap[string, *Preset] } type Preset struct { @@ -108,8 +109,7 @@ func ReadConfigOrCreateIfNotExist(configFilePath string) (*Config, error) { return nil, fmt.Errorf("failed to open file '%s': %v", configFilePath, err) } defer fh.Close() - decoder := toml.NewDecoder(fh) - err = decoder.Decode(&cfg) + err = toml.NewDecoder(fh).Decode(&cfg) if err != nil { return nil, fmt.Errorf("failed to parse config file '%s': %v", configFilePath, err) } @@ -126,9 +126,10 @@ func ReadConfigOrCreateIfNotExist(configFilePath string) (*Config, error) { General: GeneralConfigOptions{ AllowConcurrentPresets: *cfg.General.AllowConcurrentPresets, }, - Presets: map[string]*Preset{}, + Presets: util.NewOrdereredMap[string, *Preset](), } + // TODO: keep order of items for k, v := range cfg.Presets { v.applyDefaults(defaults) exported := v.intoExported() diff --git a/doc/config_schema.json b/doc/config_schema.json index 2347a3c..be991ed 100644 --- a/doc/config_schema.json +++ b/doc/config_schema.json @@ -26,11 +26,47 @@ "type": "string", "description": "A layer name to icon path mapping." }, - "description": "An array of layer name to icon path mappings." + "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, - "description": "Preset defines the settings that kanata will be ran with when the preset gets selected in kanata-tray menu." + "required": [ + "name" + ] } }, "type": "object", @@ -50,16 +86,19 @@ "description": "Options that apply to kanata-tray behavior in general." }, "defaults": { - "$ref": "#/definitions/preset", - "description": "You can override default preset fields here." + "$ref": "#/definitions/preset" }, "presets": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/preset" + "type": "array", + "items": { + "$ref": "#/definitions/preset_with_name" }, - "description": "Defines presets that will be available in kanata-tray menu." + "additionalProperties": false, + "description": "An array of presets that will be available in kanata-tray menu. Each item must have a 'name' field." } }, - "additionalProperties": false + "additionalProperties": false, + "required": [ + "defaults" + ] } \ No newline at end of file diff --git a/go.mod b/go.mod index 081f485..7b30399 100644 --- a/go.mod +++ b/go.mod @@ -11,12 +11,11 @@ require ( require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/naoina/go-stringutil v0.1.0 // indirect golang.org/x/text v0.14.0 // indirect ) require ( - github.com/BurntSushi/toml v1.3.2 + github.com/elliotchance/orderedmap/v2 v2.2.0 github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect github.com/getlantern/errors v1.0.4 // indirect github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 // indirect @@ -26,7 +25,6 @@ require ( github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-stack/stack v1.8.1 // indirect - github.com/influxdata/toml v0.0.0-20180607005434-2a2e3012f7cf github.com/k0kubun/pp/v3 v3.2.0 github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect github.com/pelletier/go-toml/v2 v2.1.1 diff --git a/go.sum b/go.sum index b919581..9d42827 100644 --- a/go.sum +++ b/go.sum @@ -1,16 +1,14 @@ -github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= -github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 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= @@ -40,8 +38,6 @@ github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/influxdata/toml v0.0.0-20180607005434-2a2e3012f7cf h1:SDlFXYATjEbWThjvSTGdLmHyPozB8QsUFQs/LQ/bOcE= -github.com/influxdata/toml v0.0.0-20180607005434-2a2e3012f7cf/go.mod h1:zApaNFpP/bTpQItGZNNUMISDMDAnTXu9UqJ4yT3ocz8= github.com/k0kubun/pp/v3 v3.2.0 h1:h33hNTZ9nVFNP3u2Fsgz8JXiF5JINoZfFq4SvKJwNcs= github.com/k0kubun/pp/v3 v3.2.0/go.mod h1:ODtJQbQcIRfAD3N+theGCV1m/CBxweERz2dapdz1EwA= github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f h1:dKccXx7xA56UNqOcFIbuqFjAWPVtP688j5QMgmo6OHU= @@ -53,12 +49,9 @@ github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJ 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/naoina/go-stringutil v0.1.0 h1:rCUeRUHjBjGTSHl0VC00jUPLz8/F9dDzYI70Hzifhks= -github.com/naoina/go-stringutil v0.1.0/go.mod h1:XJ2SJL9jCtBh+P9q5btrd/Ylo8XwT/h1USek5+NqSA0= 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= @@ -79,30 +72,21 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 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 v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= -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/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= @@ -124,15 +108,11 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBc 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.6.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.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= From 0f3f9429a1d282ffb042b3094ee1212518895693 Mon Sep 17 00:00:00 2001 From: rszyma Date: Wed, 20 Mar 2024 01:51:34 +0100 Subject: [PATCH 09/14] preserve order or presets in menu --- app/custom_icons.go | 6 +- app/menu_template.go | 5 +- config/config.go | 145 +++++++++++++++++++++++++++++++++++++++---- go.mod | 5 +- go.sum | 17 ++++- 5 files changed, 159 insertions(+), 19 deletions(-) diff --git a/app/custom_icons.go b/app/custom_icons.go index d4fd785..e4c8e6e 100644 --- a/app/custom_icons.go +++ b/app/custom_icons.go @@ -87,8 +87,10 @@ func ResolveIcons(configFolder string, cfg *config.Config) LayerIcons { } } - for presetName := range cfg.Presets { - for layerName, unvalidatedIconPath := range cfg.Presets[presetName].LayerIcons { + 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) diff --git a/app/menu_template.go b/app/menu_template.go index f2ceda3..5692097 100644 --- a/app/menu_template.go +++ b/app/menu_template.go @@ -42,7 +42,10 @@ func (m *PresetMenuEntry) Tooltip() string { func MenuTemplateFromConfig(cfg config.Config) ([]PresetMenuEntry, error) { presets := []PresetMenuEntry{} - for presetName, preset := range cfg.Presets { + 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. diff --git a/config/config.go b/config/config.go index ab5596b..67c970e 100644 --- a/config/config.go +++ b/config/config.go @@ -1,18 +1,22 @@ 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 { PresetDefaults Preset General GeneralConfigOptions - Presets orderedmap.OrderedMap[string, *Preset] + Presets *OrderedMap[string, *Preset] } type Preset struct { @@ -23,6 +27,11 @@ type Preset struct { LayerIcons map[string]string } +func (m *Preset) GoString() string { + pp.Default.SetColoringEnabled(false) + return pp.Sprintf("%s", m) +} + type GeneralConfigOptions struct { AllowConcurrentPresets bool } @@ -90,9 +99,15 @@ type generalConfigOptions struct { func ReadConfigOrCreateIfNotExist(configFilePath string) (*Config, error) { var cfg *config = &config{} - err := toml.Unmarshal([]byte(defaultCfg), &cfg) + // 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 { - return nil, fmt.Errorf("failed to parse default config: %v", err) + panic(fmt.Errorf("default config failed layersOrder: %v", err)) + } + err = toml.Unmarshal([]byte(defaultCfg), &cfg) + if err != nil { + panic(fmt.Errorf("failed to parse default config: %v", err)) } // temporarily remove default presets presetsFromDefaultConfig := cfg.Presets @@ -104,15 +119,21 @@ 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() - err = toml.NewDecoder(fh).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 { @@ -126,20 +147,120 @@ func ReadConfigOrCreateIfNotExist(configFilePath string) (*Config, error) { General: GeneralConfigOptions{ AllowConcurrentPresets: *cfg.General.AllowConcurrentPresets, }, - Presets: util.NewOrdereredMap[string, *Preset](), + Presets: NewOrderedMap[string, *Preset](), } - // TODO: keep order of items - for k, v := range cfg.Presets { + for _, layerName := range layersNames { + v, ok := cfg.Presets[layerName] + if !ok { + panic("layer names should match") + } v.applyDefaults(defaults) exported := v.intoExported() - cfg2.Presets[k] = exported + cfg2.Presets.Set(layerName, exported) } - pp.Printf("loaded config: %v\n", cfg2) + 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" diff --git a/go.mod b/go.mod index 7b30399..f7682fb 100644 --- a/go.mod +++ b/go.mod @@ -9,8 +9,10 @@ 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.20 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect golang.org/x/text v0.14.0 // indirect ) @@ -26,8 +28,9 @@ 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 + 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 diff --git a/go.sum b/go.sum index 9d42827..4b680f3 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,5 @@ 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= @@ -43,8 +44,12 @@ 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= @@ -54,22 +59,28 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE 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.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= From bd7082ad261d0878fc379bc3677576c78c748282 Mon Sep 17 00:00:00 2001 From: rszyma Date: Wed, 20 Mar 2024 20:53:06 +0100 Subject: [PATCH 10/14] use correct tcp port for kanata instances --- app/app.go | 6 ++---- main.go | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/app/app.go b/app/app.go index 92c5976..f266234 100644 --- a/app/app.go +++ b/app/app.go @@ -20,7 +20,6 @@ type SysTrayApp struct { presetCancelFuncs []context.CancelFunc // cancel functions can be nil layerIcons LayerIcons - tcpPort int statusClickedCh chan int // the value sent in channel is an index of preset openLogsClickedCh chan int // the value sent in channel is an index of preset @@ -35,13 +34,12 @@ type SysTrayApp struct { mQuit *systray.MenuItem } -func NewSystrayApp(menuTemplate []PresetMenuEntry, layerIcons LayerIcons, allowConcurrentPresets bool, tcpPort int) *SysTrayApp { +func NewSystrayApp(menuTemplate []PresetMenuEntry, layerIcons LayerIcons, allowConcurrentPresets bool) *SysTrayApp { t := &SysTrayApp{ presets: menuTemplate, layerIcons: layerIcons, concurrentPresets: allowConcurrentPresets, - tcpPort: tcpPort, } systray.SetIcon(icons.Default) @@ -87,7 +85,7 @@ func (t *SysTrayApp) runPreset(presetIndex int, runner *runner_pkg.Runner) { kanataExecutable := t.presets[presetIndex].Preset.KanataExecutable kanataConfig := t.presets[presetIndex].Preset.KanataConfig ctx, cancel := context.WithCancel(context.Background()) - err := runner.Run(ctx, t.presets[presetIndex].PresetName, kanataExecutable, kanataConfig, t.tcpPort) + err := runner.Run(ctx, t.presets[presetIndex].PresetName, kanataExecutable, kanataConfig, t.presets[presetIndex].Preset.TcpPort) if err != nil { fmt.Printf("runner.Run failed with: %v\n", err) t.setStatus(presetIndex, statusCrashed) diff --git a/main.go b/main.go index dc24ebf..6bf41f6 100644 --- a/main.go +++ b/main.go @@ -76,7 +76,7 @@ func mainImpl() error { runner := runner.NewRunner(ctx, cfg.General.AllowConcurrentPresets) onReady := func() { - app := app.NewSystrayApp(menuTemplate, layerIcons, cfg.General.AllowConcurrentPresets, 12313) + app := app.NewSystrayApp(menuTemplate, layerIcons, cfg.General.AllowConcurrentPresets) go app.StartProcessingLoop(runner, cfg.General.AllowConcurrentPresets, configFolder) } From 939924f22bd29aab88e261a6c9cec2cdb20ef050 Mon Sep 17 00:00:00 2001 From: rszyma Date: Wed, 20 Mar 2024 20:57:20 +0100 Subject: [PATCH 11/14] remove unnecessary param --- app/app.go | 4 ++-- main.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/app.go b/app/app.go index f266234..90a90b4 100644 --- a/app/app.go +++ b/app/app.go @@ -97,12 +97,12 @@ func (t *SysTrayApp) runPreset(presetIndex int, runner *runner_pkg.Runner) { t.presetCancelFuncs[presetIndex] = cancel } -func (app *SysTrayApp) StartProcessingLoop(runner *runner_pkg.Runner, allowConcurrentPresets bool, configFolder string) { +func (app *SysTrayApp) StartProcessingLoop(runner *runner_pkg.Runner, configFolder string) { systray.SetIcon(icons.Pause) for i, preset := range app.presets { if preset.Preset.Autorun { app.runPreset(i, runner) - if allowConcurrentPresets { + if app.concurrentPresets { // Execute only the first preset if multi-exec is disabled. break } diff --git a/main.go b/main.go index 6bf41f6..114f864 100644 --- a/main.go +++ b/main.go @@ -77,7 +77,7 @@ func mainImpl() error { onReady := func() { app := app.NewSystrayApp(menuTemplate, layerIcons, cfg.General.AllowConcurrentPresets) - go app.StartProcessingLoop(runner, cfg.General.AllowConcurrentPresets, configFolder) + go app.StartProcessingLoop(runner, configFolder) } onExit := func() { From 6b77482c82716ee14cc19022d6692218a9f45147 Mon Sep 17 00:00:00 2001 From: rszyma Date: Wed, 20 Mar 2024 21:14:01 +0100 Subject: [PATCH 12/14] rename --- app/app.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/app.go b/app/app.go index 90a90b4..5b6bcd9 100644 --- a/app/app.go +++ b/app/app.go @@ -12,7 +12,7 @@ import ( runner_pkg "github.com/rszyma/kanata-tray/runner" ) -type SysTrayApp struct { +type SystrayApp struct { concurrentPresets bool presets []PresetMenuEntry @@ -34,9 +34,9 @@ type SysTrayApp struct { mQuit *systray.MenuItem } -func NewSystrayApp(menuTemplate []PresetMenuEntry, layerIcons LayerIcons, allowConcurrentPresets bool) *SysTrayApp { +func NewSystrayApp(menuTemplate []PresetMenuEntry, layerIcons LayerIcons, allowConcurrentPresets bool) *SystrayApp { - t := &SysTrayApp{ + t := &SystrayApp{ presets: menuTemplate, layerIcons: layerIcons, concurrentPresets: allowConcurrentPresets, @@ -75,7 +75,7 @@ func NewSystrayApp(menuTemplate []PresetMenuEntry, layerIcons LayerIcons, allowC return t } -func (t *SysTrayApp) runPreset(presetIndex int, runner *runner_pkg.Runner) { +func (t *SystrayApp) runPreset(presetIndex int, runner *runner_pkg.Runner) { if t.concurrentPresets { fmt.Printf("Switching preset to '%s'\n", t.presets[presetIndex].PresetName) } else { @@ -97,7 +97,7 @@ func (t *SysTrayApp) runPreset(presetIndex int, runner *runner_pkg.Runner) { t.presetCancelFuncs[presetIndex] = cancel } -func (app *SysTrayApp) StartProcessingLoop(runner *runner_pkg.Runner, configFolder string) { +func (app *SystrayApp) StartProcessingLoop(runner *runner_pkg.Runner, configFolder string) { systray.SetIcon(icons.Pause) for i, preset := range app.presets { if preset.Preset.Autorun { @@ -199,7 +199,7 @@ func (app *SysTrayApp) StartProcessingLoop(runner *runner_pkg.Runner, configFold } } -func (t *SysTrayApp) indexFromPresetName(presetName string) (int, error) { +func (t *SystrayApp) indexFromPresetName(presetName string) (int, error) { for i, p := range t.presets { if p.PresetName == presetName { return i, nil @@ -208,7 +208,7 @@ func (t *SysTrayApp) indexFromPresetName(presetName string) (int, error) { return 0, fmt.Errorf("not found") } -func (t *SysTrayApp) isAnyPresetRunning() bool { +func (t *SystrayApp) isAnyPresetRunning() bool { for _, status := range t.statuses { if status == statusRunning { return true @@ -217,14 +217,14 @@ func (t *SysTrayApp) isAnyPresetRunning() bool { return false } -func (t *SysTrayApp) setStatus(presetIndex int, status KanataStatus) { +func (t *SystrayApp) setStatus(presetIndex int, status KanataStatus) { t.statuses[presetIndex] = status t.mPresetStatuses[presetIndex].SetTitle(string(status)) t.mPresets[presetIndex].SetTitle(t.presets[presetIndex].Title(status)) } // Cancels (stops) preset at given index. -func (t *SysTrayApp) cancel(presetIndex int) { +func (t *SystrayApp) cancel(presetIndex int) { cancel := t.presetCancelFuncs[presetIndex] if cancel != nil { cancel() From 95feee1a8410130a3131a4c91b1a79aaf48d836d Mon Sep 17 00:00:00 2001 From: rszyma Date: Thu, 21 Mar 2024 00:37:36 +0100 Subject: [PATCH 13/14] fix autorun when allow_concurrent_presets is set to false --- .vscode/launch.json | 15 +++++++ app/app.go | 86 +++++++++++++++++++++++++++-------------- main.go | 2 +- runner/kanata/kanata.go | 11 +++--- runner/runner.go | 7 ++-- 5 files changed, 84 insertions(+), 37 deletions(-) create mode 100644 .vscode/launch.json 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/app/app.go b/app/app.go index 5b6bcd9..eaf4e84 100644 --- a/app/app.go +++ b/app/app.go @@ -15,6 +15,10 @@ import ( type SystrayApp struct { concurrentPresets bool + // Used when `concurrentPresets` is disabled. + // Value -1 denotes that no config is scheduled to run. + scheduledPresetIndex int + presets []PresetMenuEntry statuses []KanataStatus presetCancelFuncs []context.CancelFunc // cancel functions can be nil @@ -36,16 +40,17 @@ type SystrayApp struct { func NewSystrayApp(menuTemplate []PresetMenuEntry, layerIcons LayerIcons, allowConcurrentPresets bool) *SystrayApp { - t := &SystrayApp{ - presets: menuTemplate, - layerIcons: layerIcons, - concurrentPresets: allowConcurrentPresets, - } - systray.SetIcon(icons.Default) systray.SetTitle("kanata-tray") systray.SetTooltip("kanata-tray") + t := &SystrayApp{ + presets: menuTemplate, + scheduledPresetIndex: -1, + layerIcons: layerIcons, + concurrentPresets: allowConcurrentPresets, + } + for _, entry := range menuTemplate { menuItem := systray.AddMenuItem(entry.Title(statusIdle), entry.Tooltip()) if !entry.IsSelectable { @@ -76,11 +81,22 @@ func NewSystrayApp(menuTemplate []PresetMenuEntry, layerIcons LayerIcons, allowC } func (t *SystrayApp) runPreset(presetIndex int, runner *runner_pkg.Runner) { - if t.concurrentPresets { + if !t.concurrentPresets && t.isAnyPresetRunning() { fmt.Printf("Switching preset to '%s'\n", t.presets[presetIndex].PresetName) - } else { - fmt.Printf("Running preset '%s'\n", t.presets[presetIndex].PresetName) + for i := range t.presets { + t.cancel(i) + t.setStatus(i, statusIdle) + } + if t.scheduledPresetIndex != -1 { + fmt.Println("the previously scheduled preset was not ran!") + } + t.scheduledPresetIndex = presetIndex + // Preset has been scheduled to run, and will actutally be run when the previous one exits. + return } + + fmt.Printf("Running preset '%s'\n", t.presets[presetIndex].PresetName) + t.setStatus(presetIndex, statusStarting) kanataExecutable := t.presets[presetIndex].Preset.KanataExecutable kanataConfig := t.presets[presetIndex].Preset.KanataConfig @@ -92,20 +108,28 @@ func (t *SystrayApp) runPreset(presetIndex int, runner *runner_pkg.Runner) { cancel() return } - t.setStatus(presetIndex, statusRunning) t.cancel(presetIndex) + t.setStatus(presetIndex, statusRunning) t.presetCancelFuncs[presetIndex] = cancel } func (app *SystrayApp) StartProcessingLoop(runner *runner_pkg.Runner, configFolder string) { - systray.SetIcon(icons.Pause) + app.setIcon(icons.Pause) + + // handle autoruns + autoranOnePreset := false for i, preset := range app.presets { if preset.Preset.Autorun { - app.runPreset(i, runner) - if app.concurrentPresets { - // Execute only the first preset if multi-exec is disabled. - break + if !app.concurrentPresets { + if !autoranOnePreset { + autoranOnePreset = true + } else { + fmt.Println("WARNING: more than 1 preset has autorun enabled, but " + + "can't run them all, because `allow_concurrent_presets` is not enabled.") + break + } } + app.runPreset(i, runner) } } @@ -121,7 +145,7 @@ func (app *SystrayApp) StartProcessingLoop(runner *runner_pkg.Runner, configFold if icon == nil { icon = icons.Default } - systray.SetIcon(icon) + app.setIcon(icon) } if event.Item.LayerNames != nil { mappedLayers := app.layerIcons.MappedLayers(event.PresetName) @@ -139,26 +163,30 @@ func (app *SystrayApp) StartProcessingLoop(runner *runner_pkg.Runner, configFold } } case ret := <-retCh: - err := ret.Item - i, err1 := app.indexFromPresetName(ret.PresetName) - if err1 != nil { + kanataProcessErr := ret.Item + i, err := app.indexFromPresetName(ret.PresetName) + if err != nil { fmt.Printf("ERROR: Preset not found: %s\n", ret.PresetName) continue } - if err != nil { - fmt.Printf("Kanata process terminated with an error: %v\n", err) + app.cancel(i) + if kanataProcessErr != nil { + fmt.Printf("Kanata process terminated with an error: %v\n", kanataProcessErr) app.setStatus(i, statusCrashed) - systray.SetIcon(icons.Crash) + app.setIcon(icons.Crash) } else { - fmt.Println("Kanata process terminated successfully") + fmt.Println("Previous kanata process terminated successfully") app.setStatus(i, statusIdle) if app.isAnyPresetRunning() { - systray.SetIcon(icons.Default) + app.setIcon(icons.Default) } else { - systray.SetIcon(icons.Pause) + app.setIcon(icons.Pause) } } - app.cancel(i) + if app.scheduledPresetIndex != -1 { + app.runPreset(app.scheduledPresetIndex, runner) + app.scheduledPresetIndex = -1 + } case i := <-app.statusClickedCh: switch app.statuses[i] { case statusIdle: @@ -167,10 +195,8 @@ func (app *SystrayApp) StartProcessingLoop(runner *runner_pkg.Runner, configFold case statusRunning: // stop kanata app.cancel(i) - app.setStatus(i, statusIdle) case statusCrashed: // restart kanata (from crashed state) - fmt.Println("Restarting kanata") app.runPreset(i, runner) } case i := <-app.openLogsClickedCh: @@ -232,6 +258,10 @@ func (t *SystrayApp) cancel(presetIndex int) { t.presetCancelFuncs[presetIndex] = nil } +func (t *SystrayApp) setIcon(iconBytes []byte) { + systray.SetIcon(iconBytes) +} + // Returns a channel that sends an index of item that was clicked. // TODO: pass ctx and cleanup on ctx cancel. func multipleMenuItemsClickListener(menuItems []*systray.MenuItem) chan int { diff --git a/main.go b/main.go index 114f864..f787f32 100644 --- a/main.go +++ b/main.go @@ -73,7 +73,7 @@ func mainImpl() error { // 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, cfg.General.AllowConcurrentPresets) + runner := runner.NewRunner(ctx) onReady := func() { app := app.NewSystrayApp(menuTemplate, layerIcons, cfg.General.AllowConcurrentPresets) diff --git a/runner/kanata/kanata.go b/runner/kanata/kanata.go index 17f9050..ee87f43 100644 --- a/runner/kanata/kanata.go +++ b/runner/kanata/kanata.go @@ -16,8 +16,8 @@ import ( // Reusing with different kanata configs/presets is allowed. type Kanata struct { // Prevents race condition when restarting kanata. - // This must be written to from from outside to free an internal slot. - ProcessSlotCh chan struct{} + // 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 @@ -27,7 +27,7 @@ type Kanata struct { func NewKanataInstance() *Kanata { return &Kanata{ - ProcessSlotCh: make(chan struct{}, 1), + processSlotCh: make(chan struct{}, 1), retCh: make(chan error), cmd: nil, @@ -54,9 +54,9 @@ func (r *Kanata) RunNonblocking(ctx context.Context, kanataExecutable string, ka cmd.SysProcAttr = os_specific.ProcessAttr go func() { - // We're waiting for previous process to be marked as finished in processing loop. + // 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{}{} + r.processSlotCh <- struct{}{} if r.logFile != nil { r.logFile.Close() @@ -124,6 +124,7 @@ func (r *Kanata) RunNonblocking(ctx context.Context, kanataExecutable string, ka // kanata crashed or terminated itself r.retCh <- err } + <-r.processSlotCh }() return nil diff --git a/runner/runner.go b/runner/runner.go index cdb8737..4e7c31a 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -22,15 +22,16 @@ type Runner struct { clientMessageChannels map[string]chan tcp_client.ClientMessage // Maps preset names to runner indices in `runnerPool` and contexts in `instanceWatcherCtxs`. activeKanataInstances map[string]int - kanataInstancePool []*kanata.Kanata - instanceWatcherCtxs []context.Context + // 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 NewRunner(ctx context.Context, concurrent bool) *Runner { +func NewRunner(ctx context.Context) *Runner { activeInstancesLimit := 10 return &Runner{ retCh: make(chan ItemAndPresetName[error]), From 06a8f79b437b2b5e569315becb63748ce96e5d97 Mon Sep 17 00:00:00 2001 From: rszyma Date: Thu, 21 Mar 2024 23:40:38 +0100 Subject: [PATCH 14/14] update README.md --- README.md | 91 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 51 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 61f1ee4..78bd825 100644 --- a/README.md +++ b/README.md @@ -12,62 +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` -### Config completion in editors - -In VSCode to get editor support for your kanata-tray config, install [Even Better TOML](https://marketplace.visualstudio.com/items?itemName=tamasfe.even-better-toml#completion-and-validation-with-json-schema) extension and the following line at the top of your `config.toml` file. -```toml -"$schema" = "https://raw.githubusercontent.com/rszyma/kanata-tray/v0.2.0/doc/config_schema.json" -``` -Make sure to replace version number in the schema link with whatever kanata-tray version you use. - -### Examples +### 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 -Notes: +`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`. + +`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\\