From e736924914b32a53bf0fe00e3760c8d3d64ebb86 Mon Sep 17 00:00:00 2001 From: RIKIBBOT Date: Fri, 6 Feb 2026 18:20:04 +0000 Subject: [PATCH 1/4] Add Linux support via X11/XTest platform backend Extract macOS-specific code from bot.c into platform_macos.c and add platform_linux.c using X11, XTest, and libpng. - platform.h: common interface for window listing, screenshots, keystroke injection, and window focus - platform_linux.c: uses _NET_CLIENT_LIST for window enumeration, XGetImage for screenshots, XTestFakeKeyEvent for keystrokes, _NET_ACTIVE_WINDOW for window raising - Makefile: auto-detects OS, links correct libraries - bot.c: now platform-independent, calls plat_* functions - README: Linux setup instructions and headless/VM usage guide --- AGENT.md | 9 +- Makefile | 37 +++- README.md | 50 +++++- bot.c | 437 ++++++----------------------------------------- platform.h | 65 +++++++ platform_linux.c | 389 +++++++++++++++++++++++++++++++++++++++++ platform_macos.c | 300 ++++++++++++++++++++++++++++++++ 7 files changed, 884 insertions(+), 403 deletions(-) create mode 100644 platform.h create mode 100644 platform_linux.c create mode 100644 platform_macos.c diff --git a/AGENT.md b/AGENT.md index fa06d57..4aef5c9 100644 --- a/AGENT.md +++ b/AGENT.md @@ -1,12 +1,15 @@ -A macOS tool to capture screenshots of terminal application windows (Terminal, iTerm2, Ghostty, kitty, etc.) and inject keystrokes into them. Uses Core Graphics APIs. +A cross-platform tool (macOS + Linux) to capture screenshots of terminal application windows and inject keystrokes into them via a Telegram bot. Uses Core Graphics on macOS, X11/XTest on Linux. # File Structure Put the file structure of the project below. Update if needed. ``` -bot.c - Telegram bot main source -Makefile - Build system +bot.c - Telegram bot main source (platform-independent) +platform.h - Platform abstraction interface +platform_macos.c - macOS backend (Core Graphics + Accessibility) +platform_linux.c - Linux backend (X11 + XTest + libpng) +Makefile - Build system (auto-detects OS) botlib.*, sds.*, cJSON.*, sqlite_wrap.*, json_wrap.* - From botlib qrcodegen.c, qrcodegen.h - QR code generation (Nayuki, MIT license) sha1.c, sha1.h - SHA-1 + HMAC-SHA1 (Steve Reid, public domain) diff --git a/Makefile b/Makefile index 7f4ccd7..c657bcc 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,38 @@ -CC = clang -CFLAGS = -Wall -O2 -mmacosx-version-min=14.0 -FRAMEWORKS = -framework CoreGraphics -framework CoreFoundation -framework ImageIO \ - -framework CoreServices -framework ApplicationServices -LIBS = -lcurl -lsqlite3 - -OBJS = bot.o botlib.o sds.o cJSON.o sqlite_wrap.o json_wrap.o qrcodegen.o sha1.o +UNAME_S := $(shell uname -s) + +ifeq ($(UNAME_S),Darwin) + CC = clang + CFLAGS = -Wall -O2 -mmacosx-version-min=14.0 + PLATFORM_LIBS = -framework CoreGraphics -framework CoreFoundation \ + -framework ImageIO -framework CoreServices \ + -framework ApplicationServices + PLATFORM_OBJ = platform_macos.o +else ifeq ($(UNAME_S),Linux) + CC ?= gcc + CFLAGS = -Wall -O2 + PLATFORM_LIBS = -lX11 -lXtst -lpng + PLATFORM_OBJ = platform_linux.o +endif + +LIBS = -lcurl -lsqlite3 $(PLATFORM_LIBS) + +OBJS = bot.o $(PLATFORM_OBJ) botlib.o sds.o cJSON.o sqlite_wrap.o \ + json_wrap.o qrcodegen.o sha1.o all: tgterm tgterm: $(OBJS) - $(CC) $(CFLAGS) -o $@ $(OBJS) $(FRAMEWORKS) $(LIBS) + $(CC) $(CFLAGS) -o $@ $(OBJS) $(LIBS) -bot.o: bot.c botlib.h sds.h +bot.o: bot.c botlib.h sds.h platform.h $(CC) $(CFLAGS) -c bot.c +platform_macos.o: platform_macos.c platform.h + $(CC) $(CFLAGS) -c platform_macos.c + +platform_linux.o: platform_linux.c platform.h + $(CC) $(CFLAGS) -c platform_linux.c + botlib.o: botlib.c botlib.h sds.h cJSON.h sqlite_wrap.h $(CC) $(CFLAGS) -c botlib.c diff --git a/README.md b/README.md index c43501e..cad7d5a 100644 --- a/README.md +++ b/README.md @@ -21,21 +21,25 @@ This is how it works: 2. After you setup your TOTP, you send the bot the first message, and you become its owner. It will only accept queries from you (your Telegram ID) and will require you to authenticat with an OTP for the first time, and again after a timeout. 3. At this point, you can ask for the list of terminal windows in your system with `.list`, connect to one of them with (for instance) `.2`, then you can send any text that will be "typed" in the window, like if you are still at your computer. You have modifiers, ways to send `ESC`, and so forth, so you can do many things, like changing the visible tab. -Important: **this program only works on macOS for now.** +**Supported platforms:** macOS and Linux (X11). ## First run -Please note in advance that the **program requires two system permissions** to function: +### macOS permissions + +The program requires two system permissions on macOS: - **Screen Recording** — needed to capture terminal window screenshots. - **Accessibility** — needed to inject keystrokes and raise windows. -MacOS will prompt you to grant these on first use. If screenshots or keystrokes silently fail, check System Settings → Privacy & Security. +macOS will prompt you to grant these on first use. If screenshots or keystrokes silently fail, check System Settings → Privacy & Security. -To setup the project: +### Setup 1. Create a Telegram bot via [@BotFather](https://t.me/botfather) and get the API key. -2. Install `libcurl` and `libsqlite3`. The project also uses my own `botlib` but it is included directly into the project, so no need to install anything. +2. Install dependencies: + - **macOS:** `libcurl` and `libsqlite3` (usually pre-installed). + - **Linux:** `sudo apt install libx11-dev libxtst-dev libpng-dev libcurl4-openssl-dev libsqlite3-dev` 3. Build with `make` and run: ``` @@ -109,9 +113,45 @@ This tool allows remote control of terminal windows via Telegram. Given the sens **Disabling TOTP.** If you don't want OTP authentication (not recommended), run with `--use-weak-security`. The bot will still enforce ownership but will not require OTP codes. +## Headless / VM usage + +tgterm can run on headless servers and VMs (no physical monitor) using Xvfb, a virtual X framebuffer. This is useful for controlling coding agents remotely from your phone. + +### Quick setup + +Install the required packages: + +``` +sudo apt install xvfb openbox xterm +``` + +Start the virtual display, a window manager, and a terminal: + +``` +Xvfb :99 -screen 0 1920x1080x24 & +DISPLAY=:99 openbox & +DISPLAY=:99 xterm -fa Monospace -fs 14 -geometry 120x40 & +``` + +Then run tgterm pointed at the virtual display: + +``` +DISPLAY=:99 ./tgterm --apikey +``` + +You can launch as many xterm sessions as you need and switch between them with `.list` and `.1`, `.2`, etc. from Telegram. + +### What you need + +- **Xvfb** — virtual X server (renders to memory, no GPU needed). +- **A window manager** — openbox is lightweight and sufficient. Required so that tgterm can enumerate windows via `_NET_CLIENT_LIST`. +- **A terminal emulator** — xterm works everywhere. Any X11 terminal will do. + ## Limitations - **Deprecated macOS APIs.** The project uses older Core Graphics and Process Manager APIs for screenshot capture and window management. These produce compiler warnings on macOS 14+ but still work correctly, and provide good compatibility with older macOS versions. They will be replaced if and when Apple removes them. +- **Linux: X11 only.** The Linux backend requires an X11 display server. Wayland is not supported (XWayland may work). +- **Linux: screenshots require visible windows.** On Linux, the screenshot is captured from the root window at the window's position, so the window must be visible (not occluded). The bot raises the window before capturing, which handles this in most cases. - **UTF-8 keystrokes.** Non-ASCII text (beyond the special emoji modifiers) is not handled correctly when sending keystrokes. Only ASCII characters are reliably injected. ## Credits diff --git a/bot.c b/bot.c index 00d63e6..b45264f 100644 --- a/bot.c +++ b/bot.c @@ -1,8 +1,7 @@ /* - * bot.c - Telegram bot to control terminal windows on macOS + * bot.c - Telegram bot to control terminal windows remotely. * - * Allows capturing screenshots and sending keystrokes to terminal applications - * (Terminal, iTerm2, Ghostty, kitty, etc.) via Telegram messages. + * Works on macOS (Core Graphics) and Linux (X11 + XTest). * * Commands: * .list - List available terminal windows @@ -11,7 +10,7 @@ * * Once connected, any text is sent as keystrokes (newline auto-added). * End with 💜 to suppress the automatic newline. - * Emoji modifiers: ❤️ (Ctrl), 💙 (Alt), 💚 (Cmd), 💛 (ESC) + * Emoji modifiers: ❤️ (Ctrl), 💙 (Alt), 💚 (Cmd/Super), 💛 (ESC) */ #include @@ -22,42 +21,15 @@ #include #include -#include -#include -#include -#include - +#include "platform.h" #include "botlib.h" #include "sha1.h" #include "qrcodegen.h" /* ============================================================================ - * Terminal Window Management + * Global State * ========================================================================= */ -#define kVK_Return 0x24 -#define kVK_Tab 0x30 -#define kVK_Escape 0x35 - -#define MOD_CTRL (1<<0) -#define MOD_ALT (1<<1) -#define MOD_CMD (1<<2) - -/* Known terminal application names. */ -static const char *TerminalApps[] = { - "Terminal", "iTerm2", "iTerm", "Ghostty", "kitty", "Alacritty", - "Hyper", "Warp", "WezTerm", "Tabby", NULL -}; - -/* Window information. */ -typedef struct { - CGWindowID window_id; - pid_t pid; - char owner[128]; - char title[256]; -} WinInfo; - -/* Global state. */ static pthread_mutex_t RequestLock = PTHREAD_MUTEX_INITIALIZER; static int DangerMode = 0; /* If 1, show all windows, not just terminals. */ static WinInfo *WindowList = NULL; /* Cached window list for .list display. */ @@ -69,12 +41,12 @@ static int Authenticated = 0; /* Whether OTP has been verified. */ static time_t LastActivity = 0; /* Last time owner sent a valid command. */ static int OtpTimeout = 300; /* Timeout in seconds (default 5 min). */ -/* Connected window - stored directly, not as index. */ -static int Connected = 0; /* 1 if connected, 0 otherwise. */ -static CGWindowID ConnectedWid = 0; /* Window ID of connected window. */ -static pid_t ConnectedPid = 0; /* PID of connected window. */ -static char ConnectedOwner[128]; /* Owner name for display. */ -static char ConnectedTitle[256]; /* Title for display. */ +/* Connected window. */ +static int Connected = 0; +static PlatWinID ConnectedWid = 0; +static pid_t ConnectedPid = 0; +static char ConnectedOwner[128]; +static char ConnectedTitle[256]; /* ============================================================================ * TOTP Authentication @@ -124,8 +96,7 @@ static uint32_t totp_code(const unsigned char *secret, size_t secret_len, return code % 1000000; } -/* Print QR code as compact ASCII art using half-block characters. - * Each output line encodes two QR rows using ▀ ▄ █ and space. */ +/* Print QR code as compact ASCII art using half-block characters. */ static void print_qr_ascii(const char *text) { uint8_t qrcode[qrcodegen_BUFFER_LEN_MAX]; uint8_t tempbuf[qrcodegen_BUFFER_LEN_MAX]; @@ -138,7 +109,7 @@ static void print_qr_ascii(const char *text) { } int size = qrcodegen_getSize(qrcode); - int lo = -1, hi = size + 1; /* 1-module quiet zone. */ + int lo = -1, hi = size + 1; for (int y = lo; y < hi; y += 2) { for (int x = lo; x < hi; x++) { @@ -146,9 +117,9 @@ static void print_qr_ascii(const char *text) { qrcodegen_getModule(qrcode, x, y)); int bot = (x >= 0 && x < size && y+1 >= 0 && y+1 < size && qrcodegen_getModule(qrcode, x, y+1)); - if (top && bot) printf("\xe2\x96\x88"); /* █ */ - else if (top && !bot) printf("\xe2\x96\x80"); /* ▀ */ - else if (!top && bot) printf("\xe2\x96\x84"); /* ▄ */ + if (top && bot) printf("\xe2\x96\x88"); + else if (top && !bot) printf("\xe2\x96\x80"); + else if (!top && bot) printf("\xe2\x96\x84"); else printf(" "); } printf("\n"); @@ -177,9 +148,7 @@ static const char *bytes_to_hex(const unsigned char *data, int len) { return hex; } -/* Setup TOTP: check for existing secret, generate if needed, display QR. - * The db_path is the SQLite database file path. - * Returns the secret length in bytes, or 0 on error/weak-security. */ +/* Setup TOTP: check for existing secret, generate if needed, display QR. */ static int totp_setup(const char *db_path) { if (WeakSecurity) return 0; @@ -188,14 +157,11 @@ static int totp_setup(const char *db_path) { fprintf(stderr, "Cannot open database for TOTP setup.\n"); return 0; } - /* Ensure KV table exists. */ sqlite3_exec(db, TB_CREATE_KV_STORE, 0, 0, NULL); - /* Check for existing secret. */ sds existing = kvGet(db, "totp_secret"); if (existing) { sdsfree(existing); - /* Load stored timeout if present. */ sds timeout_str = kvGet(db, "otp_timeout"); if (timeout_str) { int t = atoi(timeout_str); @@ -203,10 +169,9 @@ static int totp_setup(const char *db_path) { sdsfree(timeout_str); } sqlite3_close(db); - return 1; /* Secret already exists. */ + return 1; } - /* Generate 20 random bytes. */ unsigned char secret[20]; FILE *f = fopen("/dev/urandom", "r"); if (!f || fread(secret, 1, 20, f) != 20) { @@ -216,11 +181,9 @@ static int totp_setup(const char *db_path) { } fclose(f); - /* Store as hex in KV. */ kvSet(db, "totp_secret", bytes_to_hex(secret, 20), 0); sqlite3_close(db); - /* Build otpauth URI and display QR code. */ const char *b32 = base32_encode(secret, 20); char uri[256]; snprintf(uri, sizeof(uri), @@ -273,9 +236,9 @@ int match_red_heart(const unsigned char *p, size_t remaining) { /* Match colored hearts 💙💚💛 (F0 9F 92 99/9A/9B). */ int match_colored_heart(const unsigned char *p, size_t remaining, char *heart) { if (remaining >= 4 && p[0] == 0xF0 && p[1] == 0x9F && p[2] == 0x92) { - if (p[3] == 0x99) { *heart = 'B'; return 4; } /* 💙 Blue = Alt */ - if (p[3] == 0x9A) { *heart = 'G'; return 4; } /* 💚 Green = Cmd */ - if (p[3] == 0x9B) { *heart = 'Y'; return 4; } /* 💛 Yellow = ESC */ + if (p[3] == 0x99) { *heart = 'B'; return 4; } + if (p[3] == 0x9A) { *heart = 'G'; return 4; } + if (p[3] == 0x9B) { *heart = 'Y'; return 4; } } return 0; } @@ -305,18 +268,9 @@ int ends_with_purple_heart(const char *text) { } /* ============================================================================ - * Window Functions + * Window Management (using platform interface) * ========================================================================= */ -/* Check if app name is a known terminal. */ -int is_terminal_app(const char *name) { - for (int i = 0; TerminalApps[i]; i++) { - if (strcasestr(name, TerminalApps[i])) return 1; - } - return 0; -} - -/* Free the cached window list. */ void free_window_list(void) { if (WindowList) { free(WindowList); @@ -325,134 +279,17 @@ void free_window_list(void) { WindowCount = 0; } -/* Refresh the window list. Returns number of windows found. */ int refresh_window_list(void) { free_window_list(); - - CFArrayRef list = CGWindowListCopyWindowInfo( - kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements, - kCGNullWindowID - ); - if (!list) return 0; - - CFIndex count = CFArrayGetCount(list); - - /* Allocate maximum possible size. */ - WindowList = malloc(count * sizeof(WinInfo)); - if (!WindowList) { - CFRelease(list); - return 0; - } - - for (CFIndex i = 0; i < count; i++) { - CFDictionaryRef info = CFArrayGetValueAtIndex(list, i); - - /* Get owner name. */ - CFStringRef owner_ref = CFDictionaryGetValue(info, kCGWindowOwnerName); - if (!owner_ref) continue; - - char owner[128]; - if (!CFStringGetCString(owner_ref, owner, sizeof(owner), kCFStringEncodingUTF8)) - continue; - - /* Filter to terminals only unless in danger mode. */ - if (!DangerMode && !is_terminal_app(owner)) continue; - - /* Get window ID and PID. */ - CFNumberRef wid_ref = CFDictionaryGetValue(info, kCGWindowNumber); - CFNumberRef pid_ref = CFDictionaryGetValue(info, kCGWindowOwnerPID); - if (!wid_ref || !pid_ref) continue; - - CGWindowID wid; - pid_t pid; - CFNumberGetValue(wid_ref, kCGWindowIDCFNumberType, &wid); - CFNumberGetValue(pid_ref, kCFNumberIntType, &pid); - - /* Only layer 0. */ - CFNumberRef layer_ref = CFDictionaryGetValue(info, kCGWindowLayer); - int layer = 0; - if (layer_ref) CFNumberGetValue(layer_ref, kCFNumberIntType, &layer); - if (layer != 0) continue; - - /* Must have reasonable size. */ - CFDictionaryRef bounds_dict = CFDictionaryGetValue(info, kCGWindowBounds); - if (!bounds_dict) continue; - - CGRect bounds; - CGRectMakeWithDictionaryRepresentation(bounds_dict, &bounds); - if (bounds.size.width <= 50 || bounds.size.height <= 50) continue; - - /* Get window title. */ - CFStringRef title_ref = CFDictionaryGetValue(info, kCGWindowName); - char title[256] = ""; - if (title_ref) - CFStringGetCString(title_ref, title, sizeof(title), kCFStringEncodingUTF8); - - /* Add to list. */ - WinInfo *w = &WindowList[WindowCount++]; - w->window_id = wid; - w->pid = pid; - strncpy(w->owner, owner, sizeof(w->owner) - 1); - w->owner[sizeof(w->owner) - 1] = '\0'; - strncpy(w->title, title, sizeof(w->title) - 1); - w->title[sizeof(w->title) - 1] = '\0'; - } - - CFRelease(list); + WindowCount = plat_list_windows(&WindowList, DangerMode); return WindowCount; } -/* Check if connected window still exists on screen. If the exact window ID - * is gone but the same PID still has an on-screen window (tab switch), - * update ConnectedWid to the new window. */ int connected_window_exists(void) { if (!Connected) return 0; - - CFArrayRef list = CGWindowListCopyWindowInfo( - kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements, - kCGNullWindowID - ); - if (!list) return 0; - - int found = 0; - CGWindowID fallback_wid = 0; - CFIndex count = CFArrayGetCount(list); - for (CFIndex i = 0; i < count; i++) { - CFDictionaryRef info = CFArrayGetValueAtIndex(list, i); - CFNumberRef wid_ref = CFDictionaryGetValue(info, kCGWindowNumber); - CFNumberRef pid_ref = CFDictionaryGetValue(info, kCGWindowOwnerPID); - if (!wid_ref || !pid_ref) continue; - - CGWindowID wid; - pid_t pid; - CFNumberGetValue(wid_ref, kCGWindowIDCFNumberType, &wid); - CFNumberGetValue(pid_ref, kCFNumberIntType, &pid); - - if (wid == ConnectedWid) { - found = 1; - break; - } - - /* Track a fallback: another on-screen window from the same PID. */ - if (pid == ConnectedPid && !fallback_wid) { - CFNumberRef layer_ref = CFDictionaryGetValue(info, kCGWindowLayer); - int layer = 0; - if (layer_ref) CFNumberGetValue(layer_ref, kCFNumberIntType, &layer); - if (layer == 0) fallback_wid = wid; - } - } - - /* Window gone but same app has another window — likely a tab switch. */ - if (!found && fallback_wid) { - ConnectedWid = fallback_wid; - found = 1; - } - - CFRelease(list); - return found; + return plat_window_exists(&ConnectedWid, ConnectedPid); } -/* Disconnect from current window. */ void disconnect(void) { Connected = 0; ConnectedWid = 0; @@ -461,188 +298,35 @@ void disconnect(void) { ConnectedTitle[0] = '\0'; } -/* ============================================================================ - * Screenshot Functions - * ========================================================================= */ - -int save_png(CGImageRef image, const char *path) { - CFStringRef cfpath = CFStringCreateWithCString(NULL, path, kCFStringEncodingUTF8); - CFURLRef url = CFURLCreateWithFileSystemPath(NULL, cfpath, kCFURLPOSIXPathStyle, false); - CFRelease(cfpath); - if (!url) return -1; - - CGImageDestinationRef dest = CGImageDestinationCreateWithURL(url, CFSTR("public.png"), 1, NULL); - CFRelease(url); - if (!dest) return -1; - - CGImageDestinationAddImage(dest, image, NULL); - int ok = CGImageDestinationFinalize(dest); - CFRelease(dest); - return ok ? 0 : -1; -} - -CGImageRef capture_window(CGWindowID wid) { - return CGWindowListCreateImage(CGRectNull, kCGWindowListOptionIncludingWindow, wid, - kCGWindowImageBoundsIgnoreFraming | kCGWindowImageNominalResolution); -} - -/* Capture and save screenshot of connected window. Returns 0 on success. */ int capture_connected_window(const char *path) { if (!Connected) return -1; - - CGImageRef img = capture_window(ConnectedWid); - if (!img) return -1; - - int ret = save_png(img, path); - CGImageRelease(img); - return ret; + return plat_capture_window(ConnectedWid, path); } /* ============================================================================ - * Keystroke Functions + * Keystroke Sending * ========================================================================= */ -/* Private API to get CGWindowID from AXUIElement. */ -extern AXError _AXUIElementGetWindow(AXUIElementRef element, CGWindowID *wid); - -/* Bring app to front. */ -int bring_to_front(pid_t pid) { - ProcessSerialNumber psn; - if (GetProcessForPID(pid, &psn) != noErr) return -1; - if (SetFrontProcessWithOptions(&psn, kSetFrontProcessFrontWindowOnly) != noErr) return -1; - usleep(100000); - return 0; -} - -/* Raise the specific window by matching CGWindowID via Accessibility API. */ -int raise_window_by_id(pid_t pid, CGWindowID target_wid) { - AXUIElementRef app = AXUIElementCreateApplication(pid); - if (!app) return -1; - - CFArrayRef windows = NULL; - AXUIElementCopyAttributeValue(app, kAXWindowsAttribute, (CFTypeRef *)&windows); - CFRelease(app); - - if (!windows) return -1; - - int found = 0; - CFIndex count = CFArrayGetCount(windows); - for (CFIndex i = 0; i < count; i++) { - AXUIElementRef win = (AXUIElementRef)CFArrayGetValueAtIndex(windows, i); - - CGWindowID wid = 0; - if (_AXUIElementGetWindow(win, &wid) == kAXErrorSuccess) { - if (wid == target_wid) { - AXUIElementPerformAction(win, kAXRaiseAction); - found = 1; - break; - } - } - } - - CFRelease(windows); - - /* Also bring the app to front. */ - bring_to_front(pid); - return found ? 0 : -1; -} - -/* Map ASCII character to macOS virtual keycode (US keyboard layout). */ -CGKeyCode keycode_for_char(char c) { - /* Letters a-z (same codes for upper/lowercase). */ - static const CGKeyCode letter_map[26] = { - 0x00,0x0B,0x08,0x02,0x0E,0x03,0x05,0x04,0x22,0x26, /* a-j */ - 0x28,0x25,0x2E,0x2D,0x1F,0x23,0x0C,0x0F,0x01,0x11, /* k-t */ - 0x20,0x09,0x0D,0x07,0x10,0x06 /* u-z */ - }; - /* Digits 0-9. */ - static const CGKeyCode digit_map[10] = { - 0x1D,0x12,0x13,0x14,0x15,0x17,0x16,0x1A,0x1C,0x19 /* 0-9 */ - }; - /* Punctuation / symbols. */ - if (c >= 'a' && c <= 'z') return letter_map[c - 'a']; - if (c >= 'A' && c <= 'Z') return letter_map[c - 'A']; - if (c >= '0' && c <= '9') return digit_map[c - '0']; - switch (c) { - case '-': return 0x1B; case '=': return 0x18; - case '[': return 0x21; case ']': return 0x1E; - case '\\': return 0x2A; case ';': return 0x29; - case '\'': return 0x27; case ',': return 0x2B; - case '.': return 0x2F; case '/': return 0x2C; - case '`': return 0x32; case ' ': return 0x31; - } - return 0xFFFF; /* Unknown. */ -} - -void send_key(pid_t pid, CGKeyCode keycode, UniChar ch, int mods) { - /* When modifiers are active and we have a character, use the - * correct virtual keycode so the system sends the right combo. */ - int mapped_keycode = 0; - if (ch && mods) { - CGKeyCode mapped = keycode_for_char((char)ch); - if (mapped != 0xFFFF) { - keycode = mapped; - mapped_keycode = 1; - } - } - - CGEventRef down = CGEventCreateKeyboardEvent(NULL, keycode, true); - CGEventRef up = CGEventCreateKeyboardEvent(NULL, keycode, false); - if (!down || !up) { - if (down) CFRelease(down); - if (up) CFRelease(up); - return; - } - - CGEventFlags flags = 0; - if (mods & MOD_CTRL) flags |= kCGEventFlagMaskControl; - if (mods & MOD_ALT) flags |= kCGEventFlagMaskAlternate; - if (mods & MOD_CMD) flags |= kCGEventFlagMaskCommand; - - if (flags) { - CGEventSetFlags(down, flags); - CGEventSetFlags(up, flags); - } - - /* When we have a mapped keycode with modifiers, let the system - * derive the character from keycode + flags. Otherwise set it. */ - if (ch && !mapped_keycode) { - CGEventKeyboardSetUnicodeString(down, 1, &ch); - CGEventKeyboardSetUnicodeString(up, 1, &ch); - } - - CGEventPostToPid(pid, down); - usleep(1000); - CGEventPostToPid(pid, up); - usleep(5000); - - CFRelease(down); - CFRelease(up); -} - /* Send keystrokes to connected window. Auto-adds newline unless ends with 💜. */ int send_keys(const char *text) { if (!Connected) return -1; - raise_window_by_id(ConnectedPid, ConnectedWid); + plat_raise_window(ConnectedPid, ConnectedWid); - /* Check if we should suppress trailing newline. */ int add_newline = !ends_with_purple_heart(text); const unsigned char *p = (const unsigned char *)text; size_t len = strlen(text); - /* If ends with purple heart, reduce length to skip it. */ - if (!add_newline && len >= 4) { + if (!add_newline && len >= 4) len -= 4; - } int mods = 0; int consumed; char heart; - int keycount = 0; /* Number of actual keystrokes sent. */ - int had_mods = 0; /* True if any keystroke used modifiers. */ - int last_was_nl = 0; /* True if last keystroke was Enter. */ + int keycount = 0; + int had_mods = 0; + int last_was_nl = 0; while (len > 0) { if ((consumed = match_red_heart(p, len)) > 0) { @@ -652,7 +336,7 @@ int send_keys(const char *text) { } if ((consumed = match_orange_heart(p, len)) > 0) { - send_key(ConnectedPid, kVK_Return, 0, mods); + plat_send_key(ConnectedPid, PLAT_KEY_RETURN, 0, mods); if (mods) had_mods = 1; keycount++; last_was_nl = 1; mods = 0; p += consumed; len -= consumed; @@ -661,7 +345,7 @@ int send_keys(const char *text) { if ((consumed = match_colored_heart(p, len, &heart)) > 0) { if (heart == 'Y') { - send_key(ConnectedPid, kVK_Escape, 0, 0); + plat_send_key(ConnectedPid, PLAT_KEY_ESCAPE, 0, 0); keycount++; had_mods = 1; last_was_nl = 0; mods = 0; } else if (heart == 'B') { @@ -676,37 +360,33 @@ int send_keys(const char *text) { last_was_nl = 0; if (*p == '\\' && len > 1) { if (p[1] == 'n') { - send_key(ConnectedPid, kVK_Return, 0, mods); + plat_send_key(ConnectedPid, PLAT_KEY_RETURN, 0, mods); if (mods) had_mods = 1; keycount++; last_was_nl = 1; mods = 0; p += 2; len -= 2; continue; } else if (p[1] == 't') { - send_key(ConnectedPid, kVK_Tab, 0, mods); + plat_send_key(ConnectedPid, PLAT_KEY_TAB, 0, mods); if (mods) had_mods = 1; keycount++; mods = 0; p += 2; len -= 2; continue; } else if (p[1] == '\\') { - send_key(ConnectedPid, 0, '\\', mods); + plat_send_key(ConnectedPid, PLAT_KEY_CHAR, '\\', mods); if (mods) had_mods = 1; keycount++; mods = 0; p += 2; len -= 2; continue; } } - send_key(ConnectedPid, 0, (UniChar)*p, mods); + plat_send_key(ConnectedPid, PLAT_KEY_CHAR, (int)*p, mods); if (mods) had_mods = 1; keycount++; mods = 0; p++; len--; } - /* Add newline unless: - * - Suppressed by purple heart - * - Single modified keystroke (like Ctrl+C) or bare ESC - * - Last explicit keystroke was already a newline */ if (add_newline && !(keycount == 1 && had_mods) && !last_was_nl) { usleep(50000); - send_key(ConnectedPid, kVK_Return, 0, 0); + plat_send_key(ConnectedPid, PLAT_KEY_RETURN, 0, 0); } return 0; @@ -716,7 +396,6 @@ int send_keys(const char *text) { * Bot Command Handlers * ========================================================================= */ -/* Build the .list response. */ sds build_list_message(void) { refresh_window_list(); @@ -731,9 +410,11 @@ sds build_list_message(void) { WinInfo *w = &WindowList[i]; char line[512]; if (w->title[0]) { - snprintf(line, sizeof(line), ".%d [%u] %s - %s\n", i + 1, w->window_id, w->owner, w->title); + snprintf(line, sizeof(line), ".%d [%lu] %s - %s\n", + i + 1, w->window_id, w->owner, w->title); } else { - snprintf(line, sizeof(line), ".%d [%u] %s\n", i + 1, w->window_id, w->owner); + snprintf(line, sizeof(line), ".%d [%lu] %s\n", + i + 1, w->window_id, w->owner); } msg = sdscat(msg, line); } @@ -747,9 +428,13 @@ sds build_help_message(void) { ".1 .2 ... - Connect to window\n" ".help - This help\n\n" "Once connected, text is sent as keystrokes.\n" - "Newline is auto-added; end with `💜` to suppress it.\n\n" + "Newline is auto-added; end with `\xf0\x9f\x92\x9c` to suppress it.\n\n" "Modifiers (tap to copy, then paste + key):\n" - "`❤️` Ctrl `💙` Alt `💚` Cmd `💛` ESC `🧡` Enter\n\n" + "`\xe2\x9d\xa4\xef\xb8\x8f` Ctrl " + "`\xf0\x9f\x92\x99` Alt " + "`\xf0\x9f\x92\x9a` Cmd/Super " + "`\xf0\x9f\x92\x9b` ESC " + "`\xf0\x9f\xa7\xa1` Enter\n\n" "Escape sequences: \\n=Enter \\t=Tab\n\n" "`.otptimeout ` - Set OTP timeout (30-28800)" ); @@ -761,16 +446,14 @@ sds build_help_message(void) { #define SCREENSHOT_PATH "/tmp/tgterm_screenshot.png" #define OWNER_KEY "owner_id" -#define REFRESH_BTN "🔄 Refresh" +#define REFRESH_BTN "\xf0\x9f\x94\x84 Refresh" #define REFRESH_DATA "refresh" -/* Send screenshot with refresh button. */ void send_screenshot(int64_t chat_id) { if (capture_connected_window(SCREENSHOT_PATH) != 0) return; botSendImageWithKeyboard(chat_id, SCREENSHOT_PATH, REFRESH_BTN, REFRESH_DATA, NULL); } -/* Refresh an existing screenshot message by editing its media. */ void refresh_screenshot(int64_t chat_id, int64_t msg_id) { if (capture_connected_window(SCREENSHOT_PATH) != 0) return; botEditMessageMedia(chat_id, msg_id, SCREENSHOT_PATH, REFRESH_BTN, REFRESH_DATA); @@ -779,7 +462,6 @@ void refresh_screenshot(int64_t chat_id, int64_t msg_id) { void handle_request(sqlite3 *db, BotRequest *br) { pthread_mutex_lock(&RequestLock); - /* Check owner. First user to message becomes owner. */ sds owner_str = kvGet(db, OWNER_KEY); int64_t owner_id = 0; @@ -789,7 +471,6 @@ void handle_request(sqlite3 *db, BotRequest *br) { } if (owner_id == 0) { - /* Register first user as owner. */ char buf[32]; snprintf(buf, sizeof(buf), "%lld", (long long)br->from); kvSet(db, OWNER_KEY, buf, 0); @@ -802,7 +483,6 @@ void handle_request(sqlite3 *db, BotRequest *br) { goto done; } - /* TOTP authentication check (applies to both messages and callbacks). */ if (!WeakSecurity) { if (!Authenticated || time(NULL) - LastActivity > OtpTimeout) { Authenticated = 0; @@ -811,7 +491,6 @@ void handle_request(sqlite3 *db, BotRequest *br) { goto done; } char *req = br->request; - /* Check if message is a 6-digit OTP code. */ int is_otp = (strlen(req) == 6); for (int i = 0; is_otp && i < 6; i++) { if (!isdigit((unsigned char)req[i])) is_otp = 0; @@ -828,7 +507,6 @@ void handle_request(sqlite3 *db, BotRequest *br) { LastActivity = time(NULL); } - /* Handle callback query (button press). */ if (br->is_callback) { botAnswerCallbackQuery(br->callback_id); if (strcmp(br->callback_data, REFRESH_DATA) == 0 && Connected) { @@ -839,7 +517,6 @@ void handle_request(sqlite3 *db, BotRequest *br) { char *req = br->request; - /* Handle .list command. */ if (strcasecmp(req, ".list") == 0) { disconnect(); sds msg = build_list_message(); @@ -848,7 +525,6 @@ void handle_request(sqlite3 *db, BotRequest *br) { goto done; } - /* Handle .help command. */ if (strcasecmp(req, ".help") == 0) { sds msg = build_help_message(); botSendMessage(br->target, msg, 0); @@ -856,7 +532,6 @@ void handle_request(sqlite3 *db, BotRequest *br) { goto done; } - /* Handle .otptimeout command. */ if (strncasecmp(req, ".otptimeout", 11) == 0) { char *arg = req + 11; while (*arg == ' ') arg++; @@ -873,7 +548,6 @@ void handle_request(sqlite3 *db, BotRequest *br) { goto done; } - /* Handle .N to connect to window N. */ if (req[0] == '.' && isdigit(req[1])) { int n = atoi(req + 1); refresh_window_list(); @@ -883,7 +557,6 @@ void handle_request(sqlite3 *db, BotRequest *br) { goto done; } - /* Store connection info directly. */ WinInfo *w = &WindowList[n - 1]; Connected = 1; ConnectedWid = w->window_id; @@ -902,13 +575,11 @@ void handle_request(sqlite3 *db, BotRequest *br) { botSendMessage(br->target, msg, 0); sdsfree(msg); - /* Raise the window and send welcome screenshot. */ - raise_window_by_id(w->pid, w->window_id); + plat_raise_window(w->pid, w->window_id); send_screenshot(br->target); goto done; } - /* Not a command - send as keystrokes if connected. */ if (!Connected) { sds msg = build_list_message(); botSendMessage(br->target, msg, 0); @@ -916,7 +587,6 @@ void handle_request(sqlite3 *db, BotRequest *br) { goto done; } - /* Check window still exists. */ if (!connected_window_exists()) { disconnect(); sds msg = sdsnew("Window closed.\n\n"); @@ -928,11 +598,8 @@ void handle_request(sqlite3 *db, BotRequest *br) { goto done; } - /* Send keystrokes. */ send_keys(req); - /* Wait a bit for the terminal to react, then re-check the window - * (keystrokes like ESC+N may switch tabs, changing the window ID). */ sleep(2); connected_window_exists(); send_screenshot(br->target); @@ -950,7 +617,6 @@ void cron_callback(sqlite3 *db) { * ========================================================================= */ int main(int argc, char **argv) { - /* Parse our custom flags. */ const char *dbfile = "./mybot.sqlite"; for (int i = 1; i < argc; i++) { if (strcmp(argv[i], "--dangerously-attach-to-any-window") == 0) { @@ -964,10 +630,9 @@ int main(int argc, char **argv) { } } - /* TOTP setup: check/generate secret before starting the bot. */ + plat_init(); totp_setup(dbfile); - /* Triggers: respond to all private messages. */ static char *triggers[] = { "*", NULL }; startBot(TB_CREATE_KV_STORE, argc, argv, TB_FLAGS_IGNORE_BAD_ARG, diff --git a/platform.h b/platform.h new file mode 100644 index 0000000..1c6014b --- /dev/null +++ b/platform.h @@ -0,0 +1,65 @@ +/* + * platform.h - Platform abstraction for window management, screenshots, + * and keystroke injection. + * + * Implementations: platform_macos.c (Core Graphics) and + * platform_linux.c (X11 + XTest + libpng). + */ + +#ifndef PLATFORM_H +#define PLATFORM_H + +#include + +/* Platform-independent window ID. */ +typedef unsigned long PlatWinID; + +/* Modifier flags. */ +#define MOD_CTRL (1<<0) +#define MOD_ALT (1<<1) +#define MOD_CMD (1<<2) /* Cmd on macOS, Super on Linux. */ + +/* Special key identifiers. */ +#define PLAT_KEY_CHAR 0 +#define PLAT_KEY_RETURN 1 +#define PLAT_KEY_TAB 2 +#define PLAT_KEY_ESCAPE 3 + +/* Window information. */ +typedef struct { + PlatWinID window_id; + pid_t pid; + char owner[128]; + char title[256]; +} WinInfo; + +/* Initialize platform resources. Call once at startup. */ +void plat_init(void); + +/* Check if app name is a known terminal. */ +int plat_is_terminal(const char *name); + +/* List terminal windows (or all windows if danger_mode). + * Allocates *out_list; caller must free() it. + * Returns the number of windows found. */ +int plat_list_windows(WinInfo **out_list, int danger_mode); + +/* Check if a window is still on screen. + * If *wid is gone but the same PID has another on-screen window, + * updates *wid to that window. Returns 1 if a window was found. */ +int plat_window_exists(PlatWinID *wid, pid_t pid); + +/* Capture window screenshot and save as PNG at path. + * Returns 0 on success. */ +int plat_capture_window(PlatWinID wid, const char *path); + +/* Raise window to front and focus it. */ +void plat_raise_window(pid_t pid, PlatWinID wid); + +/* Send a single keystroke. + * special: PLAT_KEY_CHAR to type 'ch', or PLAT_KEY_RETURN/TAB/ESCAPE. + * ch: ASCII character (ignored unless special == PLAT_KEY_CHAR). + * mods: bitmask of MOD_CTRL, MOD_ALT, MOD_CMD. */ +void plat_send_key(pid_t pid, int special, int ch, int mods); + +#endif diff --git a/platform_linux.c b/platform_linux.c new file mode 100644 index 0000000..b5ce085 --- /dev/null +++ b/platform_linux.c @@ -0,0 +1,389 @@ +/* + * platform_linux.c - Linux implementation using X11, XTest, and libpng. + * + * Requires: libx11-dev, libxtst-dev, libpng-dev + */ + +#ifdef __linux__ + +#include "platform.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +static Display *dpy; + +/* Known terminal WM_CLASS names on Linux. */ +static const char *TerminalApps[] = { + "gnome-terminal", "xterm", "kitty", "Alacritty", "alacritty", + "ghostty", "Ghostty", "terminator", "tilix", "konsole", + "xfce4-terminal", "mate-terminal", "lxterminal", "st", "stterm", + "urxvt", "URxvt", "foot", "wezterm", "Wezterm", + "hyper", "tabby", "sakura", "terminology", "guake", "tilda", + NULL +}; + +void plat_init(void) { + dpy = XOpenDisplay(NULL); + if (!dpy) { + fprintf(stderr, "Cannot open X display. Is DISPLAY set?\n"); + exit(1); + } +} + +int plat_is_terminal(const char *name) { + for (int i = 0; TerminalApps[i]; i++) { + if (strcasestr(name, TerminalApps[i])) return 1; + } + return 0; +} + +/* ---------- X11 helpers ---------- */ + +/* Get _NET_WM_PID property. Returns 0 if not set. */ +static pid_t get_window_pid(Window win) { + Atom prop = XInternAtom(dpy, "_NET_WM_PID", False); + Atom actual_type; + int actual_format; + unsigned long nitems, bytes_after; + unsigned char *data = NULL; + + if (XGetWindowProperty(dpy, win, prop, 0, 1, False, XA_CARDINAL, + &actual_type, &actual_format, &nitems, + &bytes_after, &data) != Success || !data) + return 0; + + pid_t pid = 0; + if (nitems > 0 && actual_format == 32) + pid = (pid_t)(*(unsigned long *)data); + XFree(data); + return pid; +} + +/* Get WM_CLASS. Returns the class name (second field) as malloc'd string. */ +static char *get_wm_class(Window win) { + XClassHint hint; + memset(&hint, 0, sizeof(hint)); + if (!XGetClassHint(dpy, win, &hint)) return NULL; + char *result = hint.res_class ? strdup(hint.res_class) : NULL; + if (hint.res_name) XFree(hint.res_name); + if (hint.res_class) XFree(hint.res_class); + return result; +} + +/* Get window title (_NET_WM_NAME or WM_NAME). Caller frees result. */ +static char *get_window_title(Window win) { + Atom net_wm_name = XInternAtom(dpy, "_NET_WM_NAME", False); + Atom utf8 = XInternAtom(dpy, "UTF8_STRING", False); + Atom actual_type; + int actual_format; + unsigned long nitems, bytes_after; + unsigned char *data = NULL; + + /* Try _NET_WM_NAME first (UTF-8). */ + if (XGetWindowProperty(dpy, win, net_wm_name, 0, 1024, False, utf8, + &actual_type, &actual_format, &nitems, + &bytes_after, &data) == Success && data && nitems) { + char *title = strdup((char *)data); + XFree(data); + return title; + } + if (data) XFree(data); + + /* Fallback to WM_NAME. */ + data = NULL; + if (XGetWindowProperty(dpy, win, XA_WM_NAME, 0, 1024, False, XA_STRING, + &actual_type, &actual_format, &nitems, + &bytes_after, &data) == Success && data && nitems) { + char *title = strdup((char *)data); + XFree(data); + return title; + } + if (data) XFree(data); + return NULL; +} + +/* Get _NET_CLIENT_LIST from the root window. Caller frees result. */ +static Window *get_client_list(int *count) { + Atom prop = XInternAtom(dpy, "_NET_CLIENT_LIST", False); + Atom actual_type; + int actual_format; + unsigned long nitems, bytes_after; + unsigned char *data = NULL; + + Window root = DefaultRootWindow(dpy); + if (XGetWindowProperty(dpy, root, prop, 0, 4096, False, XA_WINDOW, + &actual_type, &actual_format, &nitems, + &bytes_after, &data) != Success || !data) { + *count = 0; + return NULL; + } + + Window *list = malloc(nitems * sizeof(Window)); + if (!list) { XFree(data); *count = 0; return NULL; } + memcpy(list, data, nitems * sizeof(Window)); + *count = (int)nitems; + XFree(data); + return list; +} + +/* ---------- Window listing ---------- */ + +int plat_list_windows(WinInfo **out_list, int danger_mode) { + *out_list = NULL; + + int wcount = 0; + Window *clients = get_client_list(&wcount); + if (!clients || wcount == 0) return 0; + + WinInfo *list = malloc(wcount * sizeof(WinInfo)); + if (!list) { free(clients); return 0; } + int n = 0; + + for (int i = 0; i < wcount; i++) { + char *wm_class = get_wm_class(clients[i]); + if (!wm_class) continue; + + if (!danger_mode && !plat_is_terminal(wm_class)) { + free(wm_class); + continue; + } + + XWindowAttributes attr; + if (!XGetWindowAttributes(dpy, clients[i], &attr) || + attr.width <= 50 || attr.height <= 50) { + free(wm_class); + continue; + } + + WinInfo *w = &list[n++]; + w->window_id = (PlatWinID)clients[i]; + w->pid = get_window_pid(clients[i]); + + strncpy(w->owner, wm_class, sizeof(w->owner) - 1); + w->owner[sizeof(w->owner) - 1] = '\0'; + free(wm_class); + + char *title = get_window_title(clients[i]); + if (title) { + strncpy(w->title, title, sizeof(w->title) - 1); + w->title[sizeof(w->title) - 1] = '\0'; + free(title); + } else { + w->title[0] = '\0'; + } + } + + free(clients); + *out_list = list; + return n; +} + +/* ---------- Window existence check ---------- */ + +int plat_window_exists(PlatWinID *wid, pid_t pid) { + int wcount = 0; + Window *clients = get_client_list(&wcount); + if (!clients) return 0; + + int found = 0; + PlatWinID fallback = 0; + + for (int i = 0; i < wcount; i++) { + if ((PlatWinID)clients[i] == *wid) { + found = 1; + break; + } + if (!fallback && get_window_pid(clients[i]) == pid) + fallback = (PlatWinID)clients[i]; + } + + if (!found && fallback) { + *wid = fallback; + found = 1; + } + + free(clients); + return found; +} + +/* ---------- Screenshot ---------- */ + +/* Save XImage as PNG using libpng. */ +static int save_ximage_png(XImage *img, const char *path) { + FILE *fp = fopen(path, "wb"); + if (!fp) return -1; + + png_structp png = png_create_write_struct(PNG_LIBPNG_VER_STRING, + NULL, NULL, NULL); + if (!png) { fclose(fp); return -1; } + + png_infop info = png_create_info_struct(png); + if (!info) { + png_destroy_write_struct(&png, NULL); + fclose(fp); + return -1; + } + + if (setjmp(png_jmpbuf(png))) { + png_destroy_write_struct(&png, &info); + fclose(fp); + return -1; + } + + png_init_io(png, fp); + png_set_IHDR(png, info, img->width, img->height, 8, + PNG_COLOR_TYPE_RGB, PNG_INTERLACE_NONE, + PNG_COMPRESSION_TYPE_DEFAULT, PNG_FILTER_TYPE_DEFAULT); + png_write_info(png, info); + + unsigned char *row = malloc(3 * img->width); + if (!row) { + png_destroy_write_struct(&png, &info); + fclose(fp); + return -1; + } + + for (int y = 0; y < img->height; y++) { + for (int x = 0; x < img->width; x++) { + unsigned long pixel = XGetPixel(img, x, y); + row[x * 3] = (pixel >> 16) & 0xFF; /* R */ + row[x * 3 + 1] = (pixel >> 8) & 0xFF; /* G */ + row[x * 3 + 2] = pixel & 0xFF; /* B */ + } + png_write_row(png, row); + } + + free(row); + png_write_end(png, NULL); + png_destroy_write_struct(&png, &info); + fclose(fp); + return 0; +} + +int plat_capture_window(PlatWinID wid, const char *path) { + Window win = (Window)wid; + XWindowAttributes attr; + if (!XGetWindowAttributes(dpy, win, &attr)) return -1; + + /* Translate window origin to root coordinates so we can capture + * from the root window. This is more reliable with compositing WMs + * than capturing from the window drawable directly. */ + int rx, ry; + Window child; + XTranslateCoordinates(dpy, win, DefaultRootWindow(dpy), + 0, 0, &rx, &ry, &child); + + /* Clip to screen bounds. */ + int sw = DisplayWidth(dpy, DefaultScreen(dpy)); + int sh = DisplayHeight(dpy, DefaultScreen(dpy)); + int x = rx < 0 ? 0 : rx; + int y = ry < 0 ? 0 : ry; + int w = attr.width - (x - rx); + int h = attr.height - (y - ry); + if (x + w > sw) w = sw - x; + if (y + h > sh) h = sh - y; + if (w <= 0 || h <= 0) return -1; + + XImage *img = XGetImage(dpy, DefaultRootWindow(dpy), + x, y, w, h, AllPlanes, ZPixmap); + if (!img) return -1; + + int ret = save_ximage_png(img, path); + XDestroyImage(img); + return ret; +} + +/* ---------- Window focus ---------- */ + +void plat_raise_window(pid_t pid, PlatWinID wid) { + (void)pid; + Window win = (Window)wid; + Window root = DefaultRootWindow(dpy); + + /* Send _NET_ACTIVE_WINDOW client message to the window manager. */ + XEvent ev; + memset(&ev, 0, sizeof(ev)); + ev.xclient.type = ClientMessage; + ev.xclient.window = win; + ev.xclient.message_type = XInternAtom(dpy, "_NET_ACTIVE_WINDOW", False); + ev.xclient.format = 32; + ev.xclient.data.l[0] = 1; /* Source: application */ + ev.xclient.data.l[1] = CurrentTime; + ev.xclient.data.l[2] = 0; + + XSendEvent(dpy, root, False, + SubstructureRedirectMask | SubstructureNotifyMask, &ev); + XMapRaised(dpy, win); + XFlush(dpy); + usleep(100000); +} + +/* ---------- Keystroke injection ---------- */ + +void plat_send_key(pid_t pid, int special, int ch, int mods) { + (void)pid; /* XTest sends to the focused window. */ + + KeyCode keycode; + int need_shift = 0; + + if (special == PLAT_KEY_RETURN) { + keycode = XKeysymToKeycode(dpy, XK_Return); + } else if (special == PLAT_KEY_TAB) { + keycode = XKeysymToKeycode(dpy, XK_Tab); + } else if (special == PLAT_KEY_ESCAPE) { + keycode = XKeysymToKeycode(dpy, XK_Escape); + } else { + /* Map ASCII character to keysym. X11 Latin-1 keysyms match ASCII. */ + KeySym sym = (KeySym)ch; + keycode = XKeysymToKeycode(dpy, sym); + if (!keycode) return; + + /* Determine if Shift is needed: compare with the unshifted keysym + * at index 0 for this keycode. */ + KeySym base = XkbKeycodeToKeysym(dpy, keycode, 0, 0); + if (base != sym) need_shift = 1; + } + + if (!keycode) return; + + /* Press modifiers. */ + if (mods & MOD_CTRL) + XTestFakeKeyEvent(dpy, XKeysymToKeycode(dpy, XK_Control_L), True, 0); + if (mods & MOD_ALT) + XTestFakeKeyEvent(dpy, XKeysymToKeycode(dpy, XK_Alt_L), True, 0); + if (mods & MOD_CMD) + XTestFakeKeyEvent(dpy, XKeysymToKeycode(dpy, XK_Super_L), True, 0); + if (need_shift) + XTestFakeKeyEvent(dpy, XKeysymToKeycode(dpy, XK_Shift_L), True, 0); + + /* Key press + release. */ + XTestFakeKeyEvent(dpy, keycode, True, 0); + XTestFakeKeyEvent(dpy, keycode, False, 0); + + /* Release modifiers (reverse order). */ + if (need_shift) + XTestFakeKeyEvent(dpy, XKeysymToKeycode(dpy, XK_Shift_L), False, 0); + if (mods & MOD_CMD) + XTestFakeKeyEvent(dpy, XKeysymToKeycode(dpy, XK_Super_L), False, 0); + if (mods & MOD_ALT) + XTestFakeKeyEvent(dpy, XKeysymToKeycode(dpy, XK_Alt_L), False, 0); + if (mods & MOD_CTRL) + XTestFakeKeyEvent(dpy, XKeysymToKeycode(dpy, XK_Control_L), False, 0); + + XFlush(dpy); + usleep(5000); +} + +#endif /* __linux__ */ diff --git a/platform_macos.c b/platform_macos.c new file mode 100644 index 0000000..a61c209 --- /dev/null +++ b/platform_macos.c @@ -0,0 +1,300 @@ +/* + * platform_macos.c - macOS implementation using Core Graphics and Accessibility. + */ + +#ifdef __APPLE__ + +#include "platform.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +/* Known terminal application names on macOS. */ +static const char *TerminalApps[] = { + "Terminal", "iTerm2", "iTerm", "Ghostty", "kitty", "Alacritty", + "Hyper", "Warp", "WezTerm", "Tabby", NULL +}; + +void plat_init(void) { + /* Nothing needed on macOS. */ +} + +int plat_is_terminal(const char *name) { + for (int i = 0; TerminalApps[i]; i++) { + if (strcasestr(name, TerminalApps[i])) return 1; + } + return 0; +} + +int plat_list_windows(WinInfo **out_list, int danger_mode) { + *out_list = NULL; + + CFArrayRef list = CGWindowListCopyWindowInfo( + kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements, + kCGNullWindowID + ); + if (!list) return 0; + + CFIndex count = CFArrayGetCount(list); + WinInfo *wlist = malloc(count * sizeof(WinInfo)); + if (!wlist) { CFRelease(list); return 0; } + + int n = 0; + for (CFIndex i = 0; i < count; i++) { + CFDictionaryRef info = CFArrayGetValueAtIndex(list, i); + + CFStringRef owner_ref = CFDictionaryGetValue(info, kCGWindowOwnerName); + if (!owner_ref) continue; + + char owner[128]; + if (!CFStringGetCString(owner_ref, owner, sizeof(owner), + kCFStringEncodingUTF8)) + continue; + + if (!danger_mode && !plat_is_terminal(owner)) continue; + + CFNumberRef wid_ref = CFDictionaryGetValue(info, kCGWindowNumber); + CFNumberRef pid_ref = CFDictionaryGetValue(info, kCGWindowOwnerPID); + if (!wid_ref || !pid_ref) continue; + + CGWindowID wid; + pid_t pid; + CFNumberGetValue(wid_ref, kCGWindowIDCFNumberType, &wid); + CFNumberGetValue(pid_ref, kCFNumberIntType, &pid); + + CFNumberRef layer_ref = CFDictionaryGetValue(info, kCGWindowLayer); + int layer = 0; + if (layer_ref) CFNumberGetValue(layer_ref, kCFNumberIntType, &layer); + if (layer != 0) continue; + + CFDictionaryRef bounds_dict = CFDictionaryGetValue(info, kCGWindowBounds); + if (!bounds_dict) continue; + CGRect bounds; + CGRectMakeWithDictionaryRepresentation(bounds_dict, &bounds); + if (bounds.size.width <= 50 || bounds.size.height <= 50) continue; + + CFStringRef title_ref = CFDictionaryGetValue(info, kCGWindowName); + char title[256] = ""; + if (title_ref) + CFStringGetCString(title_ref, title, sizeof(title), + kCFStringEncodingUTF8); + + WinInfo *w = &wlist[n++]; + w->window_id = (PlatWinID)wid; + w->pid = pid; + strncpy(w->owner, owner, sizeof(w->owner) - 1); + w->owner[sizeof(w->owner) - 1] = '\0'; + strncpy(w->title, title, sizeof(w->title) - 1); + w->title[sizeof(w->title) - 1] = '\0'; + } + + CFRelease(list); + *out_list = wlist; + return n; +} + +int plat_window_exists(PlatWinID *wid, pid_t pid) { + CFArrayRef list = CGWindowListCopyWindowInfo( + kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements, + kCGNullWindowID + ); + if (!list) return 0; + + int found = 0; + PlatWinID fallback = 0; + CFIndex count = CFArrayGetCount(list); + + for (CFIndex i = 0; i < count; i++) { + CFDictionaryRef info = CFArrayGetValueAtIndex(list, i); + CFNumberRef wid_ref = CFDictionaryGetValue(info, kCGWindowNumber); + CFNumberRef pid_ref = CFDictionaryGetValue(info, kCGWindowOwnerPID); + if (!wid_ref || !pid_ref) continue; + + CGWindowID cg_wid; + pid_t cg_pid; + CFNumberGetValue(wid_ref, kCGWindowIDCFNumberType, &cg_wid); + CFNumberGetValue(pid_ref, kCFNumberIntType, &cg_pid); + + if ((PlatWinID)cg_wid == *wid) { + found = 1; + break; + } + + if (cg_pid == pid && !fallback) { + CFNumberRef layer_ref = CFDictionaryGetValue(info, kCGWindowLayer); + int layer = 0; + if (layer_ref) + CFNumberGetValue(layer_ref, kCFNumberIntType, &layer); + if (layer == 0) fallback = (PlatWinID)cg_wid; + } + } + + if (!found && fallback) { + *wid = fallback; + found = 1; + } + + CFRelease(list); + return found; +} + +static int save_png(CGImageRef image, const char *path) { + CFStringRef cfpath = CFStringCreateWithCString(NULL, path, + kCFStringEncodingUTF8); + CFURLRef url = CFURLCreateWithFileSystemPath(NULL, cfpath, + kCFURLPOSIXPathStyle, false); + CFRelease(cfpath); + if (!url) return -1; + + CGImageDestinationRef dest = + CGImageDestinationCreateWithURL(url, CFSTR("public.png"), 1, NULL); + CFRelease(url); + if (!dest) return -1; + + CGImageDestinationAddImage(dest, image, NULL); + int ok = CGImageDestinationFinalize(dest); + CFRelease(dest); + return ok ? 0 : -1; +} + +int plat_capture_window(PlatWinID wid, const char *path) { + CGImageRef img = CGWindowListCreateImage( + CGRectNull, kCGWindowListOptionIncludingWindow, (CGWindowID)wid, + kCGWindowImageBoundsIgnoreFraming | kCGWindowImageNominalResolution); + if (!img) return -1; + + int ret = save_png(img, path); + CGImageRelease(img); + return ret; +} + +/* Private API to get CGWindowID from AXUIElement. */ +extern AXError _AXUIElementGetWindow(AXUIElementRef element, CGWindowID *wid); + +static int bring_to_front(pid_t pid) { + ProcessSerialNumber psn; + if (GetProcessForPID(pid, &psn) != noErr) return -1; + if (SetFrontProcessWithOptions(&psn, kSetFrontProcessFrontWindowOnly) + != noErr) return -1; + usleep(100000); + return 0; +} + +void plat_raise_window(pid_t pid, PlatWinID wid) { + AXUIElementRef app = AXUIElementCreateApplication(pid); + if (!app) { bring_to_front(pid); return; } + + CFArrayRef windows = NULL; + AXUIElementCopyAttributeValue(app, kAXWindowsAttribute, + (CFTypeRef *)&windows); + CFRelease(app); + + if (windows) { + CFIndex count = CFArrayGetCount(windows); + for (CFIndex i = 0; i < count; i++) { + AXUIElementRef win = + (AXUIElementRef)CFArrayGetValueAtIndex(windows, i); + CGWindowID cg_wid = 0; + if (_AXUIElementGetWindow(win, &cg_wid) == kAXErrorSuccess) { + if ((PlatWinID)cg_wid == wid) { + AXUIElementPerformAction(win, kAXRaiseAction); + break; + } + } + } + CFRelease(windows); + } + + bring_to_front(pid); +} + +/* Map ASCII character to macOS virtual keycode (US keyboard layout). */ +static CGKeyCode keycode_for_char(char c) { + static const CGKeyCode letter_map[26] = { + 0x00,0x0B,0x08,0x02,0x0E,0x03,0x05,0x04,0x22,0x26, + 0x28,0x25,0x2E,0x2D,0x1F,0x23,0x0C,0x0F,0x01,0x11, + 0x20,0x09,0x0D,0x07,0x10,0x06 + }; + static const CGKeyCode digit_map[10] = { + 0x1D,0x12,0x13,0x14,0x15,0x17,0x16,0x1A,0x1C,0x19 + }; + if (c >= 'a' && c <= 'z') return letter_map[c - 'a']; + if (c >= 'A' && c <= 'Z') return letter_map[c - 'A']; + if (c >= '0' && c <= '9') return digit_map[c - '0']; + switch (c) { + case '-': return 0x1B; case '=': return 0x18; + case '[': return 0x21; case ']': return 0x1E; + case '\\': return 0x2A; case ';': return 0x29; + case '\'': return 0x27; case ',': return 0x2B; + case '.': return 0x2F; case '/': return 0x2C; + case '`': return 0x32; case ' ': return 0x31; + } + return 0xFFFF; +} + +void plat_send_key(pid_t pid, int special, int ch, int mods) { + CGKeyCode keycode; + UniChar uc = 0; + int mapped_keycode = 0; + + switch (special) { + case PLAT_KEY_RETURN: keycode = 0x24; break; + case PLAT_KEY_TAB: keycode = 0x30; break; + case PLAT_KEY_ESCAPE: keycode = 0x35; break; + default: + uc = (UniChar)ch; + if (mods) { + CGKeyCode mapped = keycode_for_char((char)ch); + if (mapped != 0xFFFF) { + keycode = mapped; + mapped_keycode = 1; + } else { + keycode = 0; + } + } else { + keycode = 0; + } + break; + } + + CGEventRef down = CGEventCreateKeyboardEvent(NULL, keycode, true); + CGEventRef up = CGEventCreateKeyboardEvent(NULL, keycode, false); + if (!down || !up) { + if (down) CFRelease(down); + if (up) CFRelease(up); + return; + } + + CGEventFlags flags = 0; + if (mods & MOD_CTRL) flags |= kCGEventFlagMaskControl; + if (mods & MOD_ALT) flags |= kCGEventFlagMaskAlternate; + if (mods & MOD_CMD) flags |= kCGEventFlagMaskCommand; + + if (flags) { + CGEventSetFlags(down, flags); + CGEventSetFlags(up, flags); + } + + if (uc && !mapped_keycode) { + CGEventKeyboardSetUnicodeString(down, 1, &uc); + CGEventKeyboardSetUnicodeString(up, 1, &uc); + } + + CGEventPostToPid(pid, down); + usleep(1000); + CGEventPostToPid(pid, up); + usleep(5000); + + CFRelease(down); + CFRelease(up); +} + +#endif /* __APPLE__ */ From 6ef8cf07bcb3a3ad46cec89d0dcdf8aaab283fb7 Mon Sep 17 00:00:00 2001 From: RIKIBBOT Date: Fri, 6 Feb 2026 18:21:05 +0000 Subject: [PATCH 2/4] Document resolution, theme, and color customization for headless setup --- README.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/README.md b/README.md index cad7d5a..5f74abe 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,62 @@ DISPLAY=:99 ./tgterm --apikey You can launch as many xterm sessions as you need and switch between them with `.list` and `.1`, `.2`, etc. from Telegram. +### Resolution + +Change the Xvfb screen size for higher resolution screenshots. For example, 2560x1440: + +``` +Xvfb :99 -screen 0 2560x1440x24 & +``` + +### Terminal theme and colors + +xterm reads `~/.Xresources` for appearance settings. Load them with `xrdb` **before** starting xterm: + +``` +DISPLAY=:99 xrdb -merge ~/.Xresources +DISPLAY=:99 xterm -title "Session 1" & +``` + +Example `~/.Xresources` with a dark theme (Catppuccin Mocha): + +``` +xterm*faceName: DejaVu Sans Mono +xterm*faceSize: 13 +xterm*renderFont: true +xterm*termName: xterm-256color +xterm*scrollBar: false +xterm*saveLines: 4096 +xterm*geometry: 140x45 + +xterm*background: #1e1e2e +xterm*foreground: #cdd6f4 +xterm*cursorColor: #f5e0dc + +xterm*color0: #45475a +xterm*color1: #f38ba8 +xterm*color2: #a6e3a1 +xterm*color3: #f9e2af +xterm*color4: #89b4fa +xterm*color5: #f5c2e7 +xterm*color6: #94e2d5 +xterm*color7: #bac2de +xterm*color8: #585b70 +xterm*color9: #f38ba8 +xterm*color10: #a6e3a1 +xterm*color11: #f9e2af +xterm*color12: #89b4fa +xterm*color13: #f5c2e7 +xterm*color14: #94e2d5 +xterm*color15: #f5e0dc +``` + +You'll also need `x11-xserver-utils` installed for `xrdb`: + +``` +sudo apt install x11-xserver-utils +``` + ### What you need - **Xvfb** — virtual X server (renders to memory, no GPU needed). From 0a03525236729a1d7871c5d8caba0f9499223748 Mon Sep 17 00:00:00 2001 From: RIKIBBOT Date: Fri, 6 Feb 2026 18:32:41 +0000 Subject: [PATCH 3/4] Add arrow keys, PageUp/PageDown support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emoji-based navigation keys: - ⬆️⬇️⬅️➡️ for arrow keys (history, cursor movement) - 🔼🔽 for Page Up/Down (scrolling in vim, less, etc.) - Modifiers work with nav keys (e.g. Ctrl+Up) --- README.md | 7 ++++++ bot.c | 58 ++++++++++++++++++++++++++++++++++++++++++++++++ platform.h | 6 +++++ platform_linux.c | 12 ++++++++++ platform_macos.c | 6 +++++ 5 files changed, 89 insertions(+) diff --git a/README.md b/README.md index 5f74abe..f053907 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,13 @@ Once connected to a window, any text you send is typed into it as keystrokes. A Modifiers can be combined: `❤️💙x` sends Ctrl+Alt+X. A single modified keystroke (like `❤️c`) will not have an automatic newline appended. +**Navigation keys:** + +- ⬆️ ⬇️ ⬅️ ➡️ — Arrow keys (command history, cursor movement) +- 🔼 🔽 — Page Up / Page Down (scrolling in vim, less, etc.) + +Modifiers work with navigation too: `❤️⬆️` sends Ctrl+Up. + **Escape sequences:** `\n` sends Enter, `\t` sends Tab, `\\` sends a literal backslash. ### Screenshots diff --git a/bot.c b/bot.c index b45264f..7a99144 100644 --- a/bot.c +++ b/bot.c @@ -257,6 +257,40 @@ int match_purple_heart(const unsigned char *p, size_t remaining) { return 0; } +/* Match arrow emoji ⬆️⬇️⬅️➡️ (3-byte base + optional 3-byte VS16). + * Returns consumed bytes and sets *key to the PLAT_KEY_* constant. */ +int match_arrow(const unsigned char *p, size_t remaining, int *key) { + if (remaining >= 3 && p[0] == 0xE2 && p[1] == 0xAC) { + int k = 0; + if (p[2] == 0x86) k = PLAT_KEY_UP; /* ⬆ U+2B06 */ + else if (p[2] == 0x87) k = PLAT_KEY_DOWN; /* ⬇ U+2B07 */ + else if (p[2] == 0x85) k = PLAT_KEY_LEFT; /* ⬅ U+2B05 */ + if (k) { + *key = k; + if (remaining >= 6 && p[3] == 0xEF && p[4] == 0xB8 && p[5] == 0x8F) + return 6; + return 3; + } + } + /* ➡ U+27A1 = E2 9E A1 (+ optional VS16) */ + if (remaining >= 3 && p[0] == 0xE2 && p[1] == 0x9E && p[2] == 0xA1) { + *key = PLAT_KEY_RIGHT; + if (remaining >= 6 && p[3] == 0xEF && p[4] == 0xB8 && p[5] == 0x8F) + return 6; + return 3; + } + return 0; +} + +/* Match page up/down emoji 🔼🔽 (F0 9F 94 BC/BD). */ +int match_page_updown(const unsigned char *p, size_t remaining, int *key) { + if (remaining >= 4 && p[0] == 0xF0 && p[1] == 0x9F && p[2] == 0x94) { + if (p[3] == 0xBC) { *key = PLAT_KEY_PAGEUP; return 4; } /* 🔼 */ + if (p[3] == 0xBD) { *key = PLAT_KEY_PAGEDN; return 4; } /* 🔽 */ + } + return 0; +} + /* Check if string ends with purple heart. */ int ends_with_purple_heart(const char *text) { size_t len = strlen(text); @@ -343,6 +377,23 @@ int send_keys(const char *text) { continue; } + int navkey; + if ((consumed = match_arrow(p, len, &navkey)) > 0) { + plat_send_key(ConnectedPid, navkey, 0, mods); + if (mods) had_mods = 1; + keycount++; last_was_nl = 0; mods = 0; + p += consumed; len -= consumed; + continue; + } + + if ((consumed = match_page_updown(p, len, &navkey)) > 0) { + plat_send_key(ConnectedPid, navkey, 0, mods); + if (mods) had_mods = 1; + keycount++; last_was_nl = 0; mods = 0; + p += consumed; len -= consumed; + continue; + } + if ((consumed = match_colored_heart(p, len, &heart)) > 0) { if (heart == 'Y') { plat_send_key(ConnectedPid, PLAT_KEY_ESCAPE, 0, 0); @@ -435,6 +486,13 @@ sds build_help_message(void) { "`\xf0\x9f\x92\x9a` Cmd/Super " "`\xf0\x9f\x92\x9b` ESC " "`\xf0\x9f\xa7\xa1` Enter\n\n" + "Navigation:\n" + "`\xe2\xac\x86\xef\xb8\x8f` Up " + "`\xe2\xac\x87\xef\xb8\x8f` Down " + "`\xe2\xac\x85\xef\xb8\x8f` Left " + "`\xe2\x9e\xa1\xef\xb8\x8f` Right " + "`\xf0\x9f\x94\xbc` PgUp " + "`\xf0\x9f\x94\xbd` PgDn\n\n" "Escape sequences: \\n=Enter \\t=Tab\n\n" "`.otptimeout ` - Set OTP timeout (30-28800)" ); diff --git a/platform.h b/platform.h index 1c6014b..c875434 100644 --- a/platform.h +++ b/platform.h @@ -24,6 +24,12 @@ typedef unsigned long PlatWinID; #define PLAT_KEY_RETURN 1 #define PLAT_KEY_TAB 2 #define PLAT_KEY_ESCAPE 3 +#define PLAT_KEY_UP 4 +#define PLAT_KEY_DOWN 5 +#define PLAT_KEY_LEFT 6 +#define PLAT_KEY_RIGHT 7 +#define PLAT_KEY_PAGEUP 8 +#define PLAT_KEY_PAGEDN 9 /* Window information. */ typedef struct { diff --git a/platform_linux.c b/platform_linux.c index b5ce085..c1b7d74 100644 --- a/platform_linux.c +++ b/platform_linux.c @@ -344,6 +344,18 @@ void plat_send_key(pid_t pid, int special, int ch, int mods) { keycode = XKeysymToKeycode(dpy, XK_Tab); } else if (special == PLAT_KEY_ESCAPE) { keycode = XKeysymToKeycode(dpy, XK_Escape); + } else if (special == PLAT_KEY_UP) { + keycode = XKeysymToKeycode(dpy, XK_Up); + } else if (special == PLAT_KEY_DOWN) { + keycode = XKeysymToKeycode(dpy, XK_Down); + } else if (special == PLAT_KEY_LEFT) { + keycode = XKeysymToKeycode(dpy, XK_Left); + } else if (special == PLAT_KEY_RIGHT) { + keycode = XKeysymToKeycode(dpy, XK_Right); + } else if (special == PLAT_KEY_PAGEUP) { + keycode = XKeysymToKeycode(dpy, XK_Page_Up); + } else if (special == PLAT_KEY_PAGEDN) { + keycode = XKeysymToKeycode(dpy, XK_Page_Down); } else { /* Map ASCII character to keysym. X11 Latin-1 keysyms match ASCII. */ KeySym sym = (KeySym)ch; diff --git a/platform_macos.c b/platform_macos.c index a61c209..e150a46 100644 --- a/platform_macos.c +++ b/platform_macos.c @@ -249,6 +249,12 @@ void plat_send_key(pid_t pid, int special, int ch, int mods) { case PLAT_KEY_RETURN: keycode = 0x24; break; case PLAT_KEY_TAB: keycode = 0x30; break; case PLAT_KEY_ESCAPE: keycode = 0x35; break; + case PLAT_KEY_UP: keycode = 0x7E; break; + case PLAT_KEY_DOWN: keycode = 0x7D; break; + case PLAT_KEY_LEFT: keycode = 0x7B; break; + case PLAT_KEY_RIGHT: keycode = 0x7C; break; + case PLAT_KEY_PAGEUP: keycode = 0x74; break; + case PLAT_KEY_PAGEDN: keycode = 0x79; break; default: uc = (UniChar)ch; if (mods) { From dd2178681619e6def4e343222e154cfe0ff255ba Mon Sep 17 00:00:00 2001 From: RIKIBBOT Date: Fri, 6 Feb 2026 19:10:47 +0000 Subject: [PATCH 4/4] Fix nav key auto-newline bug, restore comments, trim README Navigation keys (arrows, PageUp/PageDown) sent without modifiers were getting an automatic Enter appended because had_mods was only set when mods != 0. Now had_mods is set unconditionally for all navigation keys, matching the behavior of ESC. Restore inline comments that were inadvertently stripped during the platform refactoring. Trim the README Xresources section to avoid bloating it with a full color theme. --- README.md | 46 +---------------------------------- bot.c | 71 +++++++++++++++++++++++++++++++++++++++---------------- 2 files changed, 51 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index f053907..aa95e0c 100644 --- a/README.md +++ b/README.md @@ -158,51 +158,7 @@ Xvfb :99 -screen 0 2560x1440x24 & ### Terminal theme and colors -xterm reads `~/.Xresources` for appearance settings. Load them with `xrdb` **before** starting xterm: - -``` -DISPLAY=:99 xrdb -merge ~/.Xresources -DISPLAY=:99 xterm -title "Session 1" & -``` - -Example `~/.Xresources` with a dark theme (Catppuccin Mocha): - -``` -xterm*faceName: DejaVu Sans Mono -xterm*faceSize: 13 -xterm*renderFont: true -xterm*termName: xterm-256color -xterm*scrollBar: false -xterm*saveLines: 4096 -xterm*geometry: 140x45 - -xterm*background: #1e1e2e -xterm*foreground: #cdd6f4 -xterm*cursorColor: #f5e0dc - -xterm*color0: #45475a -xterm*color1: #f38ba8 -xterm*color2: #a6e3a1 -xterm*color3: #f9e2af -xterm*color4: #89b4fa -xterm*color5: #f5c2e7 -xterm*color6: #94e2d5 -xterm*color7: #bac2de -xterm*color8: #585b70 -xterm*color9: #f38ba8 -xterm*color10: #a6e3a1 -xterm*color11: #f9e2af -xterm*color12: #89b4fa -xterm*color13: #f5c2e7 -xterm*color14: #94e2d5 -xterm*color15: #f5e0dc -``` - -You'll also need `x11-xserver-utils` installed for `xrdb`: - -``` -sudo apt install x11-xserver-utils -``` +xterm reads `~/.Xresources` for appearance settings (font, colors, geometry). Load them with `xrdb -merge ~/.Xresources` before starting xterm. You can also pass settings directly via `-xrm` flags on the xterm command line. ### What you need diff --git a/bot.c b/bot.c index 7a99144..6a17196 100644 --- a/bot.c +++ b/bot.c @@ -41,12 +41,12 @@ static int Authenticated = 0; /* Whether OTP has been verified. */ static time_t LastActivity = 0; /* Last time owner sent a valid command. */ static int OtpTimeout = 300; /* Timeout in seconds (default 5 min). */ -/* Connected window. */ -static int Connected = 0; -static PlatWinID ConnectedWid = 0; -static pid_t ConnectedPid = 0; -static char ConnectedOwner[128]; -static char ConnectedTitle[256]; +/* Connected window - stored directly, not as index. */ +static int Connected = 0; /* 1 if connected, 0 otherwise. */ +static PlatWinID ConnectedWid = 0; /* Window ID of connected window. */ +static pid_t ConnectedPid = 0; /* PID of connected window. */ +static char ConnectedOwner[128]; /* Owner name for display. */ +static char ConnectedTitle[256]; /* Title for display. */ /* ============================================================================ * TOTP Authentication @@ -96,7 +96,8 @@ static uint32_t totp_code(const unsigned char *secret, size_t secret_len, return code % 1000000; } -/* Print QR code as compact ASCII art using half-block characters. */ +/* Print QR code as compact ASCII art using half-block characters. + * Each output line encodes two QR rows using ▀ ▄ █ and space. */ static void print_qr_ascii(const char *text) { uint8_t qrcode[qrcodegen_BUFFER_LEN_MAX]; uint8_t tempbuf[qrcodegen_BUFFER_LEN_MAX]; @@ -109,7 +110,7 @@ static void print_qr_ascii(const char *text) { } int size = qrcodegen_getSize(qrcode); - int lo = -1, hi = size + 1; + int lo = -1, hi = size + 1; /* 1-module quiet zone. */ for (int y = lo; y < hi; y += 2) { for (int x = lo; x < hi; x++) { @@ -117,9 +118,9 @@ static void print_qr_ascii(const char *text) { qrcodegen_getModule(qrcode, x, y)); int bot = (x >= 0 && x < size && y+1 >= 0 && y+1 < size && qrcodegen_getModule(qrcode, x, y+1)); - if (top && bot) printf("\xe2\x96\x88"); - else if (top && !bot) printf("\xe2\x96\x80"); - else if (!top && bot) printf("\xe2\x96\x84"); + if (top && bot) printf("\xe2\x96\x88"); /* █ */ + else if (top && !bot) printf("\xe2\x96\x80"); /* ▀ */ + else if (!top && bot) printf("\xe2\x96\x84"); /* ▄ */ else printf(" "); } printf("\n"); @@ -148,7 +149,8 @@ static const char *bytes_to_hex(const unsigned char *data, int len) { return hex; } -/* Setup TOTP: check for existing secret, generate if needed, display QR. */ +/* Setup TOTP: check for existing secret, generate if needed, display QR. + * Returns 1 on success, 0 on error/weak-security. */ static int totp_setup(const char *db_path) { if (WeakSecurity) return 0; @@ -157,11 +159,14 @@ static int totp_setup(const char *db_path) { fprintf(stderr, "Cannot open database for TOTP setup.\n"); return 0; } + /* Ensure KV table exists. */ sqlite3_exec(db, TB_CREATE_KV_STORE, 0, 0, NULL); + /* Check for existing secret. */ sds existing = kvGet(db, "totp_secret"); if (existing) { sdsfree(existing); + /* Load stored timeout if present. */ sds timeout_str = kvGet(db, "otp_timeout"); if (timeout_str) { int t = atoi(timeout_str); @@ -169,9 +174,10 @@ static int totp_setup(const char *db_path) { sdsfree(timeout_str); } sqlite3_close(db); - return 1; + return 1; /* Secret already exists. */ } + /* Generate 20 random bytes. */ unsigned char secret[20]; FILE *f = fopen("/dev/urandom", "r"); if (!f || fread(secret, 1, 20, f) != 20) { @@ -181,9 +187,11 @@ static int totp_setup(const char *db_path) { } fclose(f); + /* Store as hex in KV. */ kvSet(db, "totp_secret", bytes_to_hex(secret, 20), 0); sqlite3_close(db); + /* Build otpauth URI and display QR code. */ const char *b32 = base32_encode(secret, 20); char uri[256]; snprintf(uri, sizeof(uri), @@ -236,9 +244,9 @@ int match_red_heart(const unsigned char *p, size_t remaining) { /* Match colored hearts 💙💚💛 (F0 9F 92 99/9A/9B). */ int match_colored_heart(const unsigned char *p, size_t remaining, char *heart) { if (remaining >= 4 && p[0] == 0xF0 && p[1] == 0x9F && p[2] == 0x92) { - if (p[3] == 0x99) { *heart = 'B'; return 4; } - if (p[3] == 0x9A) { *heart = 'G'; return 4; } - if (p[3] == 0x9B) { *heart = 'Y'; return 4; } + if (p[3] == 0x99) { *heart = 'B'; return 4; } /* 💙 Blue = Alt */ + if (p[3] == 0x9A) { *heart = 'G'; return 4; } /* 💚 Green = Cmd */ + if (p[3] == 0x9B) { *heart = 'Y'; return 4; } /* 💛 Yellow = ESC */ } return 0; } @@ -319,6 +327,9 @@ int refresh_window_list(void) { return WindowCount; } +/* Check if connected window still exists on screen. If the exact window ID + * is gone but the same PID still has an on-screen window (tab switch), + * the platform layer updates ConnectedWid to the new window. */ int connected_window_exists(void) { if (!Connected) return 0; return plat_window_exists(&ConnectedWid, ConnectedPid); @@ -347,20 +358,22 @@ int send_keys(const char *text) { plat_raise_window(ConnectedPid, ConnectedWid); + /* Check if we should suppress trailing newline. */ int add_newline = !ends_with_purple_heart(text); const unsigned char *p = (const unsigned char *)text; size_t len = strlen(text); + /* If ends with purple heart, reduce length to skip it. */ if (!add_newline && len >= 4) len -= 4; int mods = 0; int consumed; char heart; - int keycount = 0; - int had_mods = 0; - int last_was_nl = 0; + int keycount = 0; /* Number of actual keystrokes sent. */ + int had_mods = 0; /* True if any keystroke used modifiers/nav. */ + int last_was_nl = 0; /* True if last keystroke was Enter. */ while (len > 0) { if ((consumed = match_red_heart(p, len)) > 0) { @@ -380,7 +393,7 @@ int send_keys(const char *text) { int navkey; if ((consumed = match_arrow(p, len, &navkey)) > 0) { plat_send_key(ConnectedPid, navkey, 0, mods); - if (mods) had_mods = 1; + had_mods = 1; keycount++; last_was_nl = 0; mods = 0; p += consumed; len -= consumed; continue; @@ -388,7 +401,7 @@ int send_keys(const char *text) { if ((consumed = match_page_updown(p, len, &navkey)) > 0) { plat_send_key(ConnectedPid, navkey, 0, mods); - if (mods) had_mods = 1; + had_mods = 1; keycount++; last_was_nl = 0; mods = 0; p += consumed; len -= consumed; continue; @@ -435,6 +448,10 @@ int send_keys(const char *text) { p++; len--; } + /* Add newline unless: + * - Suppressed by purple heart + * - Single modified keystroke (like Ctrl+C) or bare ESC/nav key + * - Last explicit keystroke was already a newline */ if (add_newline && !(keycount == 1 && had_mods) && !last_was_nl) { usleep(50000); plat_send_key(ConnectedPid, PLAT_KEY_RETURN, 0, 0); @@ -520,6 +537,7 @@ void refresh_screenshot(int64_t chat_id, int64_t msg_id) { void handle_request(sqlite3 *db, BotRequest *br) { pthread_mutex_lock(&RequestLock); + /* Check owner. First user to message becomes owner. */ sds owner_str = kvGet(db, OWNER_KEY); int64_t owner_id = 0; @@ -529,6 +547,7 @@ void handle_request(sqlite3 *db, BotRequest *br) { } if (owner_id == 0) { + /* Register first user as owner. */ char buf[32]; snprintf(buf, sizeof(buf), "%lld", (long long)br->from); kvSet(db, OWNER_KEY, buf, 0); @@ -541,6 +560,7 @@ void handle_request(sqlite3 *db, BotRequest *br) { goto done; } + /* TOTP authentication check. */ if (!WeakSecurity) { if (!Authenticated || time(NULL) - LastActivity > OtpTimeout) { Authenticated = 0; @@ -549,6 +569,7 @@ void handle_request(sqlite3 *db, BotRequest *br) { goto done; } char *req = br->request; + /* Check if message is a 6-digit OTP code. */ int is_otp = (strlen(req) == 6); for (int i = 0; is_otp && i < 6; i++) { if (!isdigit((unsigned char)req[i])) is_otp = 0; @@ -565,6 +586,7 @@ void handle_request(sqlite3 *db, BotRequest *br) { LastActivity = time(NULL); } + /* Handle callback query (button press). */ if (br->is_callback) { botAnswerCallbackQuery(br->callback_id); if (strcmp(br->callback_data, REFRESH_DATA) == 0 && Connected) { @@ -606,6 +628,7 @@ void handle_request(sqlite3 *db, BotRequest *br) { goto done; } + /* Handle .N to connect to window N. */ if (req[0] == '.' && isdigit(req[1])) { int n = atoi(req + 1); refresh_window_list(); @@ -638,6 +661,7 @@ void handle_request(sqlite3 *db, BotRequest *br) { goto done; } + /* Not a command - send as keystrokes if connected. */ if (!Connected) { sds msg = build_list_message(); botSendMessage(br->target, msg, 0); @@ -645,6 +669,7 @@ void handle_request(sqlite3 *db, BotRequest *br) { goto done; } + /* Check window still exists. */ if (!connected_window_exists()) { disconnect(); sds msg = sdsnew("Window closed.\n\n"); @@ -656,6 +681,8 @@ void handle_request(sqlite3 *db, BotRequest *br) { goto done; } + /* Send keystrokes, then wait a bit for the terminal to react. + * Re-check the window (keystrokes like ESC+N may switch tabs). */ send_keys(req); sleep(2); @@ -689,6 +716,8 @@ int main(int argc, char **argv) { } plat_init(); + + /* TOTP setup: check/generate secret before starting the bot. */ totp_setup(dbfile); static char *triggers[] = { "*", NULL };