Created
December 7, 2025 11:23
-
-
Save shift/2f08399fca76d34c189915b8e93372e4 to your computer and use it in GitHub Desktop.
Virtual HID keyboard macro for Linux
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package main | |
| import ( | |
| "bytes" | |
| "encoding/binary" | |
| "flag" | |
| "fmt" | |
| "io/ioutil" | |
| "log" | |
| "os" | |
| "os/signal" | |
| "strings" | |
| "syscall" | |
| "time" | |
| "gopkg.in/yaml.v3" | |
| ) | |
| // --- HID Constants & Structures --- | |
| const ( | |
| UHID_CREATE2 = 11 | |
| UHID_INPUT2 = 12 | |
| ) | |
| var keyboardReportDesc = []byte{ | |
| 0x05, 0x01, 0x09, 0x06, 0xa1, 0x01, 0x05, 0x07, 0x19, 0xe0, 0x29, 0xe7, 0x15, 0x00, 0x25, 0x01, | |
| 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0x95, 0x01, 0x75, 0x08, 0x81, 0x01, 0x95, 0x05, 0x75, 0x01, | |
| 0x05, 0x08, 0x19, 0x01, 0x29, 0x05, 0x91, 0x02, 0x95, 0x01, 0x75, 0x03, 0x91, 0x01, 0x95, 0x06, | |
| 0x75, 0x08, 0x15, 0x00, 0x25, 0x65, 0x05, 0x07, 0x19, 0x00, 0x29, 0x65, 0x81, 0x00, 0xc0, | |
| } | |
| type Create2Req struct { | |
| Name [128]byte | |
| Phys [64]byte | |
| Uniq [64]byte | |
| RdSize uint16 | |
| Bus uint16 | |
| Vendor uint32 | |
| Product uint32 | |
| Version uint32 | |
| Country uint32 | |
| RdData [4096]byte | |
| } | |
| // --- Configuration Structures --- | |
| type Config struct { | |
| Name string `yaml:"name"` | |
| Steps []Step `yaml:"steps"` | |
| } | |
| type Step struct { | |
| Action string `yaml:"action"` // type, press, combo, wait | |
| Text string `yaml:"text,omitempty"` // For "type" | |
| Key string `yaml:"key,omitempty"` // For "press"/"combo" | |
| Modifiers []string `yaml:"modifiers,omitempty"` // For "combo" | |
| Duration string `yaml:"duration,omitempty"` // For "wait" | |
| } | |
| // --- Key Mappings --- | |
| var hidMap = map[string]byte{ | |
| "A": 0x04, "B": 0x05, "C": 0x06, "D": 0x07, "E": 0x08, "F": 0x09, "G": 0x0a, "H": 0x0b, | |
| "I": 0x0c, "J": 0x0d, "K": 0x0e, "L": 0x0f, "M": 0x10, "N": 0x11, "O": 0x12, "P": 0x13, | |
| "Q": 0x14, "R": 0x15, "S": 0x16, "T": 0x17, "U": 0x18, "V": 0x19, "W": 0x1a, "X": 0x1b, | |
| "Y": 0x1c, "Z": 0x1d, "1": 0x1e, "2": 0x1f, "3": 0x20, "4": 0x21, "5": 0x22, "6": 0x23, | |
| "7": 0x24, "8": 0x25, "9": 0x26, "0": 0x27, | |
| "ENTER": 0x28, "ESC": 0x29, "BACKSPACE": 0x2a, "TAB": 0x2b, "SPACE": 0x2c, | |
| "-": 0x2d, "=": 0x2e, "[": 0x2f, "]": 0x30, "\\": 0x31, ";": 0x33, "'": 0x34, | |
| ",": 0x36, ".": 0x37, "/": 0x38, | |
| "F1": 0x3a, "F2": 0x3b, "F3": 0x3c, "F4": 0x3d, "F5": 0x3e, "F6": 0x3f, | |
| "F7": 0x40, "F8": 0x41, "F9": 0x42, "F10": 0x43, "F11": 0x44, "F12": 0x45, | |
| "RIGHT": 0x4f, "LEFT": 0x50, "DOWN": 0x51, "UP": 0x52, | |
| } | |
| var modMap = map[string]byte{ | |
| "CTRL": 0x01, "LCTRL": 0x01, | |
| "SHIFT": 0x02, "LSHIFT": 0x02, | |
| "ALT": 0x04, "LALT": 0x04, | |
| "GUI": 0x08, "SUPER": 0x08, "META": 0x08, | |
| "RCTRL": 0x10, "RSHIFT": 0x20, "RALT": 0x40, "RGUI": 0x80, | |
| } | |
| // --- Logic --- | |
| func main() { | |
| configFile := flag.String("c", "macro.yaml", "Path to YAML configuration file") | |
| flag.Parse() | |
| // 1. Load Config | |
| cfgData, err := ioutil.ReadFile(*configFile) | |
| if err != nil { | |
| log.Fatalf("Error reading config: %v", err) | |
| } | |
| var config Config | |
| if err := yaml.Unmarshal(cfgData, &config); err != nil { | |
| log.Fatalf("Error parsing YAML: %v", err) | |
| } | |
| // 2. Init UHID | |
| f, err := os.OpenFile("/dev/uhid", os.O_RDWR, 0) | |
| if err != nil { | |
| log.Fatalf("Failed to open /dev/uhid: %v", err) | |
| } | |
| defer f.Close() | |
| if err := createKeyboard(f); err != nil { | |
| log.Fatalf("Failed to create keyboard: %v", err) | |
| } | |
| // Keep alive reader | |
| go func() { | |
| buf := make([]byte, 4096) | |
| for { | |
| f.Read(buf) | |
| } | |
| }() | |
| fmt.Printf("Device '%s' active. Executing sequence '%s' in 2 seconds...\n", "Go Automator", config.Name) | |
| time.Sleep(2 * time.Second) | |
| // 3. Execute Steps | |
| for i, step := range config.Steps { | |
| fmt.Printf("[%d] Executing %s...\n", i, step.Action) | |
| if err := executeStep(f, step); err != nil { | |
| log.Printf("Step failed: %v", err) | |
| } | |
| } | |
| fmt.Println("Sequence Complete. Exiting.") | |
| destroyDevice(f) | |
| } | |
| func executeStep(f *os.File, step Step) error { | |
| switch step.Action { | |
| case "wait": | |
| d, err := time.ParseDuration(step.Duration) | |
| if err != nil { | |
| return err | |
| } | |
| time.Sleep(d) | |
| case "type": | |
| for _, char := range step.Text { | |
| pressChar(f, char) | |
| time.Sleep(15 * time.Millisecond) // Typing speed | |
| } | |
| case "press": | |
| code, ok := resolveKey(step.Key) | |
| if !ok { | |
| return fmt.Errorf("unknown key: %s", step.Key) | |
| } | |
| sendInput(f, 0, code) | |
| time.Sleep(50 * time.Millisecond) | |
| sendInput(f, 0, 0) | |
| case "combo": | |
| var mods byte | |
| for _, m := range step.Modifiers { | |
| if val, ok := modMap[strings.ToUpper(m)]; ok { | |
| mods |= val | |
| } | |
| } | |
| code, ok := resolveKey(step.Key) | |
| if !ok { | |
| return fmt.Errorf("unknown key in combo: %s", step.Key) | |
| } | |
| sendInput(f, mods, code) | |
| time.Sleep(50 * time.Millisecond) | |
| sendInput(f, 0, 0) | |
| } | |
| return nil | |
| } | |
| func resolveKey(k string) (byte, bool) { | |
| k = strings.ToUpper(k) | |
| if code, ok := hidMap[k]; ok { | |
| return code, true | |
| } | |
| // Fallback for single characters if user put "a" instead of "A" | |
| if len(k) == 1 { | |
| if code, ok := hidMap[string(k[0])]; ok { | |
| return code, true | |
| } | |
| } | |
| return 0, false | |
| } | |
| func pressChar(f *os.File, char rune) { | |
| // Simple ASCII mapping logic | |
| // Note: This needs a more robust map for special symbols like !, @, # | |
| // For brevity, we handle a-z, A-Z, 0-9 and space. | |
| s := string(char) | |
| upper := strings.ToUpper(s) | |
| code, ok := hidMap[upper] | |
| if !ok { | |
| return // Skip unknown | |
| } | |
| mods := byte(0) | |
| // If original was uppercase, hold shift | |
| if strings.ContainsAny(s, "ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()_+{}|:\"<>?") { | |
| mods = 0x02 // Left Shift | |
| } | |
| sendInput(f, mods, code) | |
| time.Sleep(20 * time.Millisecond) | |
| sendInput(f, 0, 0) | |
| } | |
| func createKeyboard(f *os.File) error { | |
| req := Create2Req{ | |
| RdSize: uint16(len(keyboardReportDesc)), | |
| Bus: 0x03, | |
| Vendor: 0x1235, | |
| Product: 0x5679, | |
| Version: 1, | |
| } | |
| copy(req.Name[:], "Go Automator") | |
| copy(req.RdData[:], keyboardReportDesc) | |
| buf := new(bytes.Buffer) | |
| binary.Write(buf, binary.LittleEndian, uint32(UHID_CREATE2)) | |
| binary.Write(buf, binary.LittleEndian, req) | |
| _, err := f.Write(buf.Bytes()) | |
| return err | |
| } | |
| func destroyDevice(f *os.File) { | |
| // Standard close handles it, but we can send UHID_DESTROY (1) if strict | |
| } | |
| func sendInput(f *os.File, mod byte, key byte) { | |
| // 8 byte report: [Mod, Reserved, Key1, 0, 0, 0, 0, 0] | |
| data := []byte{mod, 0, key, 0, 0, 0, 0, 0} | |
| buf := new(bytes.Buffer) | |
| binary.Write(buf, binary.LittleEndian, uint32(UHID_INPUT2)) | |
| binary.Write(buf, binary.LittleEndian, uint16(len(data))) | |
| buf.Write(data) | |
| padding := make([]byte, 4096-len(data)) | |
| buf.Write(padding) | |
| f.Write(buf.Bytes()) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment