Compare commits

..

7 commits
0.0.1 ... main

8 changed files with 198 additions and 173 deletions

View file

@ -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 <command> [args...]
Commands:
help
Print this help text
control <config>
Read the <config>-file and start listening for global hotkeys
info <config>
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
```

View file

@ -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) }
}

View file

@ -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

View file

@ -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

View file

@ -236,6 +236,7 @@ const (
)
var keyCodeMap = map[uint16]string{
// 0 must not exist!
KEY_ESC: "ESC",
KEY_1: "1",
KEY_2: "2",

53
libresplit.go Normal file
View file

@ -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)
}
}

138
main.go
View file

@ -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:] + " <command> [args...]\n\n")
fmt.Print("Commands:\n\n")
fmt.Print(" help\n")
fmt.Print(" Print this help text\n\n")
fmt.Print(" control <config>\n")
fmt.Print(" Read the <config>-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:] + " <command> [args...]\n\n")
fmt.Print("Commands:\n\n")
fmt.Print(" help\n")
fmt.Print(" Print this help text\n\n")
fmt.Print(" control <config>\n")
fmt.Print(" Read the <config>-file and start listening for global hotkeys\n\n")
fmt.Print(" info <config>\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 <config>\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()
}
}

20
xdg.go Normal file
View file

@ -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, "/")
}