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..aa95e0c 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: ``` @@ -89,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 @@ -109,9 +120,57 @@ 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. + +### 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 (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 + +- **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..6a17196 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. */ @@ -71,7 +43,7 @@ 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 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. */ @@ -178,8 +150,7 @@ static const char *bytes_to_hex(const unsigned char *data, int len) { } /* 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. */ + * Returns 1 on success, 0 on error/weak-security. */ static int totp_setup(const char *db_path) { if (WeakSecurity) return 0; @@ -294,6 +265,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); @@ -305,18 +310,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 +321,20 @@ 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. */ + * the platform layer updates 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,170 +343,20 @@ 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); @@ -633,15 +365,14 @@ int send_keys(const 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 had_mods = 0; /* True if any keystroke used modifiers/nav. */ int last_was_nl = 0; /* True if last keystroke was Enter. */ while (len > 0) { @@ -652,16 +383,33 @@ 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; continue; } + int navkey; + if ((consumed = match_arrow(p, len, &navkey)) > 0) { + plat_send_key(ConnectedPid, navkey, 0, 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); + 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') { - 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,25 +424,25 @@ 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--; @@ -702,11 +450,11 @@ int send_keys(const char *text) { /* Add newline unless: * - Suppressed by purple heart - * - Single modified keystroke (like Ctrl+C) or bare ESC + * - 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); - send_key(ConnectedPid, kVK_Return, 0, 0); + plat_send_key(ConnectedPid, PLAT_KEY_RETURN, 0, 0); } return 0; @@ -716,7 +464,6 @@ int send_keys(const char *text) { * Bot Command Handlers * ========================================================================= */ -/* Build the .list response. */ sds build_list_message(void) { refresh_window_list(); @@ -731,9 +478,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 +496,20 @@ 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" + "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)" ); @@ -761,16 +521,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); @@ -802,7 +560,7 @@ void handle_request(sqlite3 *db, BotRequest *br) { goto done; } - /* TOTP authentication check (applies to both messages and callbacks). */ + /* TOTP authentication check. */ if (!WeakSecurity) { if (!Authenticated || time(NULL) - LastActivity > OtpTimeout) { Authenticated = 0; @@ -839,7 +597,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 +605,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 +612,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++; @@ -883,7 +638,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,8 +656,7 @@ 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; } @@ -928,11 +681,10 @@ void handle_request(sqlite3 *db, BotRequest *br) { goto done; } - /* Send keystrokes. */ + /* 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); - /* 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 +702,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 +715,11 @@ int main(int argc, char **argv) { } } + plat_init(); + /* TOTP setup: check/generate secret before starting the bot. */ 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..c875434 --- /dev/null +++ b/platform.h @@ -0,0 +1,71 @@ +/* + * 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 +#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 { + 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..c1b7d74 --- /dev/null +++ b/platform_linux.c @@ -0,0 +1,401 @@ +/* + * 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 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; + 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..e150a46 --- /dev/null +++ b/platform_macos.c @@ -0,0 +1,306 @@ +/* + * 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; + 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) { + 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__ */