diff --git a/Helper/messenger.cpp b/Helper/messenger.cpp deleted file mode 100644 index 89847c0..0000000 --- a/Helper/messenger.cpp +++ /dev/null @@ -1,555 +0,0 @@ -/* -Messenger v4.0 - Authenticated Input Injection - * -This tiny executable file must be spawned with UAC elevation. -Mostly needed if you want an "ink framework" app such as gemini cli or claude code, - you will need their pid and this to send message. -* -Such apps, while will get the message from emulator, - but won't process return key to send. . -* -SECURITY::: -Send text and special keys to Node.js/Ink apps (Claude CLI, Gemini, etc.) -with HMAC-SHA256 authentication to prevent unauthorized use. - * -Authentication Flow: -1. Server creates named pipe with session secret [Server implementation not included.] -2. This exe connects to pipe, receives secret + target binding -3. Verifies HMAC signature from command line args -4. Verifies target PID matches registered target -5. Only then proceeds with injection - * -Security Implementation: -1. HMAC SHA256 - Using windows bcrypt -2. 10 second replay protection -3. Target binding with PID+process name verification -4. Const time compare to prevent timing attac -5. Secret from pipe not hardcoded. I recommend implementing per session refresh. - * -Build: g++ -o messenger.exe messenger.cpp -static -s -mwindows -lbcrypt - * -Usage: - messenger.exe (text injection) - :: Sends enter automatically with a combo approach that works. - :: Just sending VK_RETURN, or \n or anything else doesn't. - */ - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#pragma comment(lib, "bcrypt.lib") - -// Return Codes -const int SUCCESS = 0; -const int ERR_USAGE = 1; -const int ERR_AUTH_FAILED = 401; -const int ERR_TARGET_MISMATCH = 403; -const int ERR_TIMESTAMP_EXPIRED = 408; -const int ERR_PIPE_NOT_FOUND = 503; -const int ERR_ATTACH_FAILED = 2; - -// Constants, use random uuid at runtime with another thing like server PID -const int TIMESTAMP_WINDOW_SECONDS = 10; -const char* DEFAULT_PIPE_NAME = "\\\\.\\pipe\\InjectorAuth"; - -// Auth Payload Structure -struct AuthPayload { - std::string secret; - DWORD target_pid; - std::string target_name; - bool valid; -}; - -// Helper: Process escape sequences (e.g. \t, \n) -std::string ProcessEscapeSequences(const std::string& input) { - std::string result; - for (size_t i = 0; i < input.length(); i++) { - if (input[i] == '\\' && i + 1 < input.length()) { - switch (input[i + 1]) { - case 'n': result += '\n'; i++; break; - case 'r': result += '\r'; i++; break; - case 't': result += '\t'; i++; break; - case '\\': result += '\\'; i++; break; - default: result += input[i]; break; - } - } else { - result += input[i]; - } - } - return result; -} - -// Auth: Get pipe name from file -std::string GetPipeName() { - char localAppData[MAX_PATH]; - if (FAILED(SHGetFolderPathA(NULL, CSIDL_LOCAL_APPDATA, NULL, 0, localAppData))) { - return DEFAULT_PIPE_NAME; - } - - std::string pipePath = std::string(localAppData) + "\\ClaudeInjector\\pipe_name.txt"; - - std::ifstream file(pipePath); - if (!file.is_open()) { - return DEFAULT_PIPE_NAME; - } - - std::string pipeName; - std::getline(file, pipeName); - file.close(); - - return pipeName.empty() ? DEFAULT_PIPE_NAME : pipeName; -} - -// Auth: Read auth payload from named pipe -AuthPayload ReadAuthFromPipe(int* error_code) { - AuthPayload result = {"", 0, "", false}; - *error_code = ERR_AUTH_FAILED; // Default to auth failure - - std::string pipeName = GetPipeName(); - - HANDLE pipe = CreateFileA( - pipeName.c_str(), - GENERIC_READ, - 0, - NULL, - OPEN_EXISTING, - 0, - NULL - ); - - if (pipe == INVALID_HANDLE_VALUE) { - *error_code = ERR_PIPE_NOT_FOUND; // 503 - return result; - } - - char buffer[512] = {0}; - DWORD bytesRead; - BOOL readResult = ReadFile(pipe, buffer, sizeof(buffer) - 1, &bytesRead, NULL); - CloseHandle(pipe); - - if (!readResult || bytesRead == 0) { - return result; - } - - std::string payload(buffer, bytesRead); - - if (payload == "REJECTED" || payload == "NO_TARGET") { - return result; - } - - size_t pos1 = payload.find('\n'); - size_t pos2 = payload.find('\n', pos1 + 1); - - if (pos1 == std::string::npos || pos2 == std::string::npos) { - return result; // default 401 - } - - result.secret = payload.substr(0, pos1); - result.target_pid = std::stoul(payload.substr(pos1 + 1, pos2 - pos1 - 1)); - result.target_name = payload.substr(pos2 + 1); - result.valid = true; - - *error_code = 0; // Success - - return result; -} - -// Auth: Get process name by PID -std::string GetProcessName(DWORD pid) { - HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); - if (snapshot == INVALID_HANDLE_VALUE) { - return ""; - } - - PROCESSENTRY32 pe32; - pe32.dwSize = sizeof(pe32); - - std::string result = ""; - if (Process32First(snapshot, &pe32)) { - do { - if (pe32.th32ProcessID == pid) { - result = pe32.szExeFile; - break; - } - } while (Process32Next(snapshot, &pe32)); - } - - CloseHandle(snapshot); - return result; -} - -// Auth: Verify target PID and process name -bool VerifyTarget(DWORD requestedPid, const AuthPayload& auth) { - // Check 1: Requested PID matches registered target - if (requestedPid != auth.target_pid) { - return false; - } - - // Check 2: Process at that PID has expected name - std::string actualName = GetProcessName(requestedPid); - - // Case-insensitive comparison - if (_stricmp(actualName.c_str(), auth.target_name.c_str()) != 0) { - return false; - } - - return true; -} - -// Auth: Verify timestamp is within acceptable window -bool VerifyTimestamp(const std::string& timestamp) { - int64_t ts = std::stoll(timestamp); - int64_t now = static_cast(time(nullptr)); - int64_t age = now - ts; - - return age >= 0 && age <= TIMESTAMP_WINDOW_SECONDS; -} - -// Auth: Decode hex string to raw bytes -std::vector HexDecode(const std::string& hex) { - std::vector bytes; - if (hex.length() % 2 != 0) return bytes; // Invalid hex length - - bytes.reserve(hex.length() / 2); - for (size_t i = 0; i < hex.length(); i += 2) { - unsigned int byte; - if (sscanf_s(hex.c_str() + i, "%02x", &byte) != 1) { - return std::vector(); // Parse error - } - bytes.push_back(static_cast(byte)); - } - return bytes; -} - -// Auth: Compute HMAC-SHA256 -std::string ComputeHMAC(const std::vector& key, const std::string& message) { - BCRYPT_ALG_HANDLE hAlg = NULL; - BCRYPT_HASH_HANDLE hHash = NULL; - UCHAR hash[32]; - //std::string result; - - // Open algorithm provider with HMAC flag - NTSTATUS status = BCryptOpenAlgorithmProvider( - &hAlg, - BCRYPT_SHA256_ALGORITHM, - NULL, - BCRYPT_ALG_HANDLE_HMAC_FLAG - ); - - if (!BCRYPT_SUCCESS(status)) { - return ""; - } - - // Create hash with secret key - status = BCryptCreateHash( - hAlg, - &hHash, - NULL, - 0, - (PUCHAR)key.data(), - (ULONG)key.size(), - 0 - ); - - if (!BCRYPT_SUCCESS(status)) { - BCryptCloseAlgorithmProvider(hAlg, 0); - return ""; - } - - // Hash the message - status = BCryptHashData( - hHash, - (PUCHAR)message.data(), - (ULONG)message.size(), - 0 - ); - - if (!BCRYPT_SUCCESS(status)) { - BCryptDestroyHash(hHash); - BCryptCloseAlgorithmProvider(hAlg, 0); - return ""; - } - - // Finish hash - status = BCryptFinishHash(hHash, hash, 32, 0); - - BCryptDestroyHash(hHash); - BCryptCloseAlgorithmProvider(hAlg, 0); - - if (!BCRYPT_SUCCESS(status)) { - return ""; - } - - // Convert to hex string - std::stringstream ss; - for (int i = 0; i < 32; i++) { - ss << std::hex << std::setfill('0') << std::setw(2) << (int)hash[i]; - } - - return ss.str(); -} - -// Auth: Constant-time string comparison (prevents timing attacks) -bool ConstantTimeCompare(const std::string& a, const std::string& b) { - volatile int result = a.size() ^ b.size(); // XOR lengths first to prevent leak although - // it'll always be 64 char anyways - - size_t len = (a.size() < b.size()) ? a.size() : b.size(); - for (size_t i = 0; i < len; i++) { - result |= a[i] ^ b[i]; - } - return result == 0; -} - -// Input Injection: Send Return/Enter Key -void SendRawModeEnter(HANDLE hConsoleInput) { - INPUT_RECORD records[2] = {0}; - - records[0].EventType = KEY_EVENT; - records[0].Event.KeyEvent.bKeyDown = TRUE; - records[0].Event.KeyEvent.wRepeatCount = 1; - records[0].Event.KeyEvent.wVirtualKeyCode = VK_RETURN; - records[0].Event.KeyEvent.wVirtualScanCode = 0x1C; - records[0].Event.KeyEvent.uChar.AsciiChar = '\r'; - records[0].Event.KeyEvent.dwControlKeyState = 0; - - records[1] = records[0]; - records[1].Event.KeyEvent.bKeyDown = FALSE; - - DWORD written; - WriteConsoleInput(hConsoleInput, records, 2, &written); -} - -// Input Injection: Send Tab Key -void SendRawModeTab(HANDLE hConsoleInput) { - INPUT_RECORD records[2] = {0}; - - records[0].EventType = KEY_EVENT; - records[0].Event.KeyEvent.bKeyDown = TRUE; - records[0].Event.KeyEvent.wRepeatCount = 1; - records[0].Event.KeyEvent.wVirtualKeyCode = VK_TAB; - records[0].Event.KeyEvent.wVirtualScanCode = 0x0F; - records[0].Event.KeyEvent.uChar.AsciiChar = '\t'; - records[0].Event.KeyEvent.dwControlKeyState = 0; - - records[1] = records[0]; - records[1].Event.KeyEvent.bKeyDown = FALSE; - - DWORD written; - WriteConsoleInput(hConsoleInput, records, 2, &written); -} - -// Input Injection: Send Escape Key -void SendRawModeEscape(HANDLE hConsoleInput) { - INPUT_RECORD records[2] = {0}; - - records[0].EventType = KEY_EVENT; - records[0].Event.KeyEvent.bKeyDown = TRUE; - records[0].Event.KeyEvent.wRepeatCount = 1; - records[0].Event.KeyEvent.wVirtualKeyCode = VK_ESCAPE; - records[0].Event.KeyEvent.wVirtualScanCode = 0x01; - records[0].Event.KeyEvent.uChar.AsciiChar = 0x1B; - records[0].Event.KeyEvent.dwControlKeyState = 0; - - records[1] = records[0]; - records[1].Event.KeyEvent.bKeyDown = FALSE; - - DWORD written; - WriteConsoleInput(hConsoleInput, records, 2, &written); -} - -// Input Injection: Send Shift Key -void SendRawModeShift(HANDLE hConsoleInput, bool keyDown) { - INPUT_RECORD record = {0}; - - record.EventType = KEY_EVENT; - record.Event.KeyEvent.bKeyDown = keyDown ? TRUE : FALSE; - record.Event.KeyEvent.wRepeatCount = 1; - record.Event.KeyEvent.wVirtualKeyCode = VK_LSHIFT; - record.Event.KeyEvent.wVirtualScanCode = 0x2A; - record.Event.KeyEvent.uChar.AsciiChar = 0; - record.Event.KeyEvent.dwControlKeyState = keyDown ? SHIFT_PRESSED : 0; - - DWORD written; - WriteConsoleInput(hConsoleInput, &record, 1, &written); -} - -// Input Injection: Send Standard Text -void SendText(HANDLE hConsoleInput, const std::string& text) { - std::vector buffer; - - for (char c : text) { - INPUT_RECORD irDown = {0}; - irDown.EventType = KEY_EVENT; - irDown.Event.KeyEvent.bKeyDown = TRUE; - irDown.Event.KeyEvent.wRepeatCount = 1; - irDown.Event.KeyEvent.uChar.AsciiChar = c; - irDown.Event.KeyEvent.dwControlKeyState = 0; - - SHORT vk = VkKeyScanA(c); - if (vk != -1) { - irDown.Event.KeyEvent.wVirtualKeyCode = LOBYTE(vk); - irDown.Event.KeyEvent.wVirtualScanCode = MapVirtualKeyA(LOBYTE(vk), MAPVK_VK_TO_VSC); - - if (HIBYTE(vk) & 1) { - irDown.Event.KeyEvent.dwControlKeyState |= SHIFT_PRESSED; - } - } - - INPUT_RECORD irUp = irDown; - irUp.Event.KeyEvent.bKeyDown = FALSE; - - buffer.push_back(irDown); - buffer.push_back(irUp); - } - - if (!buffer.empty()) { - DWORD written; - WriteConsoleInput(hConsoleInput, buffer.data(), (DWORD)buffer.size(), &written); - } -} - -// Print Usage -void PrintUsage() { - std::cout << "Messenger v4.0 - Authenticated Input Injection" << std::endl; - std::cout << std::endl; - std::cout << "Usage:" << std::endl; - std::cout << " messenger.exe " << std::endl; - std::cout << std::endl; - std::cout << "All commands require HMAC-SHA256 authentication." << std::endl; - std::cout << std::endl; - std::cout << "Special Commands:" << std::endl; - std::cout << " --enter Send Enter key" << std::endl; - std::cout << " --tab Send Tab key" << std::endl; - std::cout << " --escape Send Escape key" << std::endl; - std::cout << " --shift-down Press Shift key down" << std::endl; - std::cout << " --shift-up Release Shift key" << std::endl; - std::cout << std::endl; - std::cout << "Text Injection:" << std::endl; - std::cout << " Send text followed by Enter" << std::endl; - std::cout << std::endl; - std::cout << "Arguments:" << std::endl; - std::cout << " PID Target process ID" << std::endl; - std::cout << " command Text or special command (--enter, etc.)" << std::endl; - std::cout << " timestamp Unix timestamp from Python" << std::endl; - std::cout << " sig HMAC-SHA256(secret, PID|command|timestamp)" << std::endl; - std::cout << std::endl; - std::cout << "Return Codes:" << std::endl; - std::cout << " 0 Success" << std::endl; - std::cout << " 1 Usage error" << std::endl; - std::cout << " 2 AttachConsole failed" << std::endl; - std::cout << " 401 Authentication failed" << std::endl; - std::cout << " 403 Target PID/name mismatch" << std::endl; - std::cout << " 408 Timestamp expired" << std::endl; - std::cout << " 503 Auth pipe not found" << std::endl; -} - -//()()()()()()()// -int main(int argc, char* const argv[]) { // Added const for linter to shut up - // Argument parsing requires 4 args - if (argc < 5) { - PrintUsage(); - return ERR_USAGE; - } - - DWORD targetPID = std::atoi(argv[1]); - std::string command = ProcessEscapeSequences(argv[2]); - std::string timestamp = argv[3]; - std::string providedSig = argv[4]; - - // All commands require authentication (including special keys) - // Step 1: Verify timestamp freshness (before pipe connection) - if (!VerifyTimestamp(timestamp)) { - return ERR_TIMESTAMP_EXPIRED; - } - - // Step 2: Get auth payload from pipe - // This also triggers server-side binary verification [SHA256 of this compiled binary] - int authError = 0; - AuthPayload auth = ReadAuthFromPipe(&authError); - if (!auth.valid) { - return authError; // Returns 503 or 401 depending on failure type - } - - // Step 3: Verify target binding (PID + process name) - if (!VerifyTarget(targetPID, auth)) { - return ERR_TARGET_MISMATCH; - } - - // Step 4: Verify HMAC signature - // Decode hex-encoded secret to raw bytes (Python sends hex, uses raw for HMAC) - std::vector secretBytes = HexDecode(auth.secret); - if (secretBytes.empty()) { - return ERR_AUTH_FAILED; // Invalid secret format - } - - std::string message = std::to_string(targetPID) + "|" + command + "|" + timestamp; - std::string expectedSig = ComputeHMAC(secretBytes, message); - - if (expectedSig.empty() || !ConstantTimeCompare(expectedSig, providedSig)) { - return ERR_AUTH_FAILED; - } - - // --- Authentication passed, proceed with injection --- - - // Attach console (hide immediately when running without console) - if (AllocConsole()) { - ShowWindow(GetConsoleWindow(), SW_HIDE); - } - FreeConsole(); - - // Attach to target console - if (!AttachConsole(targetPID)) { - return ERR_ATTACH_FAILED; - } - - // Get input handle - HANDLE hStdIn = GetStdHandle(STD_INPUT_HANDLE); - if (hStdIn == INVALID_HANDLE_VALUE) { - FreeConsole(); - return ERR_ATTACH_FAILED; - } - - // Execute command - if (command == "--enter") { - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - SendRawModeEnter(hStdIn); - } - else if (command == "--tab") { - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - SendRawModeTab(hStdIn); - } - else if (command == "--escape") { - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - SendRawModeEscape(hStdIn); - } - else if (command == "--shift-down") { - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - SendRawModeShift(hStdIn, true); - } - else if (command == "--shift-up") { - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - SendRawModeShift(hStdIn, false); - } - else { - // Text injection - SendText(hStdIn, command); - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - SendRawModeEnter(hStdIn); - } - - FreeConsole(); - return SUCCESS; -} diff --git a/README.md b/README.md index dcb32e7..9196a56 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ winget install Revoconner.HeadlessTTY - ANSI escape codes pass through correctly - v2.5.0 - Now supports tray icon using --sys-tray argument. If you need a console for a long running process to see logs, or outputs just show from system tray and hide it back. - v2.5.0 - Right-click tray icon to show/hide console on demand with full color output and VT sequences output support. +- v2.6.0 - Full special key support in tray mode (Enter, Tab, Escape, arrows, F-keys, Ctrl/Alt combos) for INK framework TUI apps like Claude CLI and Gemini CLI. +- v2.6.0 - Key injection helper is now built into the binary -- separate messenger.exe is no longer needed. ## Why This Matters @@ -173,6 +175,7 @@ Low-level ConPTY wrapper. | `start_reading()` | Start background read thread | | `stop()` | Terminate process and cleanup | | `is_running()` | Check if process is still running | +| `get_child_pid()` | Get the spawned child process PID | | `wait(timeout)` | Wait for process to exit | | `resize(size)` | Resize the PTY | @@ -187,6 +190,7 @@ High-level wrapper that manages the full lifecycle. | `set_output_callback(cb)` | Set callback for output | | `stop()` | Stop the process | | `is_running()` | Check if running | +| `get_child_pid()` | Get child process PID | | `wait(timeout)` | Wait for exit | ## How It Works @@ -422,64 +426,23 @@ if (m_hJob) { **To disable child->parent termination**, remove `monitor_loop()` and related code. -## Helper file - Messenger.cpp +## Key Injection in Tray Mode -This tiny executable file must be spawned with UAC elevation. -Mostly needed if you want an INK - https://github.com/vadimdemedes/ink -app such as gemini cli or claude code... -you will need their pid and this to send message. +INK framework TUI apps (Claude CLI, Gemini CLI, etc.) don't process special keys (Enter, Tab, Escape, arrows, etc.) when sent as raw bytes through the PTY pipe. They need proper `INPUT_RECORD` events via `WriteConsoleInput`. -Such apps, while will get the message from emulator, -but won't process return key to send. +Since v2.6.0, headless-tty handles this automatically in `--sys-tray` mode using a self-spawning helper subprocess: +1. After starting the child process, headless-tty spawns a copy of itself with a hidden `--inject-helper` flag +2. The helper permanently attaches to the child's console via `AttachConsole` +3. Special keys and modifier combos (Ctrl/Alt) are sent from the parent to the helper through an anonymous pipe +4. The helper injects them as `INPUT_RECORD` events via `WriteConsoleInput` +5. Printable characters still go through the PTY pipe as before -**Implementation pseudocode:** +Commands in tray mode are automatically wrapped in `cmd /c` (unless the command is already `cmd` or `cmd.exe`) because `AttachConsole` to ConPTY-hosted non-shell processes is unreliable -- `cmd.exe` fully initializes the console session which makes attachment reliable. -```python -pseudocode: messenger_wrapper.py - -import os, time, hmac, hashlib, subprocess, secrets -from pathlib import Path - -class MessengerAuth: - def __init__(self, target_pid, target_name): - self.secret = secrets.token_bytes(32) # Raw bytes - self.target_pid = target_pid - self.target_name = target_name - self.pipe_name = f"\\\\.\\pipe\\InjectorAuth_{os.getpid()}" - - def start_pipe_server(self): - # Create named pipe, serve on connect: - # Send: f"{self.secret.hex()}\n{self.target_pid}\n{self.target_name}" - # Verify caller binary hash before responding (optional) - pass - - def sign(self, command): - ts = str(int(time.time())) - msg = f"{self.target_pid}|{command}|{ts}" - sig = hmac.new(self.secret, msg.encode(), hashlib.sha256).hexdigest() - return ts, sig - - def send(self, command): - ts, sig = self.sign(command) - result = subprocess.run([ - "messenger.exe", - str(self.target_pid), - command, - ts, - sig - ]) - return result.returncode - -# Usage -auth = MessengerAuth(pid=12345, target_name="claude.exe") -auth.start_pipe_server() # In background thread - -auth.send("hello world") # Text + Enter -auth.send("--tab") # Special key -auth.send("--escape") +The helper process is invisible (`CREATE_NO_WINDOW`) and exits automatically when the parent closes the pipe. -``` +**No separate messenger.exe binary is needed anymore.** The `Helper/messenger.cpp` file remains in the repository for reference but is no longer built or required. --- @@ -516,7 +479,18 @@ auth.send("--escape") - Closing console window exits the application - Child process exit automatically removes tray icon - Help message now displays when running with `-h` or `--help` from command line - + +#### 2.6.0 +**Integrated Key Injection for Tray Mode** +- Full special key support in tray mode: Enter, Tab, Escape, Backspace, Delete, arrows, Home, End, Page Up/Down, F1-F12 +- Ctrl and Alt modifier combos (Ctrl+C, Ctrl+D, Alt+Tab, etc.) routed through key injection +- Self-spawning helper subprocess replaces the separate messenger.exe binary +- Commands automatically wrapped in `cmd /c` in tray mode for reliable console attachment +- Disabled Quick Edit mode to prevent text selection from blocking input delivery +- Printable characters sent directly through PTY pipe (no double echo) +- Added `get_child_pid()` to both ConPTY and HeadlessTTY classes +- Reverted from c++23 to c++17 for the main binary (messenger.exe build commented out) + --- diff --git a/build.bat b/build.bat index 0b55806..844c16b 100644 --- a/build.bat +++ b/build.bat @@ -11,8 +11,4 @@ if %ERRORLEVEL% NEQ 0 ( echo Building executable... clang++ -O3 -Wall -Wextra -std=c++17 -fno-exceptions -I include -o headless-tty.exe src/pty.cpp src/main.cpp resources/app.res -static -luser32 -lshell32 -Wl,/SUBSYSTEM:WINDOWS -Wl,/ENTRY:mainCRTStartup -if %ERRORLEVEL%==0 echo Build successful - -echo Building helper... -g++ -std=c++23 -o messenger.exe Helper/messenger.cpp -static -s -mwindows -lbcrypt if %ERRORLEVEL%==0 echo Build successful \ No newline at end of file diff --git a/include/headless_tty/pty.hpp b/include/headless_tty/pty.hpp index 054b393..11b52a0 100644 --- a/include/headless_tty/pty.hpp +++ b/include/headless_tty/pty.hpp @@ -52,13 +52,14 @@ class ConPTY { void start_reading(); void stop(); bool is_running() const; + DWORD get_child_pid() const; /* Wait for the process to exit @param timeout_ms Timeout in milliseconds (INFINITE for no timeout) @return Exit code of the process, or -1 on error */ int wait(DWORD timeout_ms = INFINITE); - bool resize(const TerminalSize& size); + bool resize(const TerminalSize& size); /* Not used in the headless-tty since its meant to be headless. @@ -122,6 +123,7 @@ class HeadlessTTY { void set_output_callback(OutputCallback callback); void stop(); bool is_running() const; + DWORD get_child_pid() const; int wait(DWORD timeout_ms = INFINITE); std::string get_last_error() const; diff --git a/src/main.cpp b/src/main.cpp index f8e210d..35297a4 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -16,6 +16,7 @@ Usage: headless-tty [options] [command] [args...] #include #include #include +#include #include #include #include @@ -34,6 +35,7 @@ static NOTIFYICONDATAW g_nid = {}; static std::atomic g_console_visible{ false }; static HANDLE g_hConsoleOut = INVALID_HANDLE_VALUE; static HANDLE g_hConsoleIn = INVALID_HANDLE_VALUE; +static HANDLE g_hHelperPipe = INVALID_HANDLE_VALUE; void signal_handler(int signum) { (void)signum; @@ -222,8 +224,12 @@ void show_console() { } if (g_hConsoleIn != INVALID_HANDLE_VALUE) { - // Enable line input with echo - SetConsoleMode(g_hConsoleIn, ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT | ENABLE_PROCESSED_INPUT); + // Raw input mode, no Quick Edit (prevents selection from blocking input) + DWORD inMode = 0; + GetConsoleMode(g_hConsoleIn, &inMode); + inMode &= ~ENABLE_QUICK_EDIT_MODE; + inMode |= ENABLE_EXTENDED_FLAGS; + SetConsoleMode(g_hConsoleIn, inMode); } SetConsoleTitleW(L"headless-tty"); @@ -344,17 +350,109 @@ void remove_tray() { } } -// Console input forwarder for tray mode using raw input events -void tray_console_input_forwarder(headless_tty::HeadlessTTY& tty) { - std::string lineBuffer; +// Helper subprocess entry point - permanently attached to child's console +static int run_inject_helper(DWORD child_pid, HANDLE hPipe) { + if (!AttachConsole(child_pid)) { + return 2; + } + HANDLE hChildIn = GetStdHandle(STD_INPUT_HANDLE); + if (hChildIn == INVALID_HANDLE_VALUE) { + FreeConsole(); + return 2; + } + + INPUT_RECORD records[2]; + while (true) { + DWORD bytesRead = 0; + if (!ReadFile(hPipe, records, sizeof(records), &bytesRead, NULL) || bytesRead == 0) { + break; + } + + DWORD count = bytesRead / sizeof(INPUT_RECORD); + if (count > 0) { + DWORD written = 0; + WriteConsoleInputA(hChildIn, records, count, &written); + } + } + + FreeConsole(); + return 0; +} + +// Spawn self as helper subprocess with anonymous pipe +static bool start_inject_helper(DWORD child_pid) { + SECURITY_ATTRIBUTES sa = {}; + sa.nLength = sizeof(sa); + sa.bInheritHandle = TRUE; + + HANDLE hRead = NULL, hWrite = NULL; + if (!CreatePipe(&hRead, &hWrite, &sa, 0)) { + return false; + } + + // Write end stays in parent, must not be inherited + SetHandleInformation(hWrite, HANDLE_FLAG_INHERIT, 0); + + wchar_t exePath[MAX_PATH]; + GetModuleFileNameW(NULL, exePath, MAX_PATH); + + wchar_t cmdLine[512]; + swprintf_s(cmdLine, L"\"%s\" --inject-helper %lu %llu", + exePath, child_pid, (unsigned long long)(uintptr_t)hRead); + + STARTUPINFOW si = {}; + si.cb = sizeof(si); + PROCESS_INFORMATION pi = {}; + + BOOL ok = CreateProcessW( + NULL, cmdLine, NULL, NULL, + TRUE, + CREATE_NO_WINDOW, + NULL, NULL, &si, &pi); + + // Close read end in parent regardless + CloseHandle(hRead); + + if (!ok) { + CloseHandle(hWrite); + return false; + } + + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + + g_hHelperPipe = hWrite; + return true; +} + +static bool is_special_key(WORD vk) { + if (vk == VK_RETURN || vk == VK_TAB || vk == VK_ESCAPE || vk == VK_BACK) + return true; + if (vk == VK_DELETE || vk == VK_INSERT || vk == VK_HOME || vk == VK_END) + return true; + if (vk == VK_PRIOR || vk == VK_NEXT) + return true; + if (vk >= VK_LEFT && vk <= VK_DOWN) + return true; + if (vk >= VK_F1 && vk <= VK_F12) + return true; + if (vk == VK_LSHIFT || vk == VK_RSHIFT || vk == VK_LCONTROL || + vk == VK_RCONTROL || vk == VK_LMENU || vk == VK_RMENU) + return true; + return false; +} + +// Console input forwarder for tray mode +// Special keys -> pipe to helper subprocess -> WriteConsoleInput to child +// Printable chars -> PTY pipe (works for INK apps) +void tray_console_input_forwarder(headless_tty::HeadlessTTY& tty) { while (true) { if (g_shutdown_requested.load() || !tty.is_running()) { break; } HANDLE hIn = g_hConsoleIn; - HANDLE hOut = g_hConsoleOut; if (!g_console_visible.load() || hIn == INVALID_HANDLE_VALUE) { Sleep(50); continue; @@ -370,47 +468,43 @@ void tray_console_input_forwarder(headless_tty::HeadlessTTY& tty) { continue; } - // Read input events INPUT_RECORD record; DWORD eventsRead = 0; if (!ReadConsoleInputA(hIn, &record, 1, &eventsRead) || eventsRead == 0) { continue; } - // Only process key down events if (record.EventType != KEY_EVENT || !record.Event.KeyEvent.bKeyDown) { continue; } char ch = record.Event.KeyEvent.uChar.AsciiChar; WORD vk = record.Event.KeyEvent.wVirtualKeyCode; - - if (vk == VK_RETURN) { - // Echo newline and send line to PTY - if (hOut != INVALID_HANDLE_VALUE) { - DWORD written; - WriteConsoleA(hOut, "\r\n", 2, &written, NULL); - } - lineBuffer += "\r\n"; - if (tty.is_running()) { - tty.write(reinterpret_cast(lineBuffer.c_str()), lineBuffer.size()); - } - lineBuffer.clear(); - } else if (vk == VK_BACK) { - // Handle backspace - if (!lineBuffer.empty()) { - lineBuffer.pop_back(); - if (hOut != INVALID_HANDLE_VALUE) { - DWORD written; - WriteConsoleA(hOut, "\b \b", 3, &written, NULL); - } - } + DWORD ctrlState = record.Event.KeyEvent.dwControlKeyState; + bool hasModifier = (ctrlState & (LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED | + LEFT_ALT_PRESSED | RIGHT_ALT_PRESSED)) != 0; + + if ((is_special_key(vk) || hasModifier) && g_hHelperPipe != INVALID_HANDLE_VALUE) { + // Route through helper subprocess -> WriteConsoleInput to child + INPUT_RECORD records[2] = {}; + + records[0].EventType = KEY_EVENT; + records[0].Event.KeyEvent.bKeyDown = TRUE; + records[0].Event.KeyEvent.wRepeatCount = 1; + records[0].Event.KeyEvent.wVirtualKeyCode = record.Event.KeyEvent.wVirtualKeyCode; + records[0].Event.KeyEvent.wVirtualScanCode = record.Event.KeyEvent.wVirtualScanCode; + records[0].Event.KeyEvent.uChar.AsciiChar = record.Event.KeyEvent.uChar.AsciiChar; + records[0].Event.KeyEvent.dwControlKeyState = record.Event.KeyEvent.dwControlKeyState; + + records[1] = records[0]; + records[1].Event.KeyEvent.bKeyDown = FALSE; + + DWORD written = 0; + WriteFile(g_hHelperPipe, records, sizeof(records), &written, NULL); } else if (ch >= 32 && ch < 127) { - // Printable ASCII - echo and buffer - lineBuffer += ch; - if (hOut != INVALID_HANDLE_VALUE) { - DWORD written; - WriteConsoleA(hOut, &ch, 1, &written, NULL); + // Printable chars go through PTY pipe; child echoes back via output callback + if (tty.is_running()) { + tty.write(reinterpret_cast(&ch), 1); } } } @@ -430,14 +524,35 @@ int run_tray_mode(const Args& args) { headless_tty::Config config; config.size.cols = args.width; config.size.rows = args.height; - config.command = args.command; - config.args = args.args; + + // Wrap in cmd /c so the helper subprocess can AttachConsole to cmd.exe + // (AttachConsole to ConPTY-hosted non-shell processes is unreliable) + std::wstring cmdLower = args.command; + for (auto& c : cmdLower) c = towlower(c); + bool already_cmd = (cmdLower == L"cmd" || cmdLower == L"cmd.exe" || + cmdLower == L"cmd /c" || cmdLower.find(L"cmd /c ") == 0 || + cmdLower.find(L"cmd.exe /c ") == 0); + + if (already_cmd) { + config.command = args.command; + config.args = args.args; + } else { + config.command = L"cmd.exe"; + std::wstring inner = args.command; + if (!args.args.empty()) { + inner += L" " + args.args; + } + config.args = L"/c " + inner; + } if (!tty.start(config)) { remove_tray(); return 1; } + // Start helper subprocess for key injection + start_inject_helper(tty.get_child_pid()); + // Set output callback AFTER start() - m_pty must exist first tty.set_output_callback([](const uint8_t* data, size_t length) { if (g_console_visible.load() && g_hConsoleOut != INVALID_HANDLE_VALUE) { @@ -475,6 +590,12 @@ int run_tray_mode(const Args& args) { // Cleanup g_shutdown_requested.store(true); + + if (g_hHelperPipe != INVALID_HANDLE_VALUE) { + CloseHandle(g_hHelperPipe); + g_hHelperPipe = INVALID_HANDLE_VALUE; + } + tty.stop(); // Input thread will exit on next loop iteration (100ms max) @@ -494,6 +615,13 @@ int run_tray_mode(const Args& args) { int main(int argc, char* argv[]) { + // Internal helper mode - spawned by parent for key injection + if (argc >= 4 && strcmp(argv[1], "--inject-helper") == 0) { + DWORD pid = (DWORD)strtoul(argv[2], NULL, 10); + HANDLE pipe = (HANDLE)(uintptr_t)_strtoui64(argv[3], NULL, 10); + return run_inject_helper(pid, pipe); + } + Args args = parse_args(argc, argv); // Attach to parent console only for help/error output diff --git a/src/pty.cpp b/src/pty.cpp index 7bb4109..7ec7808 100644 --- a/src/pty.cpp +++ b/src/pty.cpp @@ -377,6 +377,10 @@ bool ConPTY::is_running() const { return m_running.load(); } +DWORD ConPTY::get_child_pid() const { + return m_processInfo.dwProcessId; +} + int ConPTY::wait(DWORD timeout_ms) { if (!m_hProcess) { return -1; @@ -508,6 +512,11 @@ bool HeadlessTTY::is_running() const { return m_pty && m_pty->is_running(); } +DWORD HeadlessTTY::get_child_pid() const { + if (!m_pty) return 0; + return m_pty->get_child_pid(); +} + int HeadlessTTY::wait(DWORD timeout_ms) { if (!m_pty) return -1; return m_pty->wait(timeout_ms); diff --git a/usage_example.py b/usage_example.py index a20d33d..145b325 100644 --- a/usage_example.py +++ b/usage_example.py @@ -95,15 +95,14 @@ def collect_descendants(pid): headless_tty_exe = "headless-tty.exe" # Use path r"path to headless-tty.exe" such as r"C:\my folder\headless-tty.exe", or if running from the same folder use "headless-tty.exe -cmd_args = r"ipconfig -all >%temp%\ipconfig.txt && notepad %temp%\ipconfig.txt" #writing ipconfig -all data to a txt file in temp and opening that in notepad +# cmd_args = r"ipconfig -all >%temp%\ipconfig.txt && notepad %temp%\ipconfig.txt" #writing ipconfig -all data to a txt file in temp and opening that in notepad -# cmd_args = r"ping localhost -t" +cmd_args = r"claude" cmd = [ str(headless_tty_exe), "--sys-tray", "--", - "notepad", str(cmd_args) ]