diff --git a/README.md b/README.md index 38f8e13..14e015d 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# (WIP) keyVent - LibreSplit Global Hotkeys +# keyVent - LibreSplit Global Hotkeys > [!WARNING] -> This is a work in progress! +> Still in beta stage. Expect bugs! > [!NOTE] > Only single-key hotkeys are supported. @@ -20,7 +20,8 @@ The program then controls LibreSplit through it's libresplit-ctl unix domain soc This program was tested on -- Debian 13 / Wayland +- Debian 13 / Gnome / Wayland +- Ubuntu 24.04 / Gnome / Wayland + X11 ## Permissions @@ -58,14 +59,27 @@ Use `0` to disable the key bind. ## Usage -Example: +``` +keyvent [args...] + +Commands: + + help + Print this help text + + control + Read the -file and start listening for global hotkeys + + info + Show informations about the given config file and the environment + + dumpkeys + Print all keypresses to stdout + +``` + +### Example ``` keyvent control ./path/to/config.json ``` - -Print Help Text: - -``` -keyvent help -``` diff --git a/config.go b/config.go index 1fea73c..1ed03f2 100644 --- a/config.go +++ b/config.go @@ -2,7 +2,17 @@ package main // Copyright (c) 2026 Julian Müller (ChaoticByte) -type KeyBindsConfig struct { +import ( + "encoding/json" + "os" +) + +type KeybindFriendlyName struct { + FriendlyName string + Code uint16 +} + +type KeybindsConfig struct { StartOrSplit uint16 `json:"start_or_split"` StopOrReset uint16 `json:"stop_or_reset"` Cancel uint16 `json:"cancel"` @@ -11,6 +21,25 @@ type KeyBindsConfig struct { CloseLibreSplit uint16 `json:"close_libresplit"` } -type Config struct { - KeyBinds KeyBindsConfig `json:"keybinds"` +func (kbc *KeybindsConfig) FriendlyNames() []KeybindFriendlyName { + return []KeybindFriendlyName { + { "Start/Split", kbc.StartOrSplit}, + { "Stop/Reset", kbc.StopOrReset}, + { "Cancel", kbc.Cancel}, + { "Undo Split", kbc.Unsplit}, + { "Skip Split", kbc.SkipSplit}, + { "Close LibreSplit", kbc.CloseLibreSplit}, + } +} + +var config struct { + Keybinds KeybindsConfig `json:"keybinds"` +} + +func ReadConfig() { + // read config + configData, err := os.ReadFile(os.Args[2]) + if err != nil { panic(err) } + err = json.Unmarshal(configData, &config) + if err != nil { panic(err) } } diff --git a/input_event.go b/input_event.go index 6fadf2d..260116b 100644 --- a/input_event.go +++ b/input_event.go @@ -8,45 +8,13 @@ import ( "unsafe" ) -const ( - // EvSyn is used as markers to separate events. Events may be separated in time or in space, such as with the multitouch protocol. - EvSyn EventType = 0x00 - // EvKey is used to describe state changes of keyboards, buttons, or other key-like devices. - EvKey EventType = 0x01 - // EvRel is used to describe relative axis value changes, e.g. moving the mouse 5 units to the left. - EvRel EventType = 0x02 - // EvAbs is used to describe absolute axis value changes, e.g. describing the coordinates of a touch on a touchscreen. - EvAbs EventType = 0x03 - // EvMsc is used to describe miscellaneous input data that do not fit into other types. - EvMsc EventType = 0x04 - // EvSw is used to describe binary state input switches. - EvSw EventType = 0x05 - // EvLed is used to turn LEDs on devices on and off. - EvLed EventType = 0x11 - // EvSnd is used to output sound to devices. - EvSnd EventType = 0x12 - // EvRep is used for autorepeating devices. - EvRep EventType = 0x14 - // EvFf is used to send force feedback commands to an input device. - EvFf EventType = 0x15 - // EvPwr is a special type for power button and switch input. - EvPwr EventType = 0x16 - // EvFfStatus is used to receive force feedback device status. - EvFfStatus EventType = 0x17 -) - -// EventType are groupings of codes under a logical input construct. -// Each type has a set of applicable codes to be used in generating events. -// See the Ev section for details on valid codes for each type -type EventType uint16 - // eventsize is size of structure of InputEvent var eventsize = int(unsafe.Sizeof(InputEvent{})) // InputEvent is the keyboard event structure itself type InputEvent struct { Time syscall.Timeval - Type EventType + Type uint16 Code uint16 Value int32 } @@ -62,16 +30,6 @@ func (i *InputEvent) KeyPress() bool { return i.Value == 1 } -// KeyRelease is the value when we release the key on keyboard -func (i *InputEvent) KeyRelease() bool { - return i.Value == 0 -} - -// Time of the keypress in nanoseconds -func (i *InputEvent) TimeNano() int64 { - return i.Time.Nano() -} - // KeyEvent is the keyboard event for up/down (press/release) type KeyEvent int32 diff --git a/keylistener.go b/keylistener.go index 6f966f9..eaccc17 100644 --- a/keylistener.go +++ b/keylistener.go @@ -13,6 +13,14 @@ import ( "syscall" ) +// system paths +const SysDeviceNamePath = "/sys/class/input/event%d/device/name" +const DevInputEventPath = "/dev/input/event%d" + +// use lowercase names for devices, as we turn the device input name to lower case +var restrictedDevices = devices{"mouse"} +var allowedDevices = devices{"keyboard", "logitech mx keys"} + // KeyListener wrapper around file descriptior type KeyListener struct { fd *os.File @@ -30,10 +38,6 @@ func (d *devices) hasDevice(str string) bool { return false } -// use lowercase names for devices, as we turn the device input name to lower case -var restrictedDevices = devices{"mouse"} -var allowedDevices = devices{"keyboard", "logitech mx keys"} - // New creates a new keylogger for a device path func New(devPath string) (*KeyListener, error) { k := &KeyListener{} @@ -48,41 +52,13 @@ func New(devPath string) (*KeyListener, error) { return k, nil } -// FindKeyboardDevice by going through each device registered on OS -// Mostly it will contain keyword - keyboard -// Returns the file path which contains events -func FindKeyboardDevice() string { - path := "/sys/class/input/event%d/device/name" - resolved := "/dev/input/event%d" - - for i := range 255 { - buff, err := os.ReadFile(fmt.Sprintf(path, i)) - if err != nil { - continue - } - - deviceName := strings.ToLower(string(buff)) - - if restrictedDevices.hasDevice(deviceName) { - continue - } else if allowedDevices.hasDevice(deviceName) { - return fmt.Sprintf(resolved, i) - } - } - - return "" -} - // Like FindKeyboardDevice, but finds all devices which contain keyword 'keyboard' // Returns an array of file paths which contain keyboard events func FindAllKeyboardDevices() []string { - path := "/sys/class/input/event%d/device/name" - resolved := "/dev/input/event%d" - valid := make([]string, 0) - for i := 0; i < 255; i++ { - buff, err := os.ReadFile(fmt.Sprintf(path, i)) + for i := range 255 { + buff, err := os.ReadFile(fmt.Sprintf(SysDeviceNamePath, i)) // prevent from checking non-existant files if os.IsNotExist(err) { @@ -97,7 +73,7 @@ func FindAllKeyboardDevices() []string { if restrictedDevices.hasDevice(deviceName) { continue } else if allowedDevices.hasDevice(deviceName) { - valid = append(valid, fmt.Sprintf(resolved, i)) + valid = append(valid, fmt.Sprintf(DevInputEventPath, i)) } } return valid diff --git a/keymap.go b/keymap.go index 82d4e60..d057fcb 100644 --- a/keymap.go +++ b/keymap.go @@ -236,6 +236,7 @@ const ( ) var keyCodeMap = map[uint16]string{ + // 0 must not exist! KEY_ESC: "ESC", KEY_1: "1", KEY_2: "2", diff --git a/libresplit.go b/libresplit.go new file mode 100644 index 0000000..20fb0ad --- /dev/null +++ b/libresplit.go @@ -0,0 +1,53 @@ +package main + +// Copyright (c) 2026 Julian Müller (ChaoticByte) + +import ( + "encoding/binary" + "errors" + "fmt" + "net" +) + +const LibreSplitSocket = "libresplit.sock" + +const ( + CmdStartSplit uint32 = 0 + CmdStopReset uint32 = 1 + CmdCancel uint32 = 2 + CmdUnsplit uint32 = 3 + CmdSkip uint32 = 4 + CmdExit uint32 = 5 // close LibreSplit +) + +func LibreSplitSocketPath() string { + return xdg_runtime_dir + "/" + LibreSplitSocket +} + +func EncodeCmd(cmd uint32) []byte { + buf := make([]byte, 8) + binary.BigEndian.PutUint32(buf, 4) // encode length + binary.LittleEndian.PutUint32(buf[4:8], cmd) // encode cmd + return buf +} + +func SendCommand(cmd uint32) { + // try opening socket + var err error + sock, err := net.Dial("unix", LibreSplitSocketPath()) + if err != nil { + fmt.Println("Could not connect to libresplit: ", err) + return + } + // write to socket + msg := EncodeCmd(cmd) + n, err := sock.Write(msg) + // fmt.Println(msg, n, err) + sock.Close() + if err != nil || n != len(msg) { + if err == nil { + err = errors.New("wrong amout of bytes was written") + } + fmt.Println("Could not communicate with libresplit socket: ", err) + } +} diff --git a/main.go b/main.go index 68ea863..7f36939 100644 --- a/main.go +++ b/main.go @@ -3,56 +3,15 @@ package main // Copyright (c) 2026 Julian Müller (ChaoticByte) import ( - "encoding/binary" - "encoding/json" - "errors" "fmt" - "net" "os" "strings" ) -const LibreSplitSocket = "libresplit.sock" - -const ( - CmdStartSplit uint32 = 0 - CmdStopReset uint32 = 1 - CmdCancel uint32 = 2 - CmdUnsplit uint32 = 3 - CmdSkip uint32 = 4 - CmdExit uint32 = 5 // close LibreSplit -) - -// global vars - -var config Config -var xdg_runtime_dir string +// handlers var handler func(e InputEvent) -// - -func PrintHelp() { - arg1 := os.Args[0] - fmt.Print(arg1[strings.LastIndex(arg1, "/")+1:] + " [args...]\n\n") - fmt.Print("Commands:\n\n") - fmt.Print(" help\n") - fmt.Print(" Print this help text\n\n") - fmt.Print(" control \n") - fmt.Print(" Read the -file and start listening for global hotkeys\n\n") - fmt.Print(" dumpkeys\n") - fmt.Print(" Print all keypresses to stdout\n\n") - fmt.Printf("keyvent %s\n", Version) -} - - -func EncodeCmd(cmd uint32) []byte { - buf := make([]byte, 8) - binary.BigEndian.PutUint32(buf, 4) // encode length - binary.LittleEndian.PutUint32(buf[4:8], cmd) // encode cmd - return buf -} - func HandleDumpKey(e InputEvent) { if _, found := keyCodeMap[e.Code]; found { fmt.Printf("\nKey: %s, Code: %d\n", e.KeyString(), e.Code) @@ -67,42 +26,43 @@ func HandleControl(e InputEvent) { keybindFound := true var cmd uint32 switch e.Code { - case config.KeyBinds.StartOrSplit: + case config.Keybinds.StartOrSplit: cmd = CmdStartSplit - case config.KeyBinds.StopOrReset: + case config.Keybinds.StopOrReset: cmd = CmdStopReset - case config.KeyBinds.Cancel: + case config.Keybinds.Cancel: cmd = CmdCancel - case config.KeyBinds.Unsplit: + case config.Keybinds.Unsplit: cmd = CmdUnsplit - case config.KeyBinds.SkipSplit: + case config.Keybinds.SkipSplit: cmd = CmdSkip - case config.KeyBinds.CloseLibreSplit: + case config.Keybinds.CloseLibreSplit: cmd = CmdExit default: keybindFound = false } - if !keybindFound { return } - // try opening socket - var err error - sock, err := net.Dial("unix", xdg_runtime_dir + "/" + LibreSplitSocket) - if err != nil { - fmt.Println("Could not connect to libresplit: ", err) - return - } - // write to socket - msg := EncodeCmd(cmd) - n, err := sock.Write(msg) - // fmt.Println(msg, n, err) - sock.Close() - if err != nil || n != len(msg) { - if err == nil { - err = errors.New("wrong amout of bytes was written") - } - fmt.Println("Could not communicate with libresplit socket: ", err) + if keybindFound { + SendCommand(cmd) } } +// cli + +func PrintHelp() { + arg1 := os.Args[0] + fmt.Print(arg1[strings.LastIndex(arg1, "/")+1:] + " [args...]\n\n") + fmt.Print("Commands:\n\n") + fmt.Print(" help\n") + fmt.Print(" Print this help text\n\n") + fmt.Print(" control \n") + fmt.Print(" Read the -file and start listening for global hotkeys\n\n") + fmt.Print(" info \n") + fmt.Print(" Show informations about the given config file and the environment\n\n") + fmt.Print(" dumpkeys\n") + fmt.Print(" Print all keypresses to stdout\n\n") + fmt.Printf("keyvent %s\n", Version) +} + func main() { // parse cli @@ -114,7 +74,7 @@ func main() { PrintHelp() os.Exit(0) } - if os.Args[1] == "control" && len(os.Args) < 3 { + if (os.Args[1] == "control" || os.Args[1] == "info") && len(os.Args) < 3 { fmt.Print("Missing argument \n\n") PrintHelp() os.Exit(1) @@ -122,19 +82,35 @@ func main() { switch os.Args[1] { case "control": - // read config - configData, err := os.ReadFile(os.Args[2]) - if err != nil { panic(err) } - err = json.Unmarshal(configData, &config) - if err != nil { panic(err) } - // get xdg_runtime_dir - xdg_runtime_dir = os.Getenv("XDG_RUNTIME_DIR") - if xdg_runtime_dir == "" { - xdg_runtime_dir = fmt.Sprintf("/run/user/%d", os.Getuid()) - } - xdg_runtime_dir = strings.TrimRight(xdg_runtime_dir, "/") + ReadConfig() + DetectXdgRuntimeDir() // set handler handler = HandleControl + case "info": + ReadConfig() + DetectXdgRuntimeDir() + fmt.Println("Keybinds:") + for _, fn := range config.Keybinds.FriendlyNames() { + keyname := "unset" + keyname_, found := keyCodeMap[fn.Code] + if found { + keyname = keyname_ + } + fmt.Printf(" %s: %s (%d)\n", fn.FriendlyName, keyname, fn.Code) + } + lsSockPath := LibreSplitSocketPath() + fmt.Printf("LibreSplit Socket: %s\n", lsSockPath) + lsStatus := "" + _, err := os.Stat(lsSockPath) + if os.IsNotExist(err) { + lsStatus = "not running" + } else if err != nil { + lsStatus = fmt.Sprintf("? (%s)", err.Error()) + } else { + lsStatus = "running" + } + fmt.Printf("LibreSplit: %s\n", lsStatus) + return case "dumpkeys": handler = HandleDumpKey default: @@ -151,13 +127,12 @@ func main() { panic(err) } c := kl.Read() + defer kl.Close() defer close(c) go func () { for { e := <- c - if e.KeyPress() { - handler(e) - } + if e.KeyPress() { handler(e) } } }() } @@ -165,5 +140,4 @@ func main() { for { // wait fmt.Scanln() } - } diff --git a/xdg.go b/xdg.go new file mode 100644 index 0000000..3fffd75 --- /dev/null +++ b/xdg.go @@ -0,0 +1,20 @@ +package main + +// Copyright (c) 2026 Julian Müller (ChaoticByte) + +import ( + "fmt" + "os" + "strings" +) + +var xdg_runtime_dir string + +func DetectXdgRuntimeDir() { + // get xdg_runtime_dir + xdg_runtime_dir = os.Getenv("XDG_RUNTIME_DIR") + if xdg_runtime_dir == "" { + xdg_runtime_dir = fmt.Sprintf("/run/user/%d", os.Getuid()) + } + xdg_runtime_dir = strings.TrimRight(xdg_runtime_dir, "/") +}