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

263 lines
9.1 KiB
C++

// `alvin` - command-line front end to the library, handy for trying backends
// and scripting simple automations.
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <span>
#include <string>
#include <vector>
#include "alvin/alvin.hpp"
#ifdef ALVIN_CLI_VISION
#include "alvin/vision.hpp"
#endif
namespace fs = std::filesystem;
using namespace alvin;
namespace {
void usage() {
std::cerr <<
R"(usage: alvin [--input wlr|libei|uinput|sendinput] <command> [args]
monitors list monitors (logical desktop coordinates)
windows list top-level windows
active show the focused window
focus <text> focus the first window whose title/class contains <text>
move <x> <y> move the pointer to desktop coordinates
moveby <dx> <dy> move the pointer relatively
click [left|right|middle] [n] click n times (default: left, 1)
clickat <x> <y> [button] move, then click
scroll <dy> [dx] scroll wheel notches (positive = down/right)
key <chord> press a chord, e.g. ctrl+shift+t
type <text> type UTF-8 text
capture <out.ppm|png> [--window] [--cursor]
save one frame; the portal token is cached so
the picker only appears the first time
)"
#ifdef ALVIN_CLI_VISION
R"( find <template.png> [threshold]
locate an image on screen, print desktop coords
)"
#endif
;
}
int report(const Error& e) {
std::cerr << "alvin: " << toString(e.code) << ": " << e.message << '\n';
return 1;
}
int report(const Status& s) { return s ? 0 : report(s.error()); }
std::optional<int> toInt(const std::string& s) {
try {
std::size_t used = 0;
int v = std::stoi(s, &used);
if (used == s.size()) return v;
} catch (...) {
}
return std::nullopt;
}
std::optional<MouseButton> toButton(const std::string& s) {
if (s == "left") return MouseButton::Left;
if (s == "right") return MouseButton::Right;
if (s == "middle") return MouseButton::Middle;
if (s == "back") return MouseButton::Back;
if (s == "forward") return MouseButton::Forward;
return std::nullopt;
}
fs::path tokenFile(const char* name) {
const char* cache = std::getenv("XDG_CACHE_HOME");
const char* home = std::getenv("HOME");
fs::path dir = cache && *cache ? fs::path(cache) : fs::path(home ? home : ".") / ".cache";
return dir / "alvin" / name;
}
std::string readToken(const fs::path& p) {
std::ifstream f(p);
std::string t;
std::getline(f, t);
return t;
}
void writeToken(const fs::path& p, const std::string& token) {
if (token.empty()) return;
std::error_code ec;
fs::create_directories(p.parent_path(), ec);
std::ofstream(p) << token << '\n';
}
void printWindow(const WindowInfo& w) {
std::printf("%#llx %5d,%-5d %5dx%-5d %s%s[%s] %s\n", static_cast<unsigned long long>(w.id.value), w.bounds.x,
w.bounds.y, w.bounds.width, w.bounds.height, w.focused ? "* " : " ", w.minimized ? "(min) " : "",
w.appId.c_str(), w.title.c_str());
}
Result<std::unique_ptr<Input>> makeInput(const InputOptions& base) {
InputOptions opts = base;
fs::path tf = tokenFile("remotedesktop.token");
opts.restoreToken = readToken(tf);
auto in = Input::create(opts);
if (in) {
writeToken(tf, (*in)->restoreToken());
if (std::getenv("ALVIN_VERBOSE")) std::cerr << "input backend: " << toString((*in)->backend()) << '\n';
}
return in;
}
Result<Frame> captureFrame(bool window, bool cursor) {
CaptureOptions opts;
opts.source = window ? CaptureSource::Window : CaptureSource::Monitor;
opts.showCursor = cursor;
fs::path tf = tokenFile(window ? "screencast-window.token" : "screencast-monitor.token");
opts.restoreToken = readToken(tf);
auto cap = Capture::create(opts);
if (!cap) return std::unexpected(cap.error());
writeToken(tf, (*cap)->restoreToken());
return (*cap)->grab();
}
int run(std::span<const std::string> args, const InputOptions& inputOpts) {
const std::string& cmd = args[0];
auto arg = [&](std::size_t i) -> const std::string& {
static const std::string empty;
return i < args.size() ? args[i] : empty;
};
if (cmd == "monitors") {
auto mons = listMonitors();
if (!mons) return report(mons.error());
for (const auto& m : *mons)
std::printf("%-10s %5d,%-5d %5dx%-5d pixels %dx%d scale %.3g%s %s\n", m.name.c_str(), m.bounds.x,
m.bounds.y, m.bounds.width, m.bounds.height, m.pixelSize.width, m.pixelSize.height, m.scale,
m.primary ? " (primary)" : "", m.description.c_str());
return 0;
}
if (cmd == "windows" || cmd == "active" || cmd == "focus") {
auto wm = WindowManager::create();
if (!wm) return report(wm.error());
if (cmd == "windows") {
auto list = (*wm)->list();
if (!list) return report(list.error());
for (const auto& w : *list) printWindow(w);
return 0;
}
if (cmd == "active") {
auto a = (*wm)->active();
if (!a) return report(a.error());
if (*a) printWindow(**a);
return 0;
}
auto w = (*wm)->find(arg(1));
if (!w) return report(w.error());
if (!*w) return report(Error{ErrorCode::NotFound, "no window matches '" + arg(1) + "'"});
return report((*wm)->focus((*w)->id));
}
if (cmd == "capture") {
if (arg(1).empty()) return usage(), 2;
bool window = false, cursor = false;
for (std::size_t i = 2; i < args.size(); ++i) {
if (args[i] == "--window") window = true;
else if (args[i] == "--cursor") cursor = true;
}
auto frame = captureFrame(window, cursor);
if (!frame) return report(frame.error());
fs::path out = arg(1);
Status saved;
#ifdef ALVIN_CLI_VISION
if (out.extension() != ".ppm") saved = vision::saveImage(*frame, out);
else
#endif
saved = frame->savePpm(out);
if (!saved) return report(saved);
Rect r = frame->desktopRect();
std::printf("%s: %dx%d pixels, desktop rect %d,%d %dx%d\n", out.c_str(), frame->width(), frame->height(), r.x,
r.y, r.width, r.height);
return 0;
}
#ifdef ALVIN_CLI_VISION
if (cmd == "find") {
auto tpl = vision::loadImage(arg(1));
if (!tpl) return report(tpl.error());
vision::MatchOptions mo;
if (!arg(2).empty()) mo.threshold = std::stod(arg(2));
auto frame = captureFrame(false, false);
if (!frame) return report(frame.error());
auto match = vision::find(*frame, *tpl, mo);
if (!match) return report(match.error());
if (!*match) {
std::printf("not found\n");
return 1;
}
Point c = frame->toDesktop((*match)->rect.center());
std::printf("%d %d (score %.3f)\n", c.x, c.y, (*match)->score);
return 0;
}
#endif
// Everything below needs an input backend.
auto input = makeInput(inputOpts);
if (!input) return report(input.error());
Input& in = **input;
if (cmd == "move" || cmd == "moveby" || cmd == "clickat") {
auto x = toInt(arg(1)), y = toInt(arg(2));
if (!x || !y) return usage(), 2;
if (cmd == "moveby") return report(in.moveBy(*x, *y));
if (cmd == "move") return report(in.moveTo({*x, *y}));
auto b = arg(3).empty() ? std::optional{MouseButton::Left} : toButton(arg(3));
if (!b) return usage(), 2;
return report(in.clickAt({*x, *y}, *b));
}
if (cmd == "click") {
auto b = arg(1).empty() ? std::optional{MouseButton::Left} : toButton(arg(1));
auto n = arg(2).empty() ? std::optional{1} : toInt(arg(2));
if (!b || !n) return usage(), 2;
return report(in.click(*b, *n));
}
if (cmd == "scroll") {
auto dy = toInt(arg(1));
auto dx = arg(2).empty() ? std::optional{0} : toInt(arg(2));
if (!dy || !dx) return usage(), 2;
return report(in.scroll(*dx, *dy));
}
if (cmd == "key") return report(in.chord(std::string_view(arg(1))));
if (cmd == "type") return report(in.typeText(arg(1)));
usage();
return 2;
}
} // namespace
int main(int argc, char** argv) {
std::vector<std::string> args(argv + 1, argv + argc);
InputOptions inputOpts;
if (args.size() >= 2 && args[0] == "--input") {
const std::string& b = args[1];
if (b == "wlr") inputOpts.backend = InputBackend::WlrVirtual;
else if (b == "libei") inputOpts.backend = InputBackend::LibEi;
else if (b == "uinput") inputOpts.backend = InputBackend::UInput;
else if (b == "sendinput") inputOpts.backend = InputBackend::SendInput;
else return usage(), 2;
args.erase(args.begin(), args.begin() + 2);
}
if (args.empty() || args[0] == "-h" || args[0] == "--help") {
usage();
return args.empty() ? 2 : 0;
}
return run(args, inputOpts);
}