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>
263 lines
9.1 KiB
C++
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);
|
|
}
|