Files
Aeth3randClaude Opus 5.5 ca7e62ac8d Add Alvin library: input, capture, windows and vision
A cross-platform C++23 library with a platform-neutral public API and
runtime-selected backends:

- Input: Wayland virtual pointer/keyboard (Hyprland, wlroots), libei via
  the RemoteDesktop portal (GNOME, KDE), uinput, and Win32 SendInput
- Capture: ScreenCast portal + PipeWire, and Win32 GDI
- Windows: Hyprland IPC and Win32 enumeration
- Monitors via wl_output/xdg-output and EnumDisplayMonitors
- Optional OpenCV template matching (alvin::vision)

Also adds the `alvin` CLI, Catch2 unit tests, install rules with
find_package(Alvin) support, API docs (docs/API.md and the Obsidian
vault), and README instructions for both ways of consuming the library.

Windows backends are written but not yet compiled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:58:20 -04:00

499 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Alvin API Reference
Everything public lives in namespace `alvin` (vision helpers in `alvin::vision`).
Include the umbrella header for the core library:
```cpp
#include <alvin/alvin.hpp> // Input, Capture, WindowManager, Frame, keys, types
#include <alvin/vision.hpp> // optional, needs the alvin::vision target (OpenCV)
```
- [Using Alvin in your project](#using-alvin-in-your-project)
- [Error handling](#error-handling)
- [Coordinates and monitors](#coordinates-and-monitors)
- [Input](#input)
- [Keys and chords](#keys-and-chords)
- [Capture](#capture)
- [Frame](#frame)
- [Windows](#windows)
- [Vision (OpenCV)](#vision-opencv)
- [Putting it together](#putting-it-together)
---
## Using Alvin in your project
Either build Alvin as part of your project, or install it once and use `find_package`.
The README's "Using Alvin in your project" section has step-by-step instructions for both.
```cmake
add_subdirectory(external/Alvin) # option 1: build from source
# find_package(Alvin 0.1 REQUIRED) # option 2: use an installed Alvin
target_link_libraries(my_agent PRIVATE alvin::alvin) # core
target_link_libraries(my_agent PRIVATE alvin::vision) # + OpenCV helpers, if built
```
Alvin requires C++23 (it uses `std::expected`).
---
## Error handling
Alvin never throws. Every call that can fail returns a `Result<T>`, which is
`std::expected<T, alvin::Error>`. Calls with no value return `Status`
(`Result<void>`).
```cpp
struct Error {
ErrorCode code;
std::string message; // human-readable detail
};
enum class ErrorCode {
Unknown, Unsupported, NotAvailable, PermissionDenied, Cancelled,
Timeout, InvalidArgument, NotFound, Io, Protocol,
};
std::string_view toString(ErrorCode code);
```
| Code | Typical cause |
|------|---------------|
| `Unsupported` | Backend not compiled in, or the feature isn't possible on this platform |
| `NotAvailable` | Backend compiled in but its service is missing (no Wayland display, no portal interface) |
| `PermissionDenied` | `/dev/uinput` not writable, portal request denied, Windows UIPI block |
| `Cancelled` | The user dismissed a portal dialog |
| `Timeout` | No answer from a portal dialog, or no frame arrived in time |
| `NotFound` | A window or monitor that doesn't exist |
Usage patterns:
```cpp
auto input = alvin::Input::create();
if (!input) {
std::cerr << alvin::toString(input.error().code) << ": " << input.error().message << '\n';
return 1;
}
(*input)->moveTo({100, 100}); // Status; check it or ignore it
if (auto s = (*input)->click(); !s) // check a Status
std::cerr << s.error().message << '\n';
auto in = alvin::Input::create().value(); // or throw std::bad_expected_access on failure
```
---
## Coordinates and monitors
`#include <alvin/types.hpp>`
All positions are **global desktop coordinates**:
- **Linux:** logical pixels, after monitor scaling. These are the numbers `hyprctl` reports.
- **Windows:** physical pixels. Alvin makes the process per-monitor DPI aware.
```cpp
struct Point { int x, y; };
struct Size { int width, height; };
struct Rect {
int x, y, width, height;
int right() const; // x + width
int bottom() const; // y + height
Point center() const;
bool empty() const;
bool contains(Point p) const;
Rect united(const Rect& o) const; // bounding box of both
};
struct Monitor {
std::string name; // "DP-2", "\\.\DISPLAY1"
std::string description; // make/model
Rect bounds; // position and size on the desktop
Size pixelSize; // actual mode, e.g. 3840x2160
double scale; // pixelSize / logical size, e.g. 1.333
bool primary;
};
Result<std::vector<Monitor>> listMonitors();
Result<Rect> desktopBounds(); // bounding box of all monitors
```
```cpp
for (const auto& m : alvin::listMonitors().value())
std::printf("%s at %d,%d (%dx%d logical, scale %.2f)\n",
m.name.c_str(), m.bounds.x, m.bounds.y, m.bounds.width, m.bounds.height, m.scale);
```
---
## Input
`#include <alvin/input.hpp>`
### Creating
```cpp
static Result<std::unique_ptr<Input>> Input::create(const InputOptions& options = {});
struct InputOptions {
InputBackend backend = InputBackend::Auto;
std::string restoreToken; // libei only: skip the portal dialog (see restoreToken())
std::chrono::milliseconds permissionTimeout = std::chrono::minutes(2);
};
enum class InputBackend { Auto, WlrVirtual, LibEi, UInput, SendInput };
```
| Backend | Platform | Notes |
|---------|----------|-------|
| `WlrVirtual` | Hyprland, sway, wlroots | No dialog. Types any Unicode text. |
| `LibEi` | GNOME, KDE (RemoteDesktop portal) | Shows a permission dialog unless given a restore token. |
| `UInput` | Any Linux session | Needs write access to `/dev/uinput`. Takes about 2.5 s to create on Hyprland. |
| `SendInput` | Windows | Can't inject into elevated windows unless Alvin runs elevated too. |
With `Auto`, Linux tries `WlrVirtual`, then `LibEi`, then `UInput`. You can force a
backend with the environment variable `ALVIN_INPUT_BACKEND=wlr|libei|uinput` (Linux only).
Create one `Input` and keep it for the life of your program. Creation is the
expensive part (connecting to the compositor, creating devices, portal dialogs).
### Mouse
| Call | Description |
|------|-------------|
| `Status moveTo(Point p)` | Move the pointer to an absolute desktop position. |
| `Status moveBy(int dx, int dy)` | Relative motion. With `uinput`, the compositor applies pointer acceleration. |
| `Status buttonDown(MouseButton b)` / `buttonUp(b)` | Press or release a button. |
| `Status click(MouseButton b = Left, int count = 1)` | Click in place. `count = 2` double-clicks. |
| `Status clickAt(Point p, MouseButton b = Left, int count = 1)` | Move, then click. |
| `Status drag(Point from, Point to, MouseButton b = Left)` | Press at `from`, move to `to` in 10 steps, release. |
| `Status scroll(int dx, int dy)` | Wheel notches. Positive `dy` scrolls **down**, positive `dx` scrolls **right**. |
`MouseButton` is `Left`, `Right`, `Middle`, `Back` or `Forward`.
```cpp
input->clickAt({1440, 810}); // left click
input->clickAt({1440, 810}, alvin::MouseButton::Right);
input->click(alvin::MouseButton::Left, 2); // double-click in place
input->drag({100, 100}, {600, 400}); // e.g. select or move something
input->scroll(0, 3); // 3 notches down
```
### Keyboard
| Call | Description |
|------|-------------|
| `Status keyDown(Key k)` / `keyUp(Key k)` | Press or release one physical key. |
| `Status keyPress(Key k)` | Down, then up. |
| `Status chord(std::initializer_list<Key>)` | Press in order, release in reverse. |
| `Status chord(std::string_view)` | Same, from text like `"ctrl+shift+t"`. |
| `Status typeText(std::string_view utf8)` | Type text. `\n` presses Enter and `\t` presses Tab. |
```cpp
using alvin::Key;
input->keyPress(Key::Enter);
input->chord({Key::LeftCtrl, Key::C});
input->chord("ctrl+shift+t");
input->chord("super+enter");
input->typeText("Hello, wörld ✓\n");
```
`chord()` always releases every key it pressed, even when an earlier call fails,
so a key is never left held down.
**How `typeText` handles layouts:**
- **`wlr`:** builds a keymap containing exactly the characters needed, so any Unicode text works.
- **`libei`:** uses the server's keyboard layout. If the server supports libei's text capability, it sends the text directly.
- **`uinput`:** uses the layout named in `XKB_DEFAULT_LAYOUT`/`XKB_DEFAULT_VARIANT` (default `us`). Characters that aren't on that layout fail with `Unsupported` before anything is typed.
- **`sendinput`:** sends Unicode directly, so any text works.
### Other
| Call | Description |
|------|-------------|
| `InputBackend backend() const` | Which backend was chosen. |
| `std::string restoreToken() const` | libei: save this and pass it in `InputOptions::restoreToken` next run to skip the dialog. Empty for other backends. |
| `void setEventDelay(std::chrono::milliseconds)` | Pause between the down and up halves of clicks, key presses and typed characters. The default is 8 ms. Raise it for applications that miss fast input. |
| `std::string_view toString(InputBackend)` | `"wlr"`, `"libei"`, `"uinput"`, `"sendinput"`, `"auto"` |
---
## Keys and chords
`#include <alvin/keys.hpp>`
`Key` names **physical key positions** on a US QWERTY layout, the same way evdev
and Win32 scan codes do. Use `typeText()` when you want particular characters.
```
A–Z, Num0–Num9, F1–F24,
Escape, Enter, Tab, Backspace, Space,
Minus, Equal, LeftBracket, RightBracket, Backslash, Semicolon, Apostrophe, Grave, Comma, Period, Slash,
CapsLock, NumLock, ScrollLock,
LeftShift, RightShift, LeftCtrl, RightCtrl, LeftAlt, RightAlt, LeftSuper, RightSuper, Menu,
Insert, Delete, Home, End, PageUp, PageDown, Left, Right, Up, Down, PrintScreen, Pause,
Kp0–Kp9, KpDecimal, KpDivide, KpMultiply, KpSubtract, KpAdd, KpEnter,
VolumeMute, VolumeDown, VolumeUp, MediaPlayPause, MediaStop, MediaNext, MediaPrevious
```
```cpp
std::optional<Key> keyFromName(std::string_view name); // case-insensitive
std::string_view keyName(Key key);
std::optional<std::vector<Key>> parseChord(std::string_view chord);
bool isModifier(Key key);
```
Names accepted by `keyFromName` and in chord strings:
| Key | Names |
|-----|-------|
| Letters and digits | `a`…`z`, `0`…`9` |
| Function keys | `f1`…`f24` |
| Modifiers | `shift`/`lshift`, `rshift`, `ctrl`/`control`/`lctrl`, `rctrl`, `alt`/`lalt`, `ralt`/`altgr`, `super`/`win`/`meta`/`cmd`/`lsuper`, `rsuper` |
| Editing | `enter`/`return`, `esc`/`escape`, `tab`, `backspace`, `space`, `insert`, `delete`/`del` |
| Navigation | `home`, `end`, `pageup`/`pgup`, `pagedown`/`pgdn`, `left`, `right`, `up`, `down` |
| Punctuation | `minus`/`-`, `equal`/`=`, `[`, `]`, `\`, `;`, `'`, `` ` ``, `,`, `.`, `/` (or their word names) |
| Keypad | `kp0`…`kp9`, `kpdecimal`, `kpdivide`, `kpmultiply`, `kpsubtract`, `kpadd`, `kpenter` |
| Media | `mute`, `volumeup`, `volumedown`, `playpause`, `stop`, `next`, `prev`/`previous` |
| Other | `capslock`, `numlock`, `scrolllock`, `printscreen`/`print`, `pause`, `menu` |
Chords are joined with `+`, and spaces around keys are ignored. `"ctrl++"` means
Ctrl plus the `+`/`=` key.
---
## Capture
`#include <alvin/capture.hpp>`
### Creating
```cpp
static Result<std::unique_ptr<Capture>> Capture::create(const CaptureOptions& options = {});
struct CaptureOptions {
CaptureBackend backend = CaptureBackend::Auto; // Auto, Portal, Gdi
CaptureSource source = CaptureSource::Monitor; // Monitor, Window, Any
int monitorIndex = 0; // Windows: which monitor (index into listMonitors())
std::optional<WindowId> window; // Windows: capture this window directly
bool showCursor = false;
std::string restoreToken; // Portal: skip the picker (see below)
std::chrono::milliseconds permissionTimeout = std::chrono::minutes(2);
};
```
**Linux (`Portal`):** `create()` opens the desktop's share picker. On Hyprland
that's `hyprland-share-picker`. `source` controls whether the picker offers
monitors, windows or both. When the call returns, a PipeWire stream is running
in the background.
**Windows (`Gdi`):** there's no dialog. Set `monitorIndex`, or set `window` to
capture one window.
### Skipping the picker next time
```cpp
alvin::CaptureOptions opts;
opts.restoreToken = loadSavedToken(); // empty the first time
auto cap = alvin::Capture::create(opts).value();
saveToken(cap->restoreToken()); // new token each session; always re-save it
```
The `alvin` CLI does this for you and stores tokens in `~/.cache/alvin/`.
> Don't leave a picker unanswered. While it's open, xdg-desktop-portal-hyprland
> blocks every other portal request on the system, screen sharing in other
> apps included. Use `permissionTimeout` to limit how long `create()` waits.
### Getting frames
| Call | Description |
|------|-------------|
| `Result<Frame> grab(timeout = 5s)` | The most recent frame. Blocks only until the first frame exists. |
| `Result<Frame> nextFrame(timeout = 5s)` | Waits for a frame newer than the last one you received. |
| `std::string restoreToken() const` | Portal token for next time. |
| `CaptureBackend backend() const` | Which backend was chosen. |
PipeWire streams only send a frame when the screen changes, so `grab()` can
return an image that's a few seconds old if nothing has moved. That's usually
what you want. After you act (click, type), call `nextFrame()` to see the result:
```cpp
input->clickAt(button);
auto after = cap->nextFrame(std::chrono::seconds(2));
if (!after && after.error().code == alvin::ErrorCode::Timeout) {
// nothing on screen changed
}
```
A `Capture` holds a live stream, so keep one and reuse it. Don't create one per frame.
---
## Frame
`#include <alvin/frame.hpp>`
A `Frame` is an owned BGRA8 image (4 bytes per pixel: B, G, R, A, with no row
padding). It also records where it sits on the desktop.
| Member | Description |
|--------|-------------|
| `Frame(int w, int h)` | New zero-filled frame. |
| `int width()`, `int height()`, `Size size()` | Size in **pixels**. |
| `std::size_t stride()` | Bytes per row (`width * 4`). |
| `uint8_t* data()`, `row(y)`, `std::span<const uint8_t> bytes()` | Raw pixel access. |
| `Color pixel(int x, int y)` | `{r, g, b, a}`. Returns all zeros if out of bounds. |
| `Rect desktopRect()` | The area of the desktop this frame shows, in desktop coordinates. |
| `Point toDesktop(Point px)` | Frame pixel to desktop coordinate. |
| `Point fromDesktop(Point p)` | Desktop coordinate to frame pixel. |
| `Frame crop(Rect r)` | Sub-image (`r` is in frame pixels and gets clipped). `desktopRect()` is adjusted to match. |
| `int64_t timestampNs()` | Capture timestamp. |
| `Status savePpm(path)` | Save without dependencies (PPM). With vision, use `vision::saveImage` for PNG/JPG. |
**Always convert through `toDesktop()` before clicking.** On a monitor scaled
to 1.33, a 3840×2160 frame covers 2880×1620 desktop units, so frame pixels
aren't desktop coordinates:
```cpp
auto frame = cap->grab().value();
alvin::Point pixel{1920, 1080}; // something found in the image
input->clickAt(frame.toDesktop(pixel)); // correct on any scale
```
```cpp
alvin::Color c = frame.pixel(10, 10);
if (c.r > 200 && c.g < 50 && c.b < 50) { /* red */ }
```
---
## Windows
`#include <alvin/window.hpp>`
Wayland hides other windows from normal clients, so each compositor needs its
own backend. Right now that's Hyprland (IPC socket) and Win32.
```cpp
static Result<std::unique_ptr<WindowManager>> WindowManager::create(WindowBackend b = WindowBackend::Auto);
struct WindowInfo {
WindowId id; // opaque handle; pass it to focus()
std::string title;
std::string appId; // Hyprland class / Win32 executable name
Rect bounds; // desktop coordinates
int64_t pid;
bool focused, visible, minimized, fullscreen;
std::string workspace; // Hyprland workspace name
};
```
| Call | Description |
|------|-------------|
| `Result<std::vector<WindowInfo>> list()` | All mapped top-level windows. |
| `Result<std::optional<WindowInfo>> active()` | The focused window, or none. |
| `Result<std::optional<WindowInfo>> find(std::string_view text)` | First window whose title or appId contains `text` (case-insensitive). |
| `Status focus(WindowId id)` | Focus it. On Hyprland this switches to its workspace. |
```cpp
auto wm = alvin::WindowManager::create().value();
if (auto w = wm->find("firefox").value()) {
wm->focus(w->id);
input->clickAt(w->bounds.center());
// click 40px in from the top-left of the window
input->clickAt({w->bounds.x + 40, w->bounds.y + 40});
}
```
---
## Vision (OpenCV)
`#include <alvin/vision.hpp>`, link `alvin::vision`.
| Call | Description |
|------|-------------|
| `cv::Mat view(const Frame&)` | Zero-copy `CV_8UC4` view. The Frame must outlive the Mat. |
| `cv::Mat toBgr(const Frame&)` | Deep copy as `CV_8UC3`, the layout most OpenCV code expects. |
| `Result<Frame> fromMat(const cv::Mat&)` | 8-bit gray, BGR or BGRA to a Frame. |
| `Result<cv::Mat> loadImage(path)` | Load a PNG/JPG as BGR. |
| `Status saveImage(const Frame&, path)` | Save as PNG/JPG/… (by extension). |
| `Result<std::optional<Match>> find(frame, needle, opts)` | Best template match at or above the threshold. |
| `Result<std::vector<Match>> findAll(frame, needle, opts)` | Every non-overlapping match, best first. |
```cpp
struct Match { Rect rect; double score; }; // rect in FRAME PIXELS, score 0..1
struct MatchOptions {
double threshold = 0.9; // lower it (0.7–0.85) if the target is anti-aliased or slightly different
bool grayscale = false; // faster; ignores colour
int maxResults = 16; // findAll only
};
```
Take template images from Alvin's own captures, e.g. `alvin capture` followed by
a crop. That keeps the scale and colours identical, which template matching needs.
```cpp
auto needle = alvin::vision::loadImage("save_button.png").value();
auto frame = cap->grab().value();
if (auto hit = alvin::vision::find(frame, needle, {.threshold = 0.85}).value())
input->clickAt(frame.toDesktop(hit->rect.center()));
for (const auto& m : alvin::vision::findAll(frame, needle, {.threshold = 0.8}).value())
std::printf("match at %d,%d score %.2f\n", m.rect.x, m.rect.y, m.score);
```
For anything else OpenCV can do, use `vision::view(frame)` to get a `cv::Mat`.
---
## Putting it together
A small agent loop: wait for a button to appear, click it, then check that the
screen changed.
```cpp
#include <alvin/alvin.hpp>
#include <alvin/vision.hpp>
#include <chrono>
#include <iostream>
#include <thread>
using namespace std::chrono_literals;
int main() {
auto input = alvin::Input::create();
if (!input) { std::cerr << input.error().message << '\n'; return 1; }
auto cap = alvin::Capture::create(); // the picker appears on first run
if (!cap) { std::cerr << cap.error().message << '\n'; return 1; }
auto button = alvin::vision::loadImage("accept.png");
if (!button) { std::cerr << button.error().message << '\n'; return 1; }
for (int attempt = 0; attempt < 30; ++attempt) {
auto frame = (*cap)->grab();
if (!frame) { std::cerr << frame.error().message << '\n'; return 1; }
auto hit = alvin::vision::find(*frame, *button, {.threshold = 0.85});
if (hit && *hit) {
(void)(*input)->clickAt(frame->toDesktop((*hit)->rect.center()));
if ((*cap)->nextFrame(2s)) std::cout << "clicked; screen updated\n";
return 0;
}
std::this_thread::sleep_for(1s);
}
std::cerr << "button never appeared\n";
return 1;
}
```
See [`examples/alvin_cli.cpp`](../examples/alvin_cli.cpp) for a complete program
that uses every part of the API.