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>
This commit is contained in:
Aeth3randClaude Opus 5.5 committed 2026-09-27 13:58:20 -04:00
1 parent 76ff506fd0
commit ca7e62ac8d
59 files changed
+6515 -1

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
build/
.cache/
# Obsidian per-session UI state
AlvinDoc/.obsidian/workspace.json
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+33
View File
@@ -0,0 +1,33 @@
{
"file-explorer": true,
"global-search": true,
"switcher": true,
"graph": true,
"backlink": true,
"canvas": true,
"outgoing-link": true,
"tag-pane": true,
"footnotes": false,
"properties": true,
"page-preview": true,
"daily-notes": true,
"templates": true,
"note-composer": true,
"command-palette": true,
"slash-command": false,
"editor-status": true,
"bookmarks": true,
"markdown-importer": false,
"zk-prefixer": false,
"random-note": false,
"outline": true,
"word-count": true,
"slides": false,
"audio-recorder": false,
"workspaces": false,
"file-recovery": true,
"publish": false,
"sync": true,
"bases": true,
"webviewer": false
}
+22
View File
@@ -0,0 +1,22 @@
{
"collapse-filter": true,
"search": "",
"showTags": false,
"showAttachments": false,
"hideUnresolved": false,
"showOrphans": true,
"collapse-color-groups": true,
"colorGroups": [],
"collapse-display": true,
"showArrow": false,
"textFadeMultiplier": 0,
"nodeSizeMultiplier": 1,
"lineSizeMultiplier": 1,
"collapse-forces": true,
"centerStrength": 0.518713248970312,
"repelStrength": 10,
"linkStrength": 1,
"linkDistance": 250,
"scale": 1,
"close": true
}
+498
View File
@@ -0,0 +1,498 @@
# 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]]
- [[#Error handling]]
- [[#Coordinates and monitors]]
- [[#Input]]
- [[#Keys and chords]]
- [[#Capture]]
- [[#Frame]]
- [[#Windows]]
- [[#Vision (OpenCV)]]
- [[#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` in the repo for a complete program
that uses every part of the API.
+5
View File
@@ -0,0 +1,5 @@
# Linux
[[Libs#[libei](https //gitlab.freedesktop.org/libinput/libei) - linux input|libei]]
# Windows
[[Libs#SendInput Win32 API - windows input|SendInput]]
+10
View File
@@ -0,0 +1,10 @@
## [libei](https://gitlab.freedesktop.org/libinput/libei) - linux input
## SendInput Win32 API - windows input
# Dear ImGui
# Pipewire (libpipewire)
# xdg-desktop-portal (maybe)
# [OpenCV](https://opencv.org/)
+15
View File
@@ -0,0 +1,15 @@
# Screen Capture & Computer Vision (Decision Making)
To "see" the screen and make complex logical evaluations, combine low-level frame grabbers with computer vision wrappers.
**Linux Capture (Wayland/Hyprland):** Implement **PipeWire** or utilize the **xdg-desktop-portal (ScreenCast)** API. Unprivileged apps cannot take screenshots directly in Wayland, so you must stream frames via PipeWire. [[1](https://www.reddit.com/r/kde/comments/113wt14/wayland_input_emulation_org_kde_kwin_fake_input/), [2](https://github.com/can1357/oh-my-pi/issues/7886)]
**Cross-Platform Vision Processing:** **[OpenCV (C++)](https://opencv.org/)** is the industry standard for processing raw frame arrays. It allows for image template matching (e.g., finding a specific button on screen), color analysis, and image thresholding. [[1](https://www.reddit.com/r/hyprland/comments/1rwxfae/i_built_a_scrolling_longscreenshot_tool_for/)]
**Text Recognition (OCR):** Integrate **[Tesseract OCR](https://github.com/tesseract-ocr/tesseract)** if the bot needs to read on-screen text, menus, or labels to inform its choices.
# Cross-Platform Window Management & Coordinates
A major pitfall of Wayland automation is that clients do not know where other windows are located on the global coordinate space. [[1](https://github.com/developer239/robot-cpp)]
- **Linux (Hyprland IPC):** Use **`hyprctl`** JSON sockets or the **`hyprland-ipc`** protocol to query window positions, bounding boxes, and active window titles. Because this mechanism is compositor-dependent, implement it cleanly under a modular interface.
- **Windows/macOS:** Use Win32 (`GetWindowRect`, `GetForegroundWindow`) and the macOS `CGWindowListCopyWindowInfo` API respectively to achieve identical window tracking capabilities on alternative platforms.
+259
View File
@@ -0,0 +1,259 @@
cmake_minimum_required(VERSION 3.24)
project(Alvin
VERSION 0.1.0
DESCRIPTION "Agent Leveraging Vision and Input: cross-platform input synthesis and screen capture"
LANGUAGES C CXX) # C only for wayland-scanner output
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
include(GNUInstallDirs)
# ---------------------------------------------------------------------------
# Options. "AUTO" enables a backend when its dependencies are found.
# ---------------------------------------------------------------------------
set(ALVIN_WITH_WAYLAND AUTO CACHE STRING "Wayland virtual pointer/keyboard input + monitor listing (ON/OFF/AUTO)")
set(ALVIN_WITH_LIBEI AUTO CACHE STRING "libei input via the RemoteDesktop portal (ON/OFF/AUTO)")
set(ALVIN_WITH_PORTAL AUTO CACHE STRING "ScreenCast portal + PipeWire capture (ON/OFF/AUTO)")
set(ALVIN_WITH_UINPUT ON CACHE BOOL "/dev/uinput input backend")
set(ALVIN_WITH_VISION AUTO CACHE STRING "OpenCV-based alvin::vision library (ON/OFF/AUTO)")
option(ALVIN_BUILD_EXAMPLES "Build the alvin command-line tool" ON)
option(ALVIN_BUILD_TESTS "Build unit tests" ${PROJECT_IS_TOP_LEVEL})
# Resolves an AUTO/ON/OFF option against whether its dependencies were found.
function(alvin_resolve option found)
if(${option} STREQUAL "AUTO")
set(${option}_ENABLED ${found} PARENT_SCOPE)
elseif(${option})
if(NOT ${found})
message(FATAL_ERROR "${option}=ON but its dependencies were not found")
endif()
set(${option}_ENABLED TRUE PARENT_SCOPE)
else()
set(${option}_ENABLED FALSE PARENT_SCOPE)
endif()
endfunction()
add_library(alvin
src/common/core.cpp
src/common/frame.cpp
src/common/json.cpp
src/common/keys.cpp
)
add_library(alvin::alvin ALIAS alvin)
target_include_directories(alvin
PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src)
target_compile_features(alvin PUBLIC cxx_std_23)
if(MSVC)
target_compile_options(alvin PRIVATE /W4 /utf-8)
else()
target_compile_options(alvin PRIVATE -Wall -Wextra -Wpedantic)
endif()
if(WIN32)
# No export macros in the headers; let CMake export every symbol from the DLL.
set_target_properties(alvin PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON)
endif()
set(ALVIN_ENABLED_BACKENDS "")
# Links a pkg-config dependency privately and records how AlvinConfig.cmake can
# find it again: a static libalvin carries no record of its own dependencies.
set(ALVIN_CONFIG_DEPENDENCIES "")
macro(alvin_private_pkg var)
target_link_libraries(alvin PRIVATE PkgConfig::${var})
string(APPEND ALVIN_CONFIG_DEPENDENCIES " pkg_check_modules(${var} REQUIRED QUIET IMPORTED_TARGET ${ARGN})\n")
endmacro()
# ---------------------------------------------------------------------------
# Linux
# ---------------------------------------------------------------------------
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
find_package(PkgConfig REQUIRED)
pkg_check_modules(XKBCOMMON REQUIRED IMPORTED_TARGET xkbcommon)
pkg_check_modules(WAYLAND IMPORTED_TARGET wayland-client)
pkg_check_modules(WAYLAND_PROTOCOLS wayland-protocols)
find_program(WAYLAND_SCANNER wayland-scanner)
pkg_check_modules(GIO IMPORTED_TARGET gio-2.0 gio-unix-2.0)
pkg_check_modules(PIPEWIRE IMPORTED_TARGET libpipewire-0.3)
pkg_check_modules(LIBEI IMPORTED_TARGET libei-1.0)
set(_wayland_found FALSE)
if(WAYLAND_FOUND AND WAYLAND_PROTOCOLS_FOUND AND WAYLAND_SCANNER)
set(_wayland_found TRUE)
endif()
set(_portal_found FALSE)
if(GIO_FOUND AND PIPEWIRE_FOUND)
set(_portal_found TRUE)
endif()
set(_libei_found FALSE)
if(GIO_FOUND AND LIBEI_FOUND)
set(_libei_found TRUE)
endif()
alvin_resolve(ALVIN_WITH_WAYLAND _wayland_found)
alvin_resolve(ALVIN_WITH_PORTAL _portal_found)
alvin_resolve(ALVIN_WITH_LIBEI _libei_found)
target_sources(alvin PRIVATE
src/linux/platform_linux.cpp
src/linux/hyprland_windows.cpp
src/linux/keycodes.cpp
src/linux/xkb.cpp)
alvin_private_pkg(XKBCOMMON xkbcommon)
if(ALVIN_WITH_WAYLAND_ENABLED)
pkg_get_variable(WAYLAND_PROTOCOLS_DIR wayland-protocols pkgdatadir)
set(_proto_out ${CMAKE_CURRENT_BINARY_DIR}/protocols)
file(MAKE_DIRECTORY ${_proto_out})
set(_protocols
${CMAKE_CURRENT_SOURCE_DIR}/protocols/wlr-virtual-pointer-unstable-v1.xml
${CMAKE_CURRENT_SOURCE_DIR}/protocols/virtual-keyboard-unstable-v1.xml
${WAYLAND_PROTOCOLS_DIR}/unstable/xdg-output/xdg-output-unstable-v1.xml)
foreach(xml ${_protocols})
get_filename_component(name ${xml} NAME_WE)
add_custom_command(
OUTPUT ${_proto_out}/${name}-client-protocol.h ${_proto_out}/${name}-protocol.c
COMMAND ${WAYLAND_SCANNER} client-header ${xml} ${_proto_out}/${name}-client-protocol.h
COMMAND ${WAYLAND_SCANNER} private-code ${xml} ${_proto_out}/${name}-protocol.c
DEPENDS ${xml}
VERBATIM)
list(APPEND _proto_sources ${_proto_out}/${name}-protocol.c)
list(APPEND _proto_headers ${_proto_out}/${name}-client-protocol.h)
endforeach()
# wayland-scanner emits C; build it as a small C object library.
add_library(alvin_wayland_protocols OBJECT ${_proto_sources} ${_proto_headers})
target_include_directories(alvin_wayland_protocols PUBLIC ${_proto_out})
target_link_libraries(alvin_wayland_protocols PUBLIC PkgConfig::WAYLAND)
set_target_properties(alvin_wayland_protocols PROPERTIES POSITION_INDEPENDENT_CODE ON)
target_sources(alvin PRIVATE
src/linux/wayland.cpp
src/linux/wlr_input.cpp
$<TARGET_OBJECTS:alvin_wayland_protocols>)
target_include_directories(alvin PRIVATE ${_proto_out})
alvin_private_pkg(WAYLAND wayland-client)
add_dependencies(alvin alvin_wayland_protocols)
target_compile_definitions(alvin PRIVATE ALVIN_HAVE_WAYLAND)
list(APPEND ALVIN_ENABLED_BACKENDS "wlr-input" "wayland-monitors")
endif()
if(ALVIN_WITH_PORTAL_ENABLED OR ALVIN_WITH_LIBEI_ENABLED)
target_sources(alvin PRIVATE src/linux/portal.cpp)
alvin_private_pkg(GIO gio-2.0 gio-unix-2.0)
endif()
if(ALVIN_WITH_PORTAL_ENABLED)
target_sources(alvin PRIVATE src/linux/pipewire_capture.cpp)
alvin_private_pkg(PIPEWIRE libpipewire-0.3)
target_compile_definitions(alvin PRIVATE ALVIN_HAVE_PORTAL)
list(APPEND ALVIN_ENABLED_BACKENDS "portal-capture")
endif()
if(ALVIN_WITH_LIBEI_ENABLED)
target_sources(alvin PRIVATE src/linux/libei_input.cpp)
alvin_private_pkg(LIBEI libei-1.0)
target_compile_definitions(alvin PRIVATE ALVIN_HAVE_LIBEI)
if(LIBEI_VERSION VERSION_GREATER_EQUAL 1.6)
target_compile_definitions(alvin PRIVATE ALVIN_LIBEI_HAS_TEXT)
endif()
list(APPEND ALVIN_ENABLED_BACKENDS "libei-input")
endif()
if(ALVIN_WITH_UINPUT)
target_sources(alvin PRIVATE src/linux/uinput_input.cpp)
target_compile_definitions(alvin PRIVATE ALVIN_HAVE_UINPUT)
list(APPEND ALVIN_ENABLED_BACKENDS "uinput-input")
endif()
list(APPEND ALVIN_ENABLED_BACKENDS "hyprland-windows")
# ---------------------------------------------------------------------------
# Windows
# ---------------------------------------------------------------------------
elseif(WIN32)
target_sources(alvin PRIVATE
src/windows/platform_windows.cpp
src/windows/sendinput_input.cpp
src/windows/gdi_capture.cpp
src/windows/win32_windows.cpp)
target_compile_definitions(alvin PRIVATE UNICODE _UNICODE)
target_link_libraries(alvin PRIVATE user32 gdi32 dwmapi)
list(APPEND ALVIN_ENABLED_BACKENDS "sendinput-input" "gdi-capture" "win32-windows")
else()
message(FATAL_ERROR "Alvin does not support ${CMAKE_SYSTEM_NAME} yet")
endif()
# ---------------------------------------------------------------------------
# Optional OpenCV integration
# ---------------------------------------------------------------------------
find_package(OpenCV QUIET COMPONENTS core imgproc imgcodecs)
alvin_resolve(ALVIN_WITH_VISION OpenCV_FOUND)
if(ALVIN_WITH_VISION_ENABLED)
add_library(alvin_vision src/vision/vision.cpp)
add_library(alvin::vision ALIAS alvin_vision)
target_link_libraries(alvin_vision PUBLIC alvin ${OpenCV_LIBS})
set_target_properties(alvin_vision PROPERTIES EXPORT_NAME vision)
if(WIN32)
set_target_properties(alvin_vision PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON)
endif()
list(APPEND ALVIN_ENABLED_BACKENDS "vision(OpenCV ${OpenCV_VERSION})")
endif()
message(STATUS "Alvin backends: ${ALVIN_ENABLED_BACKENDS}")
if(ALVIN_BUILD_EXAMPLES)
add_subdirectory(examples)
endif()
if(ALVIN_BUILD_TESTS)
enable_testing()
add_subdirectory(tests)
endif()
# ---------------------------------------------------------------------------
# Install + find_package(Alvin) support
# ---------------------------------------------------------------------------
include(CMakePackageConfigHelpers)
set(ALVIN_CMAKE_DIR ${CMAKE_INSTALL_LIBDIR}/cmake/Alvin)
set(_alvin_install_targets alvin)
if(ALVIN_WITH_VISION_ENABLED)
list(APPEND _alvin_install_targets alvin_vision)
endif()
install(TARGETS ${_alvin_install_targets} EXPORT AlvinTargets
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
install(EXPORT AlvinTargets NAMESPACE alvin:: DESTINATION ${ALVIN_CMAKE_DIR})
# Values baked into AlvinConfig.cmake.
get_target_property(_alvin_type alvin TYPE)
if(_alvin_type STREQUAL "STATIC_LIBRARY" AND NOT ALVIN_CONFIG_DEPENDENCIES STREQUAL "")
set(ALVIN_NEEDS_PKGCONFIG TRUE)
else()
set(ALVIN_NEEDS_PKGCONFIG FALSE)
endif()
if(ALVIN_WITH_VISION_ENABLED)
set(ALVIN_HAS_VISION TRUE)
else()
set(ALVIN_HAS_VISION FALSE)
endif()
configure_package_config_file(cmake/AlvinConfig.cmake.in
${CMAKE_CURRENT_BINARY_DIR}/AlvinConfig.cmake
INSTALL_DESTINATION ${ALVIN_CMAKE_DIR})
write_basic_package_version_file(${CMAKE_CURRENT_BINARY_DIR}/AlvinConfigVersion.cmake
COMPATIBILITY SameMinorVersion) # pre-1.0: minor versions may break the API
install(FILES
${CMAKE_CURRENT_BINARY_DIR}/AlvinConfig.cmake
${CMAKE_CURRENT_BINARY_DIR}/AlvinConfigVersion.cmake
DESTINATION ${ALVIN_CMAKE_DIR})
+252 -1
View File
@@ -1,3 +1,254 @@
# Alvin # Alvin
Agent Leveraging Vision and Input. Nice. Agent Leveraging Vision and Input. Nice.
A cross-platform C++23 library that lets a program **see the screen** (capture monitors or windows),
**act on it** (synthesize mouse and keyboard input), and **find things on it** (optional OpenCV template
matching). All of this sits behind one platform-neutral API, with swappable backends for each desktop.
```cpp
#include <alvin/alvin.hpp>
#include <alvin/vision.hpp>
auto input = alvin::Input::create().value();
auto screen = alvin::Capture::create().value(); // portal picker appears the first time only
auto frame = screen->grab().value();
auto button = alvin::vision::loadImage("ok_button.png").value();
if (auto hit = alvin::vision::find(frame, button).value())
input->clickAt(frame.toDesktop(hit->rect.center()));
input->chord("ctrl+s");
input->typeText("héllo wörld ✓");
```
**Full API reference: [docs/API.md](docs/API.md)**
## Layout
```
include/alvin/ public API (the only headers users include)
input.hpp Input: moveTo/moveBy/click/drag/scroll/keyDown/keyUp/chord/typeText
capture.hpp Capture: grab() latest frame, nextFrame() wait for a new one
frame.hpp Frame: BGRA pixels + where they sit on the desktop
window.hpp WindowManager: list/active/find/focus top-level windows
types.hpp Point/Rect/Monitor, listMonitors(), desktopBounds()
keys.hpp Key enum (physical keys), MouseButton, chord parsing
vision.hpp optional OpenCV helpers (alvin::vision target)
src/common/ platform-neutral code + the internal platform interface
src/linux/ Linux backends
src/windows/ Windows backends
src/vision/ OpenCV integration
protocols/ vendored Wayland protocol XML
examples/ the `alvin` CLI
tests/ Catch2 unit tests
```
Every public type is platform-neutral; backend headers (libei, PipeWire, Wayland, Win32) never leak
into `include/`. Each platform implements four entry points in `src/common/platform.hpp`
(`createInput`, `createCapture`, `createWindowManager`, `listMonitors`), which pick a backend at runtime.
## Backends
| Area | Backend | Where it works | Status |
|---------|---------|----------------|--------|
| Input | `wlr` — `zwlr_virtual_pointer_v1` + `zwp_virtual_keyboard_v1` | Hyprland, sway, wlroots compositors. No dialog. | Tested on Hyprland (motion, multi-monitor, fractional scale) |
| Input | `libei` — via the RemoteDesktop portal | GNOME, KDE Plasma | Builds; untested (Hyprland's portal has no RemoteDesktop) |
| Input | `uinput` — kernel virtual devices | Any Linux session incl. X11/TTY; needs write access to `/dev/uinput` | Tested on Hyprland (absolute + relative motion) |
| Input | `sendinput` — Win32 `SendInput` | Windows | Written; not yet compiled |
| Capture | `portal` — ScreenCast portal + PipeWire (SHM buffers) | Any modern Wayland desktop | Portal handshake tested; frame path awaiting first run |
| Capture | `gdi` — BitBlt / PrintWindow | Windows | Written; not yet compiled |
| Windows | `hyprland` — IPC socket | Hyprland | Tested |
| Windows | `win32` — EnumWindows + DWM | Windows | Written; not yet compiled |
With `InputBackend::Auto`, Linux tries `wlr` → `libei` → `uinput`. Override with
`ALVIN_INPUT_BACKEND=wlr|libei|uinput` or `InputOptions::backend`.
### Things worth knowing
- **Coordinates** are global desktop coordinates in logical pixels on Linux (what `hyprctl` reports)
and physical pixels on Windows (Alvin makes the process per-monitor DPI aware). A `Frame` from a
scaled monitor has more pixels than its logical size; use `frame.toDesktop()` / `fromDesktop()`.
- **Portal restore tokens**: `Capture::restoreToken()` / `Input::restoreToken()` return a token; pass
it back in the options next run to skip the picker. The CLI caches them in `~/.cache/alvin/`.
- **Typing text**: `wlr` uploads a keymap containing exactly the characters needed, so any Unicode
text works regardless of layout. `uinput` and `libei` go through the layout (`libei` uses the
server's keymap; `uinput` uses `XKB_DEFAULT_LAYOUT`, defaulting to `us`).
- **uinput** devices take ~2.5 s to be adopted by Hyprland; `Input::create()` waits for the compositor
to open them. Relative motion through uinput is subject to pointer acceleration.
- **Windows UIPI** blocks injecting input into elevated windows unless Alvin runs elevated too.
## Building
Arch Linux dependencies (most are already on a Hyprland system):
```sh
sudo pacman -S --needed cmake ninja pkgconf wayland wayland-protocols libxkbcommon \
glib2 pipewire libei opencv catch2
```
```sh
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build
ctest --test-dir build
```
Options (`ON`/`OFF`/`AUTO`, where `AUTO` enables a backend when its dependencies are found):
`ALVIN_WITH_WAYLAND`, `ALVIN_WITH_PORTAL`, `ALVIN_WITH_LIBEI`, `ALVIN_WITH_VISION`, plus
`ALVIN_WITH_UINPUT`, `ALVIN_BUILD_EXAMPLES`, `ALVIN_BUILD_TESTS`.
On Windows, build with a recent MSVC (C++23 `std::expected`) and CMake; OpenCV is optional.
## Using Alvin in your project
There are two ways to use Alvin from another project. Either way you link the same two targets:
| Target | What it gives you |
|--------|-------------------|
| `alvin::alvin` | Core library: input, capture, windows, monitors (`#include <alvin/alvin.hpp>`) |
| `alvin::vision` | OpenCV helpers (`#include <alvin/vision.hpp>`). Only exists if Alvin was built with OpenCV. |
Your project must be built as C++23 or newer. Linking `alvin::alvin` requests that for you.
### Option 1: build Alvin with your project (`add_subdirectory`)
Your build compiles Alvin from source alongside your own code. There's nothing to install, and
changes to Alvin are picked up on your next build. This is the easiest choice while Alvin is still
changing.
1. Put Alvin somewhere your project can reach, e.g. as a git submodule:
```sh
cd my_agent
git submodule add https://github.com/<you>/Alvin.git external/Alvin
```
Or just point at your existing checkout (step 2 accepts an absolute path).
2. In your project's `CMakeLists.txt`:
```cmake
cmake_minimum_required(VERSION 3.24)
project(my_agent CXX)
# Optional: skip Alvin's own tests and CLI while building your project.
set(ALVIN_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(ALVIN_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
add_subdirectory(external/Alvin)
# With a checkout outside your source tree, give a build directory name as well:
# add_subdirectory(/home/you/gitRepos/Alvin alvin)
add_executable(my_agent main.cpp)
target_link_libraries(my_agent PRIVATE alvin::alvin alvin::vision)
```
3. Build as normal:
```sh
cmake -S . -B build -G Ninja
cmake --build build
```
### Option 2: install Alvin once, then `find_package(Alvin)`
This builds and installs Alvin as a compiled library (static `.a`/`.lib` or shared `.so`/`.dll`)
with its headers. Any number of projects can then find it with `find_package`, without rebuilding
Alvin.
1. Build and install Alvin. Pick an install prefix: `~/.local` for just you (no sudo), or
`/usr/local` for the whole system.
```sh
cd Alvin
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=$HOME/.local \
-DBUILD_SHARED_LIBS=ON # OFF (the default) for a static library
cmake --build build
cmake --install build # prefix with sudo for /usr/local
```
This installs:
```
<prefix>/include/alvin/*.hpp headers
<prefix>/lib/libalvin.so (or .a) library, plus libalvin_vision if built
<prefix>/lib/cmake/Alvin/AlvinConfig.cmake what find_package reads
<prefix>/bin/alvin the CLI
```
2. In your project's `CMakeLists.txt`:
```cmake
cmake_minimum_required(VERSION 3.24)
project(my_agent CXX)
find_package(Alvin 0.1 REQUIRED) # core only
# find_package(Alvin 0.1 REQUIRED COMPONENTS vision) # fail early if Alvin was built without OpenCV
add_executable(my_agent main.cpp)
target_link_libraries(my_agent PRIVATE alvin::alvin alvin::vision)
```
3. Configure your project, telling CMake where Alvin is installed. `/usr/local` and `/usr` are
searched automatically; for anything else, pass the prefix:
```sh
cmake -S . -B build -G Ninja -DCMAKE_PREFIX_PATH=$HOME/.local
cmake --build build
```
**Notes for option 2**
- **After updating Alvin**, re-run the `cmake --build` and `cmake --install` commands from step 1.
Projects linked against the shared library use the new version the next time they run, but only
if the API didn't change; otherwise rebuild them. Projects linked against the static library
always need rebuilding.
- **Static vs shared:** a static install bakes Alvin into your executable. Alvin's own dependencies
(Wayland, PipeWire, libei, …) must still be installed wherever your program is *built*;
`AlvinConfig.cmake` finds them for you. A shared install puts all of that in `libalvin.so`.
- **"cannot open shared object file: libalvin.so"** at runtime means the library's folder isn't on
the loader's search path. This is common with `~/.local` or `/usr/local` prefixes. Either run
`export LD_LIBRARY_PATH=$HOME/.local/lib`, install to `/usr`, or add the folder to
`/etc/ld.so.conf.d/` and run `sudo ldconfig`.
- **Versioning:** `find_package(Alvin 0.1)` accepts any 0.1.x. Before 1.0, a new minor version
(0.2) may change the API, so it won't be accepted as a match for 0.1.
- **Windows:** the same steps should work with MSVC, but they haven't been tested yet. Use
`--config Release` on the build and install commands, and put the installed `bin` folder (where
`alvin.dll` goes) on `PATH` when running.
### Without CMake
If your project doesn't use CMake, do an option 2 install with `-DBUILD_SHARED_LIBS=ON`, then add
`<prefix>/include` to your include path and link `-lalvin` (plus `-lalvin_vision` and OpenCV if you
use vision). For example:
```sh
g++ -std=c++23 main.cpp -I$HOME/.local/include -L$HOME/.local/lib -lalvin -o my_agent
LD_LIBRARY_PATH=$HOME/.local/lib ./my_agent
```
## The `alvin` CLI
```sh
alvin monitors
alvin windows
alvin focus firefox
alvin move 1440 810
alvin clickat 1440 810 right
alvin key ctrl+shift+t
alvin type "hello"
alvin capture shot.png # first run shows the share picker
alvin find button.png 0.85 # prints desktop coordinates of the match
alvin --input uinput move 100 100
```
## Roadmap
- Promptless Hyprland capture (`hyprland-toplevel-export-v1`, `ext-image-copy-capture-v1`)
so an agent can capture any window by id without a picker
- DMA-BUF import for zero-copy PipeWire frames
- X11 backends (XTest input, XShm capture)
- Windows: DXGI Desktop Duplication / Windows.Graphics.Capture; CI build on Windows
- macOS: CGEvent input, ScreenCaptureKit capture
- OCR (Tesseract) in the vision module
+18
View File
@@ -0,0 +1,18 @@
@PACKAGE_INIT@
include(CMakeFindDependencyMacro)
# A static libalvin doesn't record its own dependencies, so find them again.
if(@ALVIN_NEEDS_PKGCONFIG@)
find_dependency(PkgConfig)
@ALVIN_CONFIG_DEPENDENCIES@endif()
set(Alvin_vision_FOUND @ALVIN_HAS_VISION@)
if(Alvin_vision_FOUND)
find_dependency(OpenCV COMPONENTS core imgproc imgcodecs)
endif()
include("${CMAKE_CURRENT_LIST_DIR}/AlvinTargets.cmake")
# find_package(Alvin REQUIRED COMPONENTS vision) fails if Alvin was built without OpenCV.
check_required_components(Alvin)
+498
View File
@@ -0,0 +1,498 @@
# 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.
+8
View File
@@ -0,0 +1,8 @@
add_executable(alvin_cli alvin_cli.cpp)
set_target_properties(alvin_cli PROPERTIES OUTPUT_NAME alvin)
target_link_libraries(alvin_cli PRIVATE alvin::alvin)
if(TARGET alvin::vision)
target_link_libraries(alvin_cli PRIVATE alvin::vision)
target_compile_definitions(alvin_cli PRIVATE ALVIN_CLI_VISION)
endif()
install(TARGETS alvin_cli)
+262
View File
@@ -0,0 +1,262 @@
// `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);
}
+14
View File
@@ -0,0 +1,14 @@
#pragma once
// Alvin - Agent Leveraging Vision and Input. Nice.
//
// Umbrella header for the core library. Computer-vision helpers live in the
// optional alvin::vision target (#include "alvin/vision.hpp").
#include "alvin/capture.hpp"
#include "alvin/error.hpp"
#include "alvin/frame.hpp"
#include "alvin/input.hpp"
#include "alvin/keys.hpp"
#include "alvin/types.hpp"
#include "alvin/window.hpp"
+72
View File
@@ -0,0 +1,72 @@
#pragma once
#include <chrono>
#include <cstdint>
#include <memory>
#include <optional>
#include <string>
#include <string_view>
#include "alvin/error.hpp"
#include "alvin/frame.hpp"
#include "alvin/window.hpp"
namespace alvin {
enum class CaptureBackend {
Auto,
Portal, // xdg-desktop-portal ScreenCast + PipeWire (any modern Wayland desktop)
Gdi, // Win32 BitBlt / PrintWindow
};
std::string_view toString(CaptureBackend backend) noexcept;
enum class CaptureSource : std::uint8_t {
Monitor = 1,
Window = 2,
Any = Monitor | Window,
};
struct CaptureOptions {
CaptureBackend backend = CaptureBackend::Auto;
// What the portal picker offers. On Windows, Monitor + monitorIndex or
// Window + window select the target directly with no dialog.
CaptureSource source = CaptureSource::Monitor;
int monitorIndex = 0;
std::optional<WindowId> window;
bool showCursor = false;
// Portal only: token from Capture::restoreToken() of an earlier session.
// Lets the compositor skip the picker and reuse the previous selection.
std::string restoreToken;
std::chrono::milliseconds permissionTimeout = std::chrono::minutes(2);
};
// A live capture of one monitor or window.
class Capture {
public:
static Result<std::unique_ptr<Capture>> create(const CaptureOptions& options = {});
virtual ~Capture() = default;
Capture(const Capture&) = delete;
Capture& operator=(const Capture&) = delete;
virtual CaptureBackend backend() const = 0;
// Most recent frame. Blocks until the first frame arrives (up to timeout).
// Streaming backends only deliver frames when the screen changes, so this
// does not wait for a *new* frame; use nextFrame() for that.
virtual Result<Frame> grab(std::chrono::milliseconds timeout = std::chrono::seconds(5)) = 0;
// Waits for a frame newer than any previously returned one.
virtual Result<Frame> nextFrame(std::chrono::milliseconds timeout = std::chrono::seconds(5)) = 0;
virtual std::string restoreToken() const { return {}; }
protected:
Capture() = default;
};
} // namespace alvin
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <expected>
#include <string>
#include <string_view>
namespace alvin {
enum class ErrorCode {
Unknown,
Unsupported, // backend or feature not available on this platform/session
NotAvailable, // backend compiled in but its service/device isn't present
PermissionDenied, // e.g. /dev/uinput not writable, portal request denied
Cancelled, // user dismissed a portal dialog
Timeout,
InvalidArgument,
NotFound,
Io,
Protocol, // unexpected reply from a compositor / portal / IPC socket
};
struct Error {
ErrorCode code = ErrorCode::Unknown;
std::string message;
};
template <class T>
using Result = std::expected<T, Error>;
using Status = Result<void>;
inline std::unexpected<Error> fail(ErrorCode code, std::string message) {
return std::unexpected(Error{code, std::move(message)});
}
std::string_view toString(ErrorCode code) noexcept;
} // namespace alvin
+69
View File
@@ -0,0 +1,69 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <span>
#include <vector>
#include "alvin/error.hpp"
#include "alvin/types.hpp"
namespace alvin {
// Byte order in memory. Every capture backend normalises to BGRA8, which is
// what OpenCV (CV_8UC4) and Win32 DIBs use natively.
enum class PixelFormat : std::uint8_t {
BGRA8,
};
struct Color {
std::uint8_t r = 0, g = 0, b = 0, a = 255;
friend bool operator==(const Color&, const Color&) = default;
};
class Frame {
public:
Frame() = default;
Frame(int width, int height); // zero-filled BGRA8
int width() const { return width_; }
int height() const { return height_; }
Size size() const { return {width_, height_}; }
std::size_t stride() const { return static_cast<std::size_t>(width_) * 4; }
PixelFormat format() const { return PixelFormat::BGRA8; }
bool empty() const { return data_.empty(); }
std::uint8_t* data() { return data_.data(); }
const std::uint8_t* data() const { return data_.data(); }
std::span<const std::uint8_t> bytes() const { return data_; }
std::uint8_t* row(int y) { return data_.data() + y * stride(); }
const std::uint8_t* row(int y) const { return data_.data() + y * stride(); }
Color pixel(int x, int y) const;
// Where this frame sits on the desktop, in logical pixels. Frames from a
// HiDPI monitor have more pixels than their logical size; use
// toDesktop()/fromDesktop() to convert instead of assuming 1:1.
Rect desktopRect() const { return desktopRect_; }
void setDesktopRect(Rect r) { desktopRect_ = r; }
Point toDesktop(Point framePixel) const;
Point fromDesktop(Point desktopPoint) const;
std::int64_t timestampNs() const { return timestampNs_; }
void setTimestampNs(std::int64_t t) { timestampNs_ = t; }
Frame crop(Rect r) const; // r in frame pixels; clipped to bounds
// Binary PPM (P6), dependency-free. Alpha is dropped.
Status savePpm(const std::filesystem::path& path) const;
private:
int width_ = 0;
int height_ = 0;
std::vector<std::uint8_t> data_;
Rect desktopRect_;
std::int64_t timestampNs_ = 0;
};
} // namespace alvin
+91
View File
@@ -0,0 +1,91 @@
#pragma once
#include <chrono>
#include <initializer_list>
#include <memory>
#include <string>
#include <string_view>
#include "alvin/error.hpp"
#include "alvin/keys.hpp"
#include "alvin/types.hpp"
namespace alvin {
enum class InputBackend {
Auto,
WlrVirtual, // Wayland zwlr_virtual_pointer_v1 + zwp_virtual_keyboard_v1 (Hyprland, sway, other wlroots)
LibEi, // libei via xdg-desktop-portal RemoteDesktop (GNOME, KDE)
UInput, // /dev/uinput kernel virtual devices (any Linux session; needs write access)
SendInput, // Win32
};
std::string_view toString(InputBackend backend) noexcept;
struct InputOptions {
InputBackend backend = InputBackend::Auto;
// LibEi only: token from a previous session (Input::restoreToken()) that
// lets the portal skip its permission dialog.
std::string restoreToken;
// How long to wait for the user to answer a portal permission dialog.
std::chrono::milliseconds permissionTimeout = std::chrono::minutes(2);
};
// Synthesises mouse and keyboard events.
//
// Coordinates passed to moveTo() are global desktop coordinates in logical
// pixels, matching Monitor::bounds and WindowInfo::bounds.
class Input {
public:
// Picks a backend (or uses options.backend). With Auto, the environment
// variable ALVIN_INPUT_BACKEND=wlr|libei|uinput|sendinput overrides the choice.
static Result<std::unique_ptr<Input>> create(const InputOptions& options = {});
virtual ~Input() = default;
Input(const Input&) = delete;
Input& operator=(const Input&) = delete;
virtual InputBackend backend() const = 0;
virtual Status moveTo(Point p) = 0;
virtual Status moveBy(int dx, int dy) = 0;
virtual Status buttonDown(MouseButton button) = 0;
virtual Status buttonUp(MouseButton button) = 0;
// Wheel notches; positive dy scrolls down, positive dx scrolls right.
virtual Status scroll(int dx, int dy) = 0;
virtual Status keyDown(Key key) = 0;
virtual Status keyUp(Key key) = 0;
// Types UTF-8 text using the active keyboard layout (or a synthesized one).
virtual Status typeText(std::string_view utf8) = 0;
// Portal-backed sessions can hand out a token to skip the dialog next time.
virtual std::string restoreToken() const { return {}; }
// ---- Convenience helpers built on the primitives above ----
Status click(MouseButton button = MouseButton::Left, int count = 1);
Status clickAt(Point p, MouseButton button = MouseButton::Left, int count = 1);
Status drag(Point from, Point to, MouseButton button = MouseButton::Left);
Status keyPress(Key key);
// Presses keys in order, releases in reverse: chord({Key::LeftCtrl, Key::C}).
Status chord(std::initializer_list<Key> keys);
// Same, from a string such as "ctrl+shift+esc".
Status chord(std::string_view chord);
// Delay inserted between the down/up halves of clicks and key presses.
// Some applications drop events that arrive with zero spacing.
void setEventDelay(std::chrono::milliseconds delay) { eventDelay_ = delay; }
std::chrono::milliseconds eventDelay() const { return eventDelay_; }
protected:
Input() = default;
void pause() const;
private:
std::chrono::milliseconds eventDelay_{8};
};
} // namespace alvin
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string_view>
#include <vector>
namespace alvin {
// Platform-neutral physical key identifiers. Names follow a US QWERTY layout
// but they identify key *positions*, the same way evdev codes and Win32
// scan codes do. Use Input::typeText() to produce characters instead.
enum class Key : std::uint16_t {
Unknown = 0,
A, B, C, D, E, F, G, H, I, J, K, L, M,
N, O, P, Q, R, S, T, U, V, W, X, Y, Z,
Num0, Num1, Num2, Num3, Num4, Num5, Num6, Num7, Num8, Num9,
F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12,
F13, F14, F15, F16, F17, F18, F19, F20, F21, F22, F23, 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, Kp1, Kp2, Kp3, Kp4, Kp5, Kp6, Kp7, Kp8, Kp9,
KpDecimal, KpDivide, KpMultiply, KpSubtract, KpAdd, KpEnter,
VolumeMute, VolumeDown, VolumeUp,
MediaPlayPause, MediaStop, MediaNext, MediaPrevious,
Count_,
};
enum class MouseButton : std::uint8_t {
Left,
Right,
Middle,
Back,
Forward,
};
// "ctrl", "a", "f5", "enter", "super", ... (case-insensitive).
std::optional<Key> keyFromName(std::string_view name);
std::string_view keyName(Key key);
// Parses a chord such as "ctrl+shift+t" into its keys, modifiers first as written.
std::optional<std::vector<Key>> parseChord(std::string_view chord);
bool isModifier(Key key);
} // namespace alvin
+58
View File
@@ -0,0 +1,58 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#include "alvin/error.hpp"
namespace alvin {
struct Point {
int x = 0;
int y = 0;
friend bool operator==(const Point&, const Point&) = default;
};
struct Size {
int width = 0;
int height = 0;
friend bool operator==(const Size&, const Size&) = default;
};
// All rectangles are in global desktop coordinates (logical pixels, i.e. after
// monitor scaling) unless documented otherwise.
struct Rect {
int x = 0;
int y = 0;
int width = 0;
int height = 0;
int right() const { return x + width; }
int bottom() const { return y + height; }
Point center() const { return {x + width / 2, y + height / 2}; }
bool empty() const { return width <= 0 || height <= 0; }
bool contains(Point p) const { return p.x >= x && p.y >= y && p.x < right() && p.y < bottom(); }
// Smallest rect containing both. An empty rect acts as identity.
Rect united(const Rect& o) const;
friend bool operator==(const Rect&, const Rect&) = default;
};
struct Monitor {
std::string name; // connector name, e.g. "DP-1" or "\\\\.\\DISPLAY1"
std::string description;
Rect bounds; // logical position/size in the global desktop
Size pixelSize; // physical mode size in pixels
double scale = 1.0;
bool primary = false;
};
// Enumerate monitors on the current session.
Result<std::vector<Monitor>> listMonitors();
// Bounding box of all monitors, i.e. the space absolute pointer motion maps onto.
Result<Rect> desktopBounds();
} // namespace alvin
+40
View File
@@ -0,0 +1,40 @@
#pragma once
// Optional OpenCV integration (link alvin::vision).
#include <filesystem>
#include <optional>
#include <vector>
#include <opencv2/core.hpp>
#include "alvin/error.hpp"
#include "alvin/frame.hpp"
namespace alvin::vision {
// Zero-copy CV_8UC4 (BGRA) view. The Frame must outlive the Mat.
cv::Mat view(const Frame& frame);
// Deep copy converted to CV_8UC3 (BGR), the layout most OpenCV code expects.
cv::Mat toBgr(const Frame& frame);
Result<Frame> fromMat(const cv::Mat& mat);
Result<cv::Mat> loadImage(const std::filesystem::path& path); // BGR
Status saveImage(const Frame& frame, const std::filesystem::path& path); // png/jpg/...
struct Match {
Rect rect; // in frame pixels
double score = 0; // 0..1, higher is better
};
struct MatchOptions {
double threshold = 0.9;
bool grayscale = false; // faster, ignores colour differences
int maxResults = 16; // findAll only
};
// Normalised cross-correlation template matching.
Result<std::optional<Match>> find(const Frame& haystack, const cv::Mat& needle, const MatchOptions& opts = {});
Result<std::vector<Match>> findAll(const Frame& haystack, const cv::Mat& needle, const MatchOptions& opts = {});
} // namespace alvin::vision
+64
View File
@@ -0,0 +1,64 @@
#pragma once
#include <cstdint>
#include <memory>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
#include "alvin/error.hpp"
#include "alvin/types.hpp"
namespace alvin {
// Opaque, backend-defined handle: a Hyprland client address, an HWND, ...
struct WindowId {
std::uint64_t value = 0;
friend bool operator==(const WindowId&, const WindowId&) = default;
};
struct WindowInfo {
WindowId id;
std::string title;
std::string appId; // Wayland app_id / X11 class / Win32 executable name
Rect bounds; // global desktop coordinates
std::int64_t pid = 0;
bool focused = false;
bool visible = true;
bool minimized = false;
bool fullscreen = false;
std::string workspace; // compositor-specific; empty when not applicable
};
enum class WindowBackend {
Auto,
Hyprland, // Hyprland IPC socket
Win32,
};
std::string_view toString(WindowBackend backend) noexcept;
// Querying and focusing top-level windows. Wayland deliberately hides this
// from normal clients, so each compositor needs its own implementation.
class WindowManager {
public:
static Result<std::unique_ptr<WindowManager>> create(WindowBackend backend = WindowBackend::Auto);
virtual ~WindowManager() = default;
WindowManager(const WindowManager&) = delete;
WindowManager& operator=(const WindowManager&) = delete;
virtual WindowBackend backend() const = 0;
virtual Result<std::vector<WindowInfo>> list() = 0;
virtual Result<std::optional<WindowInfo>> active() = 0;
virtual Status focus(WindowId id) = 0;
// First window whose title or appId contains `needle` (case-insensitive).
Result<std::optional<WindowInfo>> find(std::string_view needle);
protected:
WindowManager() = default;
};
} // namespace alvin
+113
View File
@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="virtual_keyboard_unstable_v1">
<copyright>
Copyright © 2008-2011 Kristian Høgsberg
Copyright © 2010-2013 Intel Corporation
Copyright © 2012-2013 Collabora, Ltd.
Copyright © 2018 Purism SPC
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice (including the next
paragraph) shall be included in all copies or substantial portions of the
Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
</copyright>
<interface name="zwp_virtual_keyboard_v1" version="1">
<description summary="virtual keyboard">
The virtual keyboard provides an application with requests which emulate
the behaviour of a physical keyboard.
This interface can be used by clients on its own to provide raw input
events, or it can accompany the input method protocol.
</description>
<request name="keymap">
<description summary="keyboard mapping">
Provide a file descriptor to the compositor which can be
memory-mapped to provide a keyboard mapping description.
Format carries a value from the keymap_format enumeration.
</description>
<arg name="format" type="uint" summary="keymap format"/>
<arg name="fd" type="fd" summary="keymap file descriptor"/>
<arg name="size" type="uint" summary="keymap size, in bytes"/>
</request>
<enum name="error">
<entry name="no_keymap" value="0" summary="No keymap was set"/>
</enum>
<request name="key">
<description summary="key event">
A key was pressed or released.
The time argument is a timestamp with millisecond granularity, with an
undefined base. All requests regarding a single object must share the
same clock.
Keymap must be set before issuing this request.
State carries a value from the key_state enumeration.
</description>
<arg name="time" type="uint" summary="timestamp with millisecond granularity"/>
<arg name="key" type="uint" summary="key that produced the event"/>
<arg name="state" type="uint" summary="physical state of the key"/>
</request>
<request name="modifiers">
<description summary="modifier and group state">
Notifies the compositor that the modifier and/or group state has
changed, and it should update state.
The client should use wl_keyboard.modifiers event to synchronize its
internal state with seat state.
Keymap must be set before issuing this request.
</description>
<arg name="mods_depressed" type="uint" summary="depressed modifiers"/>
<arg name="mods_latched" type="uint" summary="latched modifiers"/>
<arg name="mods_locked" type="uint" summary="locked modifiers"/>
<arg name="group" type="uint" summary="keyboard layout"/>
</request>
<request name="destroy" type="destructor" since="1">
<description summary="destroy the virtual keyboard keyboard object"/>
</request>
</interface>
<interface name="zwp_virtual_keyboard_manager_v1" version="1">
<description summary="virtual keyboard manager">
A virtual keyboard manager allows an application to provide keyboard
input events as if they came from a physical keyboard.
</description>
<enum name="error">
<entry name="unauthorized" value="0" summary="client not authorized to use the interface"/>
</enum>
<request name="create_virtual_keyboard">
<description summary="Create a new virtual keyboard">
Creates a new virtual keyboard associated to a seat.
If the compositor enables a keyboard to perform arbitrary actions, it
should present an error when an untrusted client requests a new
keyboard.
</description>
<arg name="seat" type="object" interface="wl_seat"/>
<arg name="id" type="new_id" interface="zwp_virtual_keyboard_v1"/>
</request>
</interface>
</protocol>
@@ -0,0 +1,152 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="wlr_virtual_pointer_unstable_v1">
<copyright>
Copyright © 2019 Josef Gajdusek
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice (including the next
paragraph) shall be included in all copies or substantial portions of the
Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
</copyright>
<interface name="zwlr_virtual_pointer_v1" version="2">
<description summary="virtual pointer">
This protocol allows clients to emulate a physical pointer device. The
requests are mostly mirror opposites of those specified in wl_pointer.
</description>
<enum name="error">
<entry name="invalid_axis" value="0"
summary="client sent invalid axis enumeration value" />
<entry name="invalid_axis_source" value="1"
summary="client sent invalid axis source enumeration value" />
</enum>
<request name="motion">
<description summary="pointer relative motion event">
The pointer has moved by a relative amount to the previous request.
Values are in the global compositor space.
</description>
<arg name="time" type="uint" summary="timestamp with millisecond granularity"/>
<arg name="dx" type="fixed" summary="displacement on the x-axis"/>
<arg name="dy" type="fixed" summary="displacement on the y-axis"/>
</request>
<request name="motion_absolute">
<description summary="pointer absolute motion event">
The pointer has moved in an absolute coordinate frame.
Value of x can range from 0 to x_extent, value of y can range from 0
to y_extent.
</description>
<arg name="time" type="uint" summary="timestamp with millisecond granularity"/>
<arg name="x" type="uint" summary="position on the x-axis"/>
<arg name="y" type="uint" summary="position on the y-axis"/>
<arg name="x_extent" type="uint" summary="extent of the x-axis"/>
<arg name="y_extent" type="uint" summary="extent of the y-axis"/>
</request>
<request name="button">
<description summary="button event">
A button was pressed or released.
</description>
<arg name="time" type="uint" summary="timestamp with millisecond granularity"/>
<arg name="button" type="uint" summary="button that produced the event"/>
<arg name="state" type="uint" enum="wl_pointer.button_state" summary="physical state of the button"/>
</request>
<request name="axis">
<description summary="axis event">
Scroll and other axis requests.
</description>
<arg name="time" type="uint" summary="timestamp with millisecond granularity"/>
<arg name="axis" type="uint" enum="wl_pointer.axis" summary="axis type"/>
<arg name="value" type="fixed" summary="length of vector in touchpad coordinates"/>
</request>
<request name="frame">
<description summary="end of a pointer event sequence">
Indicates the set of events that logically belong together.
</description>
</request>
<request name="axis_source">
<description summary="axis source event">
Source information for scroll and other axis.
</description>
<arg name="axis_source" type="uint" enum="wl_pointer.axis_source" summary="source of the axis event"/>
</request>
<request name="axis_stop">
<description summary="axis stop event">
Stop notification for scroll and other axes.
</description>
<arg name="time" type="uint" summary="timestamp with millisecond granularity"/>
<arg name="axis" type="uint" enum="wl_pointer.axis" summary="the axis stopped with this event"/>
</request>
<request name="axis_discrete">
<description summary="axis click event">
Discrete step information for scroll and other axes.
This event allows the client to extend data normally sent using the axis
event with discrete value.
</description>
<arg name="time" type="uint" summary="timestamp with millisecond granularity"/>
<arg name="axis" type="uint" enum="wl_pointer.axis" summary="axis type"/>
<arg name="value" type="fixed" summary="length of vector in touchpad coordinates"/>
<arg name="discrete" type="int" summary="number of steps"/>
</request>
<request name="destroy" type="destructor" since="1">
<description summary="destroy the virtual pointer object"/>
</request>
</interface>
<interface name="zwlr_virtual_pointer_manager_v1" version="2">
<description summary="virtual pointer manager">
This object allows clients to create individual virtual pointer objects.
</description>
<request name="create_virtual_pointer">
<description summary="Create a new virtual pointer">
Creates a new virtual pointer. The optional seat is a suggestion to the
compositor.
</description>
<arg name="seat" type="object" interface="wl_seat" allow-null="true"/>
<arg name="id" type="new_id" interface="zwlr_virtual_pointer_v1"/>
</request>
<request name="destroy" type="destructor" since="1">
<description summary="destroy the virtual pointer manager"/>
</request>
<!-- Version 2 additions -->
<request name="create_virtual_pointer_with_output" since="2">
<description summary="Create a new virtual pointer">
Creates a new virtual pointer. The seat and the output arguments are
optional. If the seat argument is set, the compositor should assign the
input device to the requested seat. If the output argument is set, the
compositor should map the input device to the requested output.
</description>
<arg name="seat" type="object" interface="wl_seat" allow-null="true"/>
<arg name="output" type="object" interface="wl_output" allow-null="true"/>
<arg name="id" type="new_id" interface="zwlr_virtual_pointer_v1"/>
</request>
</interface>
</protocol>
+178
View File
@@ -0,0 +1,178 @@
#include <algorithm>
#include <cctype>
#include <span>
#include <thread>
#include "alvin/capture.hpp"
#include "alvin/error.hpp"
#include "alvin/input.hpp"
#include "alvin/types.hpp"
#include "alvin/window.hpp"
#include "platform.hpp"
namespace alvin {
std::string_view toString(ErrorCode code) noexcept {
switch (code) {
case ErrorCode::Unknown: return "unknown";
case ErrorCode::Unsupported: return "unsupported";
case ErrorCode::NotAvailable: return "not available";
case ErrorCode::PermissionDenied: return "permission denied";
case ErrorCode::Cancelled: return "cancelled";
case ErrorCode::Timeout: return "timeout";
case ErrorCode::InvalidArgument: return "invalid argument";
case ErrorCode::NotFound: return "not found";
case ErrorCode::Io: return "i/o error";
case ErrorCode::Protocol: return "protocol error";
}
return "unknown";
}
std::string_view toString(InputBackend backend) noexcept {
switch (backend) {
case InputBackend::Auto: return "auto";
case InputBackend::WlrVirtual: return "wlr";
case InputBackend::LibEi: return "libei";
case InputBackend::UInput: return "uinput";
case InputBackend::SendInput: return "sendinput";
}
return "unknown";
}
std::string_view toString(CaptureBackend backend) noexcept {
switch (backend) {
case CaptureBackend::Auto: return "auto";
case CaptureBackend::Portal: return "portal";
case CaptureBackend::Gdi: return "gdi";
}
return "unknown";
}
std::string_view toString(WindowBackend backend) noexcept {
switch (backend) {
case WindowBackend::Auto: return "auto";
case WindowBackend::Hyprland: return "hyprland";
case WindowBackend::Win32: return "win32";
}
return "unknown";
}
Rect Rect::united(const Rect& o) const {
if (empty()) return o;
if (o.empty()) return *this;
int l = std::min(x, o.x), t = std::min(y, o.y);
int r = std::max(right(), o.right()), b = std::max(bottom(), o.bottom());
return {l, t, r - l, b - t};
}
Result<std::vector<Monitor>> listMonitors() { return detail::listMonitors(); }
Result<Rect> desktopBounds() {
auto monitors = listMonitors();
if (!monitors) return std::unexpected(monitors.error());
Rect box;
for (const auto& m : *monitors) box = box.united(m.bounds);
if (box.empty()) return fail(ErrorCode::NotFound, "no monitors reported");
return box;
}
// ---- Input ----
Result<std::unique_ptr<Input>> Input::create(const InputOptions& options) { return detail::createInput(options); }
void Input::pause() const {
if (eventDelay_.count() > 0) std::this_thread::sleep_for(eventDelay_);
}
Status Input::click(MouseButton button, int count) {
for (int i = 0; i < count; ++i) {
if (auto s = buttonDown(button); !s) return s;
pause();
if (auto s = buttonUp(button); !s) return s;
if (i + 1 < count) pause();
}
return {};
}
Status Input::clickAt(Point p, MouseButton button, int count) {
if (auto s = moveTo(p); !s) return s;
pause();
return click(button, count);
}
Status Input::drag(Point from, Point to, MouseButton button) {
if (auto s = moveTo(from); !s) return s;
pause();
if (auto s = buttonDown(button); !s) return s;
// Intermediate steps so drag thresholds and hover handlers fire.
constexpr int steps = 10;
for (int i = 1; i <= steps; ++i) {
pause();
Point p{from.x + (to.x - from.x) * i / steps, from.y + (to.y - from.y) * i / steps};
if (auto s = moveTo(p); !s) {
(void)buttonUp(button);
return s;
}
}
pause();
return buttonUp(button);
}
Status Input::keyPress(Key key) {
if (auto s = keyDown(key); !s) return s;
pause();
return keyUp(key);
}
// Presses keys in order and releases them in reverse. Always releases what it
// pressed, even after a failure, so no key is left stuck down.
static Status pressChord(Input& input, std::span<const Key> keys, void (*pause)(const Input&)) {
std::vector<Key> pressed;
Status result;
for (Key k : keys) {
result = input.keyDown(k);
if (!result) break;
pressed.push_back(k);
pause(input);
}
for (auto it = pressed.rbegin(); it != pressed.rend(); ++it) {
auto s = input.keyUp(*it);
if (result && !s) result = s;
}
return result;
}
Status Input::chord(std::initializer_list<Key> keys) {
return pressChord(*this, std::span(keys.begin(), keys.size()), [](const Input& in) { in.pause(); });
}
Status Input::chord(std::string_view text) {
auto keys = parseChord(text);
if (!keys) return fail(ErrorCode::InvalidArgument, "cannot parse key chord '" + std::string(text) + "'");
return pressChord(*this, *keys, [](const Input& in) { in.pause(); });
}
// ---- Capture / windows ----
Result<std::unique_ptr<Capture>> Capture::create(const CaptureOptions& options) { return detail::createCapture(options); }
Result<std::unique_ptr<WindowManager>> WindowManager::create(WindowBackend backend) {
return detail::createWindowManager(backend);
}
static bool containsNoCase(std::string_view haystack, std::string_view needle) {
auto it = std::search(haystack.begin(), haystack.end(), needle.begin(), needle.end(), [](char a, char b) {
return std::tolower(static_cast<unsigned char>(a)) == std::tolower(static_cast<unsigned char>(b));
});
return it != haystack.end();
}
Result<std::optional<WindowInfo>> WindowManager::find(std::string_view needle) {
auto windows = list();
if (!windows) return std::unexpected(windows.error());
for (auto& w : *windows)
if (containsNoCase(w.title, needle) || containsNoCase(w.appId, needle)) return std::optional{std::move(w)};
return std::optional<WindowInfo>{};
}
} // namespace alvin
+68
View File
@@ -0,0 +1,68 @@
#include <algorithm>
#include <cmath>
#include <cstring>
#include <fstream>
#include "alvin/frame.hpp"
namespace alvin {
Frame::Frame(int width, int height)
: width_(std::max(width, 0)),
height_(std::max(height, 0)),
data_(static_cast<std::size_t>(width_) * height_ * 4, 0),
desktopRect_{0, 0, width_, height_} {}
Color Frame::pixel(int x, int y) const {
if (x < 0 || y < 0 || x >= width_ || y >= height_) return {};
const std::uint8_t* p = row(y) + x * 4;
return {p[2], p[1], p[0], p[3]};
}
Point Frame::toDesktop(Point px) const {
if (width_ == 0 || height_ == 0) return px;
double sx = static_cast<double>(desktopRect_.width) / width_;
double sy = static_cast<double>(desktopRect_.height) / height_;
return {desktopRect_.x + static_cast<int>(std::lround(px.x * sx)),
desktopRect_.y + static_cast<int>(std::lround(px.y * sy))};
}
Point Frame::fromDesktop(Point p) const {
if (desktopRect_.width == 0 || desktopRect_.height == 0) return p;
double sx = static_cast<double>(width_) / desktopRect_.width;
double sy = static_cast<double>(height_) / desktopRect_.height;
return {static_cast<int>(std::lround((p.x - desktopRect_.x) * sx)),
static_cast<int>(std::lround((p.y - desktopRect_.y) * sy))};
}
Frame Frame::crop(Rect r) const {
int x0 = std::clamp(r.x, 0, width_), y0 = std::clamp(r.y, 0, height_);
int x1 = std::clamp(r.right(), 0, width_), y1 = std::clamp(r.bottom(), 0, height_);
Frame out(x1 - x0, y1 - y0);
for (int y = 0; y < out.height_; ++y) std::memcpy(out.row(y), row(y0 + y) + x0 * 4, out.stride());
Point tl = toDesktop({x0, y0}), br = toDesktop({x1, y1});
out.desktopRect_ = {tl.x, tl.y, br.x - tl.x, br.y - tl.y};
out.timestampNs_ = timestampNs_;
return out;
}
Status Frame::savePpm(const std::filesystem::path& path) const {
std::ofstream f(path, std::ios::binary);
if (!f) return fail(ErrorCode::Io, "cannot open " + path.string());
f << "P6\n" << width_ << ' ' << height_ << "\n255\n";
std::vector<char> line(static_cast<std::size_t>(width_) * 3);
for (int y = 0; y < height_; ++y) {
const std::uint8_t* src = row(y);
for (int x = 0; x < width_; ++x) {
line[x * 3 + 0] = static_cast<char>(src[x * 4 + 2]);
line[x * 3 + 1] = static_cast<char>(src[x * 4 + 1]);
line[x * 3 + 2] = static_cast<char>(src[x * 4 + 0]);
}
f.write(line.data(), static_cast<std::streamsize>(line.size()));
}
if (!f) return fail(ErrorCode::Io, "write failed for " + path.string());
return {};
}
} // namespace alvin
+215
View File
@@ -0,0 +1,215 @@
#include "json.hpp"
#include <cctype>
#include <charconv>
#include <cstdlib>
namespace alvin::json {
namespace {
const Value kNull{};
const Array kEmptyArray{};
const Object kEmptyObject{};
class Parser {
public:
explicit Parser(std::string_view s) : s_(s) {}
std::optional<Value> document() {
auto v = value(0);
skipSpace();
if (!v || pos_ != s_.size()) return std::nullopt;
return v;
}
private:
static constexpr int kMaxDepth = 256;
void skipSpace() {
while (pos_ < s_.size() && std::isspace(static_cast<unsigned char>(s_[pos_]))) ++pos_;
}
bool consume(char c) {
skipSpace();
if (pos_ < s_.size() && s_[pos_] == c) {
++pos_;
return true;
}
return false;
}
bool literal(std::string_view word) {
if (s_.substr(pos_, word.size()) != word) return false;
pos_ += word.size();
return true;
}
std::optional<Value> value(int depth) {
if (depth > kMaxDepth) return std::nullopt;
skipSpace();
if (pos_ >= s_.size()) return std::nullopt;
char c = s_[pos_];
if (c == '{') return object(depth);
if (c == '[') return array(depth);
if (c == '"') {
auto str = string();
if (!str) return std::nullopt;
return Value{std::move(*str)};
}
if (literal("true")) return Value{true};
if (literal("false")) return Value{false};
if (literal("null")) return Value{nullptr};
return number();
}
std::optional<Value> number() {
std::size_t start = pos_;
while (pos_ < s_.size() && (std::isdigit(static_cast<unsigned char>(s_[pos_])) || s_[pos_] == '-' ||
s_[pos_] == '+' || s_[pos_] == '.' || s_[pos_] == 'e' || s_[pos_] == 'E'))
++pos_;
if (start == pos_) return std::nullopt;
std::string text(s_.substr(start, pos_ - start));
char* end = nullptr;
double d = std::strtod(text.c_str(), &end);
if (end != text.c_str() + text.size()) return std::nullopt;
return Value{d};
}
static void appendUtf8(std::string& out, char32_t cp) {
if (cp < 0x80) {
out += static_cast<char>(cp);
} else if (cp < 0x800) {
out += static_cast<char>(0xC0 | (cp >> 6));
out += static_cast<char>(0x80 | (cp & 0x3F));
} else if (cp < 0x10000) {
out += static_cast<char>(0xE0 | (cp >> 12));
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
out += static_cast<char>(0x80 | (cp & 0x3F));
} else {
out += static_cast<char>(0xF0 | (cp >> 18));
out += static_cast<char>(0x80 | ((cp >> 12) & 0x3F));
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
out += static_cast<char>(0x80 | (cp & 0x3F));
}
}
std::optional<unsigned> hex4() {
if (pos_ + 4 > s_.size()) return std::nullopt;
unsigned v = 0;
auto r = std::from_chars(s_.data() + pos_, s_.data() + pos_ + 4, v, 16);
if (r.ptr != s_.data() + pos_ + 4) return std::nullopt;
pos_ += 4;
return v;
}
std::optional<std::string> string() {
if (!consume('"')) return std::nullopt;
std::string out;
while (pos_ < s_.size()) {
char c = s_[pos_++];
if (c == '"') return out;
if (c != '\\') {
out += c;
continue;
}
if (pos_ >= s_.size()) return std::nullopt;
switch (char e = s_[pos_++]) {
case '"': case '\\': case '/': out += e; break;
case 'b': out += '\b'; break;
case 'f': out += '\f'; break;
case 'n': out += '\n'; break;
case 'r': out += '\r'; break;
case 't': out += '\t'; break;
case 'u': {
auto hi = hex4();
if (!hi) return std::nullopt;
char32_t cp = *hi;
if (cp >= 0xD800 && cp < 0xDC00 && s_.substr(pos_, 2) == "\\u") {
pos_ += 2;
auto lo = hex4();
if (!lo) return std::nullopt;
cp = 0x10000 + ((cp - 0xD800) << 10) + (*lo - 0xDC00);
}
appendUtf8(out, cp);
break;
}
default: return std::nullopt;
}
}
return std::nullopt;
}
std::optional<Value> array(int depth) {
consume('[');
auto arr = std::make_shared<Array>();
if (consume(']')) return Value{arr};
do {
auto v = value(depth + 1);
if (!v) return std::nullopt;
arr->push_back(std::move(*v));
} while (consume(','));
if (!consume(']')) return std::nullopt;
return Value{arr};
}
std::optional<Value> object(int depth) {
consume('{');
auto obj = std::make_shared<Object>();
if (consume('}')) return Value{obj};
do {
skipSpace();
auto key = string();
if (!key || !consume(':')) return std::nullopt;
auto v = value(depth + 1);
if (!v) return std::nullopt;
obj->insert_or_assign(std::move(*key), std::move(*v));
} while (consume(','));
if (!consume('}')) return std::nullopt;
return Value{obj};
}
std::string_view s_;
std::size_t pos_ = 0;
};
} // namespace
bool Value::boolean(bool fallback) const {
auto* b = std::get_if<bool>(&v);
return b ? *b : fallback;
}
double Value::number(double fallback) const {
auto* d = std::get_if<double>(&v);
return d ? *d : fallback;
}
std::string Value::string(std::string fallback) const {
auto* s = std::get_if<std::string>(&v);
return s ? *s : fallback;
}
const Array& Value::array() const {
auto* a = std::get_if<std::shared_ptr<Array>>(&v);
return a ? **a : kEmptyArray;
}
const Object& Value::object() const {
auto* o = std::get_if<std::shared_ptr<Object>>(&v);
return o ? **o : kEmptyObject;
}
const Value& Value::operator[](std::string_view key) const {
const auto& o = object();
auto it = o.find(key);
return it == o.end() ? kNull : it->second;
}
const Value& Value::operator[](std::size_t index) const {
const auto& a = array();
return index < a.size() ? a[index] : kNull;
}
std::optional<Value> parse(std::string_view text) { return Parser(text).document(); }
} // namespace alvin::json
+42
View File
@@ -0,0 +1,42 @@
#pragma once
// A deliberately small JSON reader, enough for compositor IPC replies.
// Not a general-purpose library: no serialisation, numbers are doubles.
#include <map>
#include <memory>
#include <optional>
#include <string>
#include <string_view>
#include <variant>
#include <vector>
namespace alvin::json {
struct Value;
using Array = std::vector<Value>;
using Object = std::map<std::string, Value, std::less<>>;
struct Value {
std::variant<std::nullptr_t, bool, double, std::string, std::shared_ptr<Array>, std::shared_ptr<Object>> v;
bool isNull() const { return std::holds_alternative<std::nullptr_t>(v); }
bool isObject() const { return std::holds_alternative<std::shared_ptr<Object>>(v); }
bool isArray() const { return std::holds_alternative<std::shared_ptr<Array>>(v); }
// Lenient accessors: return the fallback on type mismatch.
bool boolean(bool fallback = false) const;
double number(double fallback = 0) const;
std::string string(std::string fallback = {}) const;
const Array& array() const; // empty array if not an array
const Object& object() const; // empty object if not an object
// Member lookup; returns a null value if missing or not an object.
const Value& operator[](std::string_view key) const;
// Element lookup; returns a null value if out of range or not an array.
const Value& operator[](std::size_t index) const;
};
std::optional<Value> parse(std::string_view text);
} // namespace alvin::json
+125
View File
@@ -0,0 +1,125 @@
#include <array>
#include <cctype>
#include <string>
#include <utility>
#include "alvin/keys.hpp"
namespace alvin {
namespace {
struct NamedKey {
std::string_view name;
Key key;
};
// First entry for each key is its canonical name.
constexpr NamedKey kNames[] = {
{"a", Key::A}, {"b", Key::B}, {"c", Key::C}, {"d", Key::D}, {"e", Key::E}, {"f", Key::F},
{"g", Key::G}, {"h", Key::H}, {"i", Key::I}, {"j", Key::J}, {"k", Key::K}, {"l", Key::L},
{"m", Key::M}, {"n", Key::N}, {"o", Key::O}, {"p", Key::P}, {"q", Key::Q}, {"r", Key::R},
{"s", Key::S}, {"t", Key::T}, {"u", Key::U}, {"v", Key::V}, {"w", Key::W}, {"x", Key::X},
{"y", Key::Y}, {"z", Key::Z},
{"0", Key::Num0}, {"1", Key::Num1}, {"2", Key::Num2}, {"3", Key::Num3}, {"4", Key::Num4},
{"5", Key::Num5}, {"6", Key::Num6}, {"7", Key::Num7}, {"8", Key::Num8}, {"9", Key::Num9},
{"f1", Key::F1}, {"f2", Key::F2}, {"f3", Key::F3}, {"f4", Key::F4}, {"f5", Key::F5},
{"f6", Key::F6}, {"f7", Key::F7}, {"f8", Key::F8}, {"f9", Key::F9}, {"f10", Key::F10},
{"f11", Key::F11}, {"f12", Key::F12}, {"f13", Key::F13}, {"f14", Key::F14}, {"f15", Key::F15},
{"f16", Key::F16}, {"f17", Key::F17}, {"f18", Key::F18}, {"f19", Key::F19}, {"f20", Key::F20},
{"f21", Key::F21}, {"f22", Key::F22}, {"f23", Key::F23}, {"f24", Key::F24},
{"escape", Key::Escape}, {"esc", Key::Escape},
{"enter", Key::Enter}, {"return", Key::Enter},
{"tab", Key::Tab},
{"backspace", Key::Backspace},
{"space", Key::Space},
{"minus", Key::Minus}, {"-", Key::Minus},
{"equal", Key::Equal}, {"=", Key::Equal},
{"leftbracket", Key::LeftBracket}, {"[", Key::LeftBracket},
{"rightbracket", Key::RightBracket}, {"]", Key::RightBracket},
{"backslash", Key::Backslash}, {"\\", Key::Backslash},
{"semicolon", Key::Semicolon}, {";", Key::Semicolon},
{"apostrophe", Key::Apostrophe}, {"'", Key::Apostrophe},
{"grave", Key::Grave}, {"`", Key::Grave},
{"comma", Key::Comma}, {",", Key::Comma},
{"period", Key::Period}, {".", Key::Period},
{"slash", Key::Slash}, {"/", Key::Slash},
{"capslock", Key::CapsLock}, {"numlock", Key::NumLock}, {"scrolllock", Key::ScrollLock},
{"shift", Key::LeftShift}, {"lshift", Key::LeftShift}, {"rshift", Key::RightShift},
{"ctrl", Key::LeftCtrl}, {"control", Key::LeftCtrl}, {"lctrl", Key::LeftCtrl}, {"rctrl", Key::RightCtrl},
{"alt", Key::LeftAlt}, {"lalt", Key::LeftAlt}, {"ralt", Key::RightAlt}, {"altgr", Key::RightAlt},
{"super", Key::LeftSuper}, {"win", Key::LeftSuper}, {"meta", Key::LeftSuper}, {"cmd", Key::LeftSuper},
{"lsuper", Key::LeftSuper}, {"rsuper", Key::RightSuper},
{"menu", Key::Menu},
{"insert", Key::Insert}, {"delete", Key::Delete}, {"del", Key::Delete},
{"home", Key::Home}, {"end", Key::End},
{"pageup", Key::PageUp}, {"pgup", Key::PageUp}, {"pagedown", Key::PageDown}, {"pgdn", Key::PageDown},
{"left", Key::Left}, {"right", Key::Right}, {"up", Key::Up}, {"down", Key::Down},
{"printscreen", Key::PrintScreen}, {"print", Key::PrintScreen}, {"pause", Key::Pause},
{"kp0", Key::Kp0}, {"kp1", Key::Kp1}, {"kp2", Key::Kp2}, {"kp3", Key::Kp3}, {"kp4", Key::Kp4},
{"kp5", Key::Kp5}, {"kp6", Key::Kp6}, {"kp7", Key::Kp7}, {"kp8", Key::Kp8}, {"kp9", Key::Kp9},
{"kpdecimal", Key::KpDecimal}, {"kpdivide", Key::KpDivide}, {"kpmultiply", Key::KpMultiply},
{"kpsubtract", Key::KpSubtract}, {"kpadd", Key::KpAdd}, {"kpenter", Key::KpEnter},
{"mute", Key::VolumeMute}, {"volumedown", Key::VolumeDown}, {"volumeup", Key::VolumeUp},
{"playpause", Key::MediaPlayPause}, {"stop", Key::MediaStop},
{"next", Key::MediaNext}, {"previous", Key::MediaPrevious}, {"prev", Key::MediaPrevious},
};
std::string lower(std::string_view s) {
std::string out(s);
for (char& c : out) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return out;
}
} // namespace
std::optional<Key> keyFromName(std::string_view name) {
auto n = lower(name);
for (const auto& e : kNames)
if (e.name == n) return e.key;
return std::nullopt;
}
std::string_view keyName(Key key) {
for (const auto& e : kNames)
if (e.key == key) return e.name;
return "unknown";
}
std::optional<std::vector<Key>> parseChord(std::string_view chord) {
// "ctrl++" means ctrl plus the '+' key, which is the Equal key position on US layouts.
bool plusKey = false;
if (chord == "+") return std::vector<Key>{Key::Equal};
if (chord.ends_with("++")) {
plusKey = true;
chord.remove_suffix(2);
}
std::vector<Key> keys;
while (true) {
auto end = chord.find('+');
auto token = chord.substr(0, end);
while (!token.empty() && std::isspace(static_cast<unsigned char>(token.front()))) token.remove_prefix(1);
while (!token.empty() && std::isspace(static_cast<unsigned char>(token.back()))) token.remove_suffix(1);
auto key = keyFromName(token);
if (!key) return std::nullopt;
keys.push_back(*key);
if (end == std::string_view::npos) break;
chord.remove_prefix(end + 1);
}
if (plusKey) keys.push_back(Key::Equal);
return keys;
}
bool isModifier(Key key) {
switch (key) {
case Key::LeftShift: case Key::RightShift:
case Key::LeftCtrl: case Key::RightCtrl:
case Key::LeftAlt: case Key::RightAlt:
case Key::LeftSuper: case Key::RightSuper:
return true;
default:
return false;
}
}
} // namespace alvin
+20
View File
@@ -0,0 +1,20 @@
#pragma once
// Entry points every platform directory (src/linux, src/windows, ...) implements.
#include <memory>
#include <vector>
#include "alvin/capture.hpp"
#include "alvin/input.hpp"
#include "alvin/types.hpp"
#include "alvin/window.hpp"
namespace alvin::detail {
Result<std::unique_ptr<Input>> createInput(const InputOptions& options);
Result<std::unique_ptr<Capture>> createCapture(const CaptureOptions& options);
Result<std::unique_ptr<WindowManager>> createWindowManager(WindowBackend backend);
Result<std::vector<Monitor>> listMonitors();
} // namespace alvin::detail
+22
View File
@@ -0,0 +1,22 @@
#pragma once
// Constructors for each Linux backend. Which ones exist depends on the
// ALVIN_WITH_* options chosen at configure time.
#include <memory>
#include "alvin/capture.hpp"
#include "alvin/input.hpp"
#include "alvin/window.hpp"
namespace alvin::linux_impl {
Result<std::unique_ptr<Input>> createWlrInput(const InputOptions& options);
Result<std::unique_ptr<Input>> createUInput(const InputOptions& options);
Result<std::unique_ptr<Input>> createLibEiInput(const InputOptions& options);
Result<std::unique_ptr<Capture>> createPortalCapture(const CaptureOptions& options);
Result<std::unique_ptr<WindowManager>> createHyprlandWindows();
} // namespace alvin::linux_impl
+126
View File
@@ -0,0 +1,126 @@
// Window queries through Hyprland's IPC socket
// ($XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket.sock).
#include <cstdio>
#include <cstdlib>
#include <string>
#include <sys/socket.h>
#include <sys/un.h>
#include "../common/json.hpp"
#include "backends.hpp"
#include "util.hpp"
namespace alvin::linux_impl {
namespace {
Result<std::string> socketPath() {
const char* sig = std::getenv("HYPRLAND_INSTANCE_SIGNATURE");
if (!sig || !*sig) return fail(ErrorCode::NotAvailable, "not running under Hyprland (HYPRLAND_INSTANCE_SIGNATURE unset)");
const char* runtime = std::getenv("XDG_RUNTIME_DIR");
std::string path = std::string(runtime ? runtime : "/run/user/" + std::to_string(getuid())) + "/hypr/" + sig +
"/.socket.sock";
if (path.size() >= sizeof(sockaddr_un::sun_path)) return fail(ErrorCode::InvalidArgument, "Hyprland socket path too long");
return path;
}
// One request per connection, as hyprctl does.
Result<std::string> hyprRequest(const std::string& path, std::string_view request) {
UniqueFd fd(::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0));
if (!fd) return fail(ErrorCode::Io, errnoMessage("socket"));
sockaddr_un addr{};
addr.sun_family = AF_UNIX;
std::snprintf(addr.sun_path, sizeof addr.sun_path, "%s", path.c_str());
if (::connect(fd.get(), reinterpret_cast<sockaddr*>(&addr), sizeof addr) < 0)
return fail(ErrorCode::NotAvailable, errnoMessage("connect to Hyprland IPC"));
if (::send(fd.get(), request.data(), request.size(), MSG_NOSIGNAL) != static_cast<ssize_t>(request.size()))
return fail(ErrorCode::Io, errnoMessage("write to Hyprland IPC"));
std::string reply;
char buf[8192];
while (true) {
ssize_t n = ::recv(fd.get(), buf, sizeof buf, 0);
if (n < 0) {
if (errno == EINTR) continue;
return fail(ErrorCode::Io, errnoMessage("read from Hyprland IPC"));
}
if (n == 0) break;
reply.append(buf, static_cast<std::size_t>(n));
}
return reply;
}
WindowInfo toWindow(const json::Value& c, const std::string& activeAddress) {
WindowInfo w;
std::string address = c["address"].string();
w.id.value = std::strtoull(address.c_str(), nullptr, 16);
w.title = c["title"].string();
w.appId = c["class"].string();
w.bounds = {static_cast<int>(c["at"][0].number()), static_cast<int>(c["at"][1].number()),
static_cast<int>(c["size"][0].number()), static_cast<int>(c["size"][1].number())};
w.pid = static_cast<std::int64_t>(c["pid"].number());
w.focused = !activeAddress.empty() && address == activeAddress;
w.minimized = c["hidden"].boolean();
w.visible = c["visible"].boolean(true) && !w.minimized;
w.fullscreen = c["fullscreen"].number() != 0;
w.workspace = c["workspace"]["name"].string();
return w;
}
class HyprlandWindows final : public WindowManager {
public:
explicit HyprlandWindows(std::string path) : path_(std::move(path)) {}
WindowBackend backend() const override { return WindowBackend::Hyprland; }
Result<std::vector<WindowInfo>> list() override {
auto active = query("j/activewindow");
if (!active) return std::unexpected(active.error());
auto clients = query("j/clients");
if (!clients) return std::unexpected(clients.error());
std::string activeAddress = (*active)["address"].string();
std::vector<WindowInfo> out;
for (const auto& c : clients->array())
if (c["mapped"].boolean(true)) out.push_back(toWindow(c, activeAddress));
return out;
}
Result<std::optional<WindowInfo>> active() override {
auto a = query("j/activewindow");
if (!a) return std::unexpected(a.error());
if (!a->isObject() || a->object().empty()) return std::optional<WindowInfo>{};
return std::optional{toWindow(*a, (*a)["address"].string())};
}
Status focus(WindowId id) override {
char address[32];
std::snprintf(address, sizeof address, "0x%llx", static_cast<unsigned long long>(id.value));
auto reply = hyprRequest(path_, std::string("dispatch focuswindow address:") + address);
if (!reply) return std::unexpected(reply.error());
if (*reply != "ok") return fail(ErrorCode::NotFound, "Hyprland: " + *reply);
return {};
}
private:
Result<json::Value> query(std::string_view request) {
auto reply = hyprRequest(path_, request);
if (!reply) return std::unexpected(reply.error());
auto parsed = json::parse(*reply);
if (!parsed) return fail(ErrorCode::Protocol, "unparseable Hyprland IPC reply to " + std::string(request));
return std::move(*parsed);
}
std::string path_;
};
} // namespace
Result<std::unique_ptr<WindowManager>> createHyprlandWindows() {
auto path = socketPath();
if (!path) return std::unexpected(path.error());
return std::make_unique<HyprlandWindows>(std::move(*path));
}
} // namespace alvin::linux_impl
+113
View File
@@ -0,0 +1,113 @@
#include "keycodes.hpp"
#include <linux/input-event-codes.h>
namespace alvin::linux_impl {
std::uint32_t toEvdev(Key key) {
switch (key) {
case Key::A: return KEY_A; case Key::B: return KEY_B; case Key::C: return KEY_C;
case Key::D: return KEY_D; case Key::E: return KEY_E; case Key::F: return KEY_F;
case Key::G: return KEY_G; case Key::H: return KEY_H; case Key::I: return KEY_I;
case Key::J: return KEY_J; case Key::K: return KEY_K; case Key::L: return KEY_L;
case Key::M: return KEY_M; case Key::N: return KEY_N; case Key::O: return KEY_O;
case Key::P: return KEY_P; case Key::Q: return KEY_Q; case Key::R: return KEY_R;
case Key::S: return KEY_S; case Key::T: return KEY_T; case Key::U: return KEY_U;
case Key::V: return KEY_V; case Key::W: return KEY_W; case Key::X: return KEY_X;
case Key::Y: return KEY_Y; case Key::Z: return KEY_Z;
case Key::Num0: return KEY_0; case Key::Num1: return KEY_1; case Key::Num2: return KEY_2;
case Key::Num3: return KEY_3; case Key::Num4: return KEY_4; case Key::Num5: return KEY_5;
case Key::Num6: return KEY_6; case Key::Num7: return KEY_7; case Key::Num8: return KEY_8;
case Key::Num9: return KEY_9;
case Key::F1: return KEY_F1; case Key::F2: return KEY_F2; case Key::F3: return KEY_F3;
case Key::F4: return KEY_F4; case Key::F5: return KEY_F5; case Key::F6: return KEY_F6;
case Key::F7: return KEY_F7; case Key::F8: return KEY_F8; case Key::F9: return KEY_F9;
case Key::F10: return KEY_F10; case Key::F11: return KEY_F11; case Key::F12: return KEY_F12;
case Key::F13: return KEY_F13; case Key::F14: return KEY_F14; case Key::F15: return KEY_F15;
case Key::F16: return KEY_F16; case Key::F17: return KEY_F17; case Key::F18: return KEY_F18;
case Key::F19: return KEY_F19; case Key::F20: return KEY_F20; case Key::F21: return KEY_F21;
case Key::F22: return KEY_F22; case Key::F23: return KEY_F23; case Key::F24: return KEY_F24;
case Key::Escape: return KEY_ESC;
case Key::Enter: return KEY_ENTER;
case Key::Tab: return KEY_TAB;
case Key::Backspace: return KEY_BACKSPACE;
case Key::Space: return KEY_SPACE;
case Key::Minus: return KEY_MINUS;
case Key::Equal: return KEY_EQUAL;
case Key::LeftBracket: return KEY_LEFTBRACE;
case Key::RightBracket: return KEY_RIGHTBRACE;
case Key::Backslash: return KEY_BACKSLASH;
case Key::Semicolon: return KEY_SEMICOLON;
case Key::Apostrophe: return KEY_APOSTROPHE;
case Key::Grave: return KEY_GRAVE;
case Key::Comma: return KEY_COMMA;
case Key::Period: return KEY_DOT;
case Key::Slash: return KEY_SLASH;
case Key::CapsLock: return KEY_CAPSLOCK;
case Key::NumLock: return KEY_NUMLOCK;
case Key::ScrollLock: return KEY_SCROLLLOCK;
case Key::LeftShift: return KEY_LEFTSHIFT;
case Key::RightShift: return KEY_RIGHTSHIFT;
case Key::LeftCtrl: return KEY_LEFTCTRL;
case Key::RightCtrl: return KEY_RIGHTCTRL;
case Key::LeftAlt: return KEY_LEFTALT;
case Key::RightAlt: return KEY_RIGHTALT;
case Key::LeftSuper: return KEY_LEFTMETA;
case Key::RightSuper: return KEY_RIGHTMETA;
case Key::Menu: return KEY_COMPOSE;
case Key::Insert: return KEY_INSERT;
case Key::Delete: return KEY_DELETE;
case Key::Home: return KEY_HOME;
case Key::End: return KEY_END;
case Key::PageUp: return KEY_PAGEUP;
case Key::PageDown: return KEY_PAGEDOWN;
case Key::Left: return KEY_LEFT;
case Key::Right: return KEY_RIGHT;
case Key::Up: return KEY_UP;
case Key::Down: return KEY_DOWN;
case Key::PrintScreen: return KEY_SYSRQ;
case Key::Pause: return KEY_PAUSE;
case Key::Kp0: return KEY_KP0; case Key::Kp1: return KEY_KP1; case Key::Kp2: return KEY_KP2;
case Key::Kp3: return KEY_KP3; case Key::Kp4: return KEY_KP4; case Key::Kp5: return KEY_KP5;
case Key::Kp6: return KEY_KP6; case Key::Kp7: return KEY_KP7; case Key::Kp8: return KEY_KP8;
case Key::Kp9: return KEY_KP9;
case Key::KpDecimal: return KEY_KPDOT;
case Key::KpDivide: return KEY_KPSLASH;
case Key::KpMultiply: return KEY_KPASTERISK;
case Key::KpSubtract: return KEY_KPMINUS;
case Key::KpAdd: return KEY_KPPLUS;
case Key::KpEnter: return KEY_KPENTER;
case Key::VolumeMute: return KEY_MUTE;
case Key::VolumeDown: return KEY_VOLUMEDOWN;
case Key::VolumeUp: return KEY_VOLUMEUP;
case Key::MediaPlayPause: return KEY_PLAYPAUSE;
case Key::MediaStop: return KEY_STOPCD;
case Key::MediaNext: return KEY_NEXTSONG;
case Key::MediaPrevious: return KEY_PREVIOUSSONG;
case Key::Unknown:
case Key::Count_:
return 0;
}
return 0;
}
std::uint32_t toEvdev(MouseButton button) {
switch (button) {
case MouseButton::Left: return BTN_LEFT;
case MouseButton::Right: return BTN_RIGHT;
case MouseButton::Middle: return BTN_MIDDLE;
case MouseButton::Back: return BTN_SIDE;
case MouseButton::Forward: return BTN_EXTRA;
}
return BTN_LEFT;
}
} // namespace alvin::linux_impl
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include <cstdint>
#include "alvin/keys.hpp"
namespace alvin::linux_impl {
// Linux evdev KEY_* code (0 if unmapped). XKB keycodes are this + 8.
std::uint32_t toEvdev(Key key);
// Linux evdev BTN_* code.
std::uint32_t toEvdev(MouseButton button);
} // namespace alvin::linux_impl
+260
View File
@@ -0,0 +1,260 @@
// Input through libei, connected via the xdg-desktop-portal RemoteDesktop
// interface. This is the sanctioned emulation path on GNOME and KDE Plasma.
// (Hyprland's portal backend does not implement RemoteDesktop; use the wlr
// backend there.)
#include <algorithm>
#include <chrono>
#include <cstring>
#include <optional>
#include <vector>
#include <gio/gio.h>
#include <libei.h>
#include <poll.h>
#include <sys/mman.h>
#include "backends.hpp"
#include "keycodes.hpp"
#include "portal.hpp"
#include "xkb.hpp"
namespace alvin::linux_impl {
namespace {
// org.freedesktop.portal.RemoteDesktop device types.
constexpr std::uint32_t kDeviceKeyboard = 1;
constexpr std::uint32_t kDevicePointer = 2;
constexpr std::uint32_t kPersistUntilRevoked = 2;
Result<std::string> startRemoteDesktop(PortalSession& session, const InputOptions& options) {
const char* iface = PortalSession::kRemoteDesktop;
std::uint32_t version = session.uintProperty(iface, "version").value_or(1);
GVariantBuilder opts;
g_variant_builder_init(&opts, G_VARIANT_TYPE_VARDICT);
g_variant_builder_add(&opts, "{sv}", "types", g_variant_new_uint32(kDeviceKeyboard | kDevicePointer));
if (version >= 2) {
g_variant_builder_add(&opts, "{sv}", "persist_mode", g_variant_new_uint32(kPersistUntilRevoked));
if (!options.restoreToken.empty())
g_variant_builder_add(&opts, "{sv}", "restore_token", g_variant_new_string(options.restoreToken.c_str()));
}
if (auto r = session.request(iface, "SelectDevices", &opts); !r) return std::unexpected(r.error());
GVariantBuilder startOpts;
g_variant_builder_init(&startOpts, G_VARIANT_TYPE_VARDICT);
auto results = session.request(iface, "Start", &startOpts, "");
if (!results) return std::unexpected(results.error());
std::string token;
const gchar* t = nullptr;
if (g_variant_lookup(results->get(), "restore_token", "&s", &t)) token = t;
return token;
}
struct EiDeleter {
void operator()(ei* e) const { ei_unref(e); }
};
class LibEiInput final : public Input {
public:
static Result<std::unique_ptr<Input>> create(const InputOptions& options) {
auto session = PortalSession::create(PortalSession::kRemoteDesktop, options.permissionTimeout);
if (!session) return std::unexpected(session.error());
auto token = startRemoteDesktop(**session, options);
if (!token) return std::unexpected(token.error());
auto fd = (*session)->requestFd(PortalSession::kRemoteDesktop, "ConnectToEIS");
if (!fd) return std::unexpected(fd.error());
std::unique_ptr<LibEiInput> in(new LibEiInput(std::move(*session), std::move(*token)));
if (auto s = in->connect(std::move(*fd)); !s) return std::unexpected(s.error());
return in;
}
~LibEiInput() override {
for (auto* d : devices_) ei_device_unref(d);
if (ei_) ei_disconnect(ei_.get());
}
InputBackend backend() const override { return InputBackend::LibEi; }
std::string restoreToken() const override { return restoreToken_; }
Status moveTo(Point p) override {
return emit(EI_DEVICE_CAP_POINTER_ABSOLUTE, [&](ei_device* d) {
ei_device_pointer_motion_absolute(d, p.x, p.y);
});
}
Status moveBy(int dx, int dy) override {
return emit(EI_DEVICE_CAP_POINTER, [&](ei_device* d) { ei_device_pointer_motion(d, dx, dy); });
}
Status buttonDown(MouseButton b) override {
return emit(EI_DEVICE_CAP_BUTTON, [&](ei_device* d) { ei_device_button_button(d, toEvdev(b), true); });
}
Status buttonUp(MouseButton b) override {
return emit(EI_DEVICE_CAP_BUTTON, [&](ei_device* d) { ei_device_button_button(d, toEvdev(b), false); });
}
Status scroll(int dx, int dy) override {
// libei discrete scroll uses 120 units per wheel notch.
return emit(EI_DEVICE_CAP_SCROLL, [&](ei_device* d) { ei_device_scroll_discrete(d, dx * 120, dy * 120); });
}
Status keyDown(Key k) override { return rawKey(toEvdev(k), true); }
Status keyUp(Key k) override { return rawKey(toEvdev(k), false); }
Status typeText(std::string_view utf8) override {
#ifdef ALVIN_LIBEI_HAS_TEXT
// libei >= 1.6 servers may accept text directly, bypassing layouts.
if (findDevice(EI_DEVICE_CAP_TEXT)) {
std::string copy(utf8);
return emit(EI_DEVICE_CAP_TEXT, [&](ei_device* d) { ei_device_text_utf8(d, copy.c_str()); });
}
#endif
auto text = decodeUtf8(utf8);
if (!text) return std::unexpected(text.error());
auto keymap = keyboardKeymap();
if (!keymap) return std::unexpected(keymap.error());
auto strokes = (*keymap)->strokesFor(*text);
if (!strokes) return std::unexpected(strokes.error());
for (const auto& s : *strokes) {
for (auto m : s.modifiers)
if (auto r = rawKey(m, true); !r) return r;
auto r = rawKey(s.key, true);
if (r) r = rawKey(s.key, false);
for (auto m : s.modifiers) (void)rawKey(m, false);
if (!r) return r;
pause();
}
return {};
}
private:
LibEiInput(std::unique_ptr<PortalSession> session, std::string token)
: session_(std::move(session)), restoreToken_(std::move(token)) {}
Status connect(UniqueFd fd) {
ei_.reset(ei_new_sender(this));
if (!ei_) return fail(ErrorCode::Unknown, "ei_new_sender failed");
ei_configure_name(ei_.get(), "alvin");
if (int rc = ei_setup_backend_fd(ei_.get(), fd.release()); rc < 0)
return fail(ErrorCode::Io, "ei_setup_backend_fd failed");
// Wait until the server has given us pointer and keyboard devices we may emulate on.
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
while (!(findDevice(EI_DEVICE_CAP_POINTER_ABSOLUTE) && findDevice(EI_DEVICE_CAP_KEYBOARD))) {
auto left = std::chrono::duration_cast<std::chrono::milliseconds>(deadline - std::chrono::steady_clock::now());
if (left.count() <= 0) {
if (findDevice(EI_DEVICE_CAP_POINTER) || findDevice(EI_DEVICE_CAP_KEYBOARD)) break; // partial is usable
return fail(ErrorCode::Timeout, "EIS server did not provide input devices");
}
pollfd pfd{ei_get_fd(ei_.get()), POLLIN, 0};
::poll(&pfd, 1, static_cast<int>(left.count()));
if (auto s = dispatch(); !s) return s;
}
return {};
}
Status dispatch() {
ei_dispatch(ei_.get());
while (ei_event* ev = ei_get_event(ei_.get())) {
ei_device* dev = ei_event_get_device(ev);
switch (ei_event_get_type(ev)) {
case EI_EVENT_SEAT_ADDED:
ei_seat_bind_capabilities(ei_event_get_seat(ev), EI_DEVICE_CAP_POINTER, EI_DEVICE_CAP_POINTER_ABSOLUTE,
EI_DEVICE_CAP_BUTTON, EI_DEVICE_CAP_SCROLL, EI_DEVICE_CAP_KEYBOARD,
#ifdef ALVIN_LIBEI_HAS_TEXT
EI_DEVICE_CAP_TEXT,
#endif
nullptr);
break;
case EI_EVENT_DEVICE_ADDED:
devices_.push_back(ei_device_ref(dev));
break;
case EI_EVENT_DEVICE_REMOVED:
std::erase_if(resumed_, [&](ei_device* d) { return d == dev; });
std::erase_if(devices_, [&](ei_device* d) {
if (d != dev) return false;
ei_device_unref(d);
return true;
});
break;
case EI_EVENT_DEVICE_RESUMED:
ei_device_start_emulating(dev, ++sequence_);
resumed_.push_back(dev);
break;
case EI_EVENT_DEVICE_PAUSED:
std::erase_if(resumed_, [&](ei_device* d) { return d == dev; });
break;
case EI_EVENT_DISCONNECT:
disconnected_ = true;
break;
default:
break;
}
ei_event_unref(ev);
}
if (disconnected_) return fail(ErrorCode::NotAvailable, "the EIS server disconnected");
return {};
}
ei_device* findDevice(ei_device_capability cap) const {
for (auto* d : resumed_)
if (ei_device_has_capability(d, cap)) return d;
return nullptr;
}
template <class F>
Status emit(ei_device_capability cap, F&& send) {
if (auto s = dispatch(); !s) return s;
ei_device* d = findDevice(cap);
if (!d) return fail(ErrorCode::Unsupported, "the EIS server granted no device with the needed capability");
send(d);
ei_device_frame(d, ei_now(ei_.get()));
return {};
}
Status rawKey(std::uint32_t code, bool down) {
if (code == 0) return fail(ErrorCode::InvalidArgument, "key has no Linux mapping");
return emit(EI_DEVICE_CAP_KEYBOARD, [&](ei_device* d) { ei_device_keyboard_key(d, code, down); });
}
// The keymap the server attached to its keyboard device, else the XKB defaults.
Result<const XkbKeymap*> keyboardKeymap() {
if (keymap_) return &*keymap_;
if (ei_device* d = findDevice(EI_DEVICE_CAP_KEYBOARD)) {
if (ei_keymap* km = ei_device_keyboard_get_keymap(d); km && ei_keymap_get_type(km) == EI_KEYMAP_TYPE_XKB) {
std::size_t size = ei_keymap_get_size(km);
void* map = mmap(nullptr, size, PROT_READ, MAP_PRIVATE, ei_keymap_get_fd(km), 0);
if (map != MAP_FAILED) {
auto parsed = XkbKeymap::fromString({static_cast<const char*>(map), strnlen(static_cast<const char*>(map), size)});
munmap(map, size);
if (parsed) keymap_ = std::move(*parsed);
}
}
}
if (!keymap_) {
auto fallback = XkbKeymap::fromNames();
if (!fallback) return std::unexpected(fallback.error());
keymap_ = std::move(*fallback);
}
return &*keymap_;
}
std::unique_ptr<PortalSession> session_;
std::string restoreToken_;
std::unique_ptr<ei, EiDeleter> ei_;
std::vector<ei_device*> devices_; // owned references
std::vector<ei_device*> resumed_; // subset of devices_ we may emulate on
std::uint32_t sequence_ = 0;
bool disconnected_ = false;
std::optional<XkbKeymap> keymap_;
};
} // namespace
Result<std::unique_ptr<Input>> createLibEiInput(const InputOptions& options) { return LibEiInput::create(options); }
} // namespace alvin::linux_impl
+347
View File
@@ -0,0 +1,347 @@
// Screen/window capture through the xdg-desktop-portal ScreenCast interface.
// The portal asks the user what to share (unless a restore token lets it skip
// that), then hands us a PipeWire remote from which we pull video frames.
#include <algorithm>
#include <condition_variable>
#include <cstring>
#include <mutex>
#include <gio/gio.h>
#include <pipewire/pipewire.h>
#include <spa/buffer/meta.h>
#include <spa/param/video/format-utils.h>
#include <spa/pod/builder.h>
#include "backends.hpp"
#include "portal.hpp"
namespace alvin::linux_impl {
namespace {
// Portal enum values (org.freedesktop.portal.ScreenCast).
constexpr std::uint32_t kCursorHidden = 1;
constexpr std::uint32_t kCursorEmbedded = 2;
constexpr std::uint32_t kPersistUntilRevoked = 2;
struct StreamInfo {
std::uint32_t nodeId = 0;
Rect desktopRect; // position is (0,0) when the portal doesn't report one (e.g. windows)
std::string restoreToken;
};
Result<StreamInfo> startScreenCast(PortalSession& session, const CaptureOptions& options) {
const char* iface = PortalSession::kScreenCast;
std::uint32_t version = session.uintProperty(iface, "version").value_or(1);
std::uint32_t available = session.uintProperty(iface, "AvailableSourceTypes").value_or(1);
std::uint32_t types = static_cast<std::uint32_t>(options.source) & available;
if (types == 0) return fail(ErrorCode::Unsupported, "the portal cannot capture the requested source type");
GVariantBuilder opts;
g_variant_builder_init(&opts, G_VARIANT_TYPE_VARDICT);
g_variant_builder_add(&opts, "{sv}", "types", g_variant_new_uint32(types));
g_variant_builder_add(&opts, "{sv}", "multiple", g_variant_new_boolean(FALSE));
if (version >= 2) {
std::uint32_t modes = session.uintProperty(iface, "AvailableCursorModes").value_or(0);
std::uint32_t want = options.showCursor ? kCursorEmbedded : kCursorHidden;
if (modes & want) g_variant_builder_add(&opts, "{sv}", "cursor_mode", g_variant_new_uint32(want));
}
if (version >= 4) {
g_variant_builder_add(&opts, "{sv}", "persist_mode", g_variant_new_uint32(kPersistUntilRevoked));
if (!options.restoreToken.empty())
g_variant_builder_add(&opts, "{sv}", "restore_token", g_variant_new_string(options.restoreToken.c_str()));
}
if (auto r = session.request(iface, "SelectSources", &opts); !r) return std::unexpected(r.error());
GVariantBuilder startOpts;
g_variant_builder_init(&startOpts, G_VARIANT_TYPE_VARDICT);
auto results = session.request(iface, "Start", &startOpts, "");
if (!results) return std::unexpected(results.error());
StreamInfo info;
const gchar* token = nullptr;
if (g_variant_lookup(results->get(), "restore_token", "&s", &token)) info.restoreToken = token;
GVariant* streamsRaw = g_variant_lookup_value(results->get(), "streams", G_VARIANT_TYPE("a(ua{sv})"));
if (!streamsRaw) return fail(ErrorCode::Protocol, "ScreenCast.Start returned no streams");
VariantPtr streams(streamsRaw);
if (g_variant_n_children(streams.get()) == 0) return fail(ErrorCode::Protocol, "ScreenCast.Start returned no streams");
GVariant* props = nullptr;
g_variant_get_child(streams.get(), 0, "(u@a{sv})", &info.nodeId, &props);
VariantPtr propsPtr(props);
gint32 x = 0, y = 0, w = 0, h = 0;
g_variant_lookup(props, "position", "(ii)", &x, &y);
g_variant_lookup(props, "size", "(ii)", &w, &h);
info.desktopRect = {x, y, w, h};
return info;
}
// Converts one row of a PipeWire video format to BGRA.
void convertRow(spa_video_format fmt, const std::uint8_t* src, std::uint8_t* dst, int width) {
switch (fmt) {
case SPA_VIDEO_FORMAT_BGRA:
std::memcpy(dst, src, static_cast<std::size_t>(width) * 4);
return;
case SPA_VIDEO_FORMAT_BGRx:
for (int x = 0; x < width; ++x, src += 4, dst += 4) dst[0] = src[0], dst[1] = src[1], dst[2] = src[2], dst[3] = 255;
return;
case SPA_VIDEO_FORMAT_RGBA:
case SPA_VIDEO_FORMAT_RGBx: {
bool alpha = fmt == SPA_VIDEO_FORMAT_RGBA;
for (int x = 0; x < width; ++x, src += 4, dst += 4)
dst[0] = src[2], dst[1] = src[1], dst[2] = src[0], dst[3] = alpha ? src[3] : 255;
return;
}
case SPA_VIDEO_FORMAT_ARGB:
case SPA_VIDEO_FORMAT_xRGB: {
bool alpha = fmt == SPA_VIDEO_FORMAT_ARGB;
for (int x = 0; x < width; ++x, src += 4, dst += 4)
dst[0] = src[3], dst[1] = src[2], dst[2] = src[1], dst[3] = alpha ? src[0] : 255;
return;
}
case SPA_VIDEO_FORMAT_ABGR:
case SPA_VIDEO_FORMAT_xBGR: {
bool alpha = fmt == SPA_VIDEO_FORMAT_ABGR;
for (int x = 0; x < width; ++x, src += 4, dst += 4)
dst[0] = src[1], dst[1] = src[2], dst[2] = src[3], dst[3] = alpha ? src[0] : 255;
return;
}
default:
std::memset(dst, 0, static_cast<std::size_t>(width) * 4);
}
}
class PipeWireGuard {
public:
PipeWireGuard() { pw_init(nullptr, nullptr); }
~PipeWireGuard() { pw_deinit(); }
};
class PortalCapture final : public Capture {
public:
static Result<std::unique_ptr<Capture>> create(const CaptureOptions& options) {
auto session = PortalSession::create(PortalSession::kScreenCast, options.permissionTimeout);
if (!session) return std::unexpected(session.error());
auto stream = startScreenCast(**session, options);
if (!stream) return std::unexpected(stream.error());
auto fd = (*session)->requestFd(PortalSession::kScreenCast, "OpenPipeWireRemote");
if (!fd) return std::unexpected(fd.error());
std::unique_ptr<PortalCapture> cap(new PortalCapture(std::move(*session), *stream));
if (auto s = cap->connect(std::move(*fd)); !s) return std::unexpected(s.error());
return cap;
}
~PortalCapture() override {
if (loop_) pw_thread_loop_lock(loop_);
if (stream_) pw_stream_destroy(stream_);
if (core_) pw_core_disconnect(core_);
if (loop_) {
pw_thread_loop_unlock(loop_);
pw_thread_loop_stop(loop_);
}
if (context_) pw_context_destroy(context_);
if (loop_) pw_thread_loop_destroy(loop_);
}
CaptureBackend backend() const override { return CaptureBackend::Portal; }
Result<Frame> grab(std::chrono::milliseconds timeout) override { return wait(timeout, 0); }
Result<Frame> nextFrame(std::chrono::milliseconds timeout) override {
std::uint64_t seen;
{
std::lock_guard lock(mutex_);
seen = lastReturned_;
}
return wait(timeout, seen);
}
std::string restoreToken() const override { return info_.restoreToken; }
private:
PortalCapture(std::unique_ptr<PortalSession> session, StreamInfo info)
: session_(std::move(session)), info_(std::move(info)) {}
Result<Frame> wait(std::chrono::milliseconds timeout, std::uint64_t after) {
std::unique_lock lock(mutex_);
bool ok = frameReady_.wait_for(lock, timeout, [&] { return sequence_ > after || !error_.empty(); });
if (!error_.empty()) return fail(ErrorCode::Io, error_);
if (!ok) return fail(ErrorCode::Timeout, "no frame received from PipeWire");
lastReturned_ = sequence_;
return latest_;
}
Status connect(UniqueFd fd) {
loop_ = pw_thread_loop_new("alvin-capture", nullptr);
if (!loop_) return fail(ErrorCode::Unknown, "pw_thread_loop_new failed");
context_ = pw_context_new(pw_thread_loop_get_loop(loop_), nullptr, 0);
if (!context_) return fail(ErrorCode::Unknown, "pw_context_new failed");
if (pw_thread_loop_start(loop_) < 0) return fail(ErrorCode::Unknown, "pw_thread_loop_start failed");
pw_thread_loop_lock(loop_);
auto status = connectLocked(std::move(fd));
pw_thread_loop_unlock(loop_);
return status;
}
Status connectLocked(UniqueFd fd) {
core_ = pw_context_connect_fd(context_, fd.release(), nullptr, 0);
if (!core_) return fail(ErrorCode::Io, "cannot connect to the PipeWire remote");
stream_ = pw_stream_new(core_, "alvin",
pw_properties_new(PW_KEY_MEDIA_TYPE, "Video", PW_KEY_MEDIA_CATEGORY, "Capture",
PW_KEY_MEDIA_ROLE, "Screen", nullptr));
if (!stream_) return fail(ErrorCode::Unknown, "pw_stream_new failed");
static const pw_stream_events events = [] {
pw_stream_events e{};
e.version = PW_VERSION_STREAM_EVENTS;
e.state_changed = &PortalCapture::onStateChanged;
e.param_changed = &PortalCapture::onParamChanged;
e.process = &PortalCapture::onProcess;
return e;
}();
pw_stream_add_listener(stream_, &listener_, &events, this);
// Offer only shared-memory friendly formats (no DMA-BUF modifiers), so the
// compositor hands us CPU-mappable buffers.
std::uint8_t buffer[1024];
spa_pod_builder b = SPA_POD_BUILDER_INIT(buffer, sizeof buffer);
spa_rectangle defSize{1920, 1080}, minSize{1, 1}, maxSize{16384, 16384};
spa_fraction defRate{0, 1}, minRate{0, 1}, maxRate{1000, 1};
const spa_pod* params[1];
params[0] = static_cast<const spa_pod*>(spa_pod_builder_add_object(
&b, SPA_TYPE_OBJECT_Format, SPA_PARAM_EnumFormat,
SPA_FORMAT_mediaType, SPA_POD_Id(SPA_MEDIA_TYPE_video),
SPA_FORMAT_mediaSubtype, SPA_POD_Id(SPA_MEDIA_SUBTYPE_raw),
SPA_FORMAT_VIDEO_format,
SPA_POD_CHOICE_ENUM_Id(9, SPA_VIDEO_FORMAT_BGRx, SPA_VIDEO_FORMAT_BGRx, SPA_VIDEO_FORMAT_BGRA,
SPA_VIDEO_FORMAT_RGBx, SPA_VIDEO_FORMAT_RGBA, SPA_VIDEO_FORMAT_xRGB,
SPA_VIDEO_FORMAT_ARGB, SPA_VIDEO_FORMAT_xBGR, SPA_VIDEO_FORMAT_ABGR),
SPA_FORMAT_VIDEO_size, SPA_POD_CHOICE_RANGE_Rectangle(&defSize, &minSize, &maxSize),
SPA_FORMAT_VIDEO_framerate, SPA_POD_CHOICE_RANGE_Fraction(&defRate, &minRate, &maxRate)));
int rc = pw_stream_connect(stream_, PW_DIRECTION_INPUT, info_.nodeId,
static_cast<pw_stream_flags>(PW_STREAM_FLAG_AUTOCONNECT | PW_STREAM_FLAG_MAP_BUFFERS),
params, 1);
if (rc < 0) return fail(ErrorCode::Io, "pw_stream_connect failed");
return {};
}
static void onStateChanged(void* data, pw_stream_state, pw_stream_state state, const char* error) {
auto* self = static_cast<PortalCapture*>(data);
std::lock_guard lock(self->mutex_);
if (state == PW_STREAM_STATE_ERROR || (state == PW_STREAM_STATE_UNCONNECTED && self->sequence_ > 0)) {
self->error_ = error ? std::string("PipeWire stream error: ") + error : "the capture stream ended";
self->frameReady_.notify_all();
}
}
static void onParamChanged(void* data, std::uint32_t id, const spa_pod* param) {
auto* self = static_cast<PortalCapture*>(data);
if (!param || id != SPA_PARAM_Format) return;
std::uint32_t mediaType = 0, mediaSubtype = 0;
if (spa_format_parse(param, &mediaType, &mediaSubtype) < 0 || mediaType != SPA_MEDIA_TYPE_video ||
mediaSubtype != SPA_MEDIA_SUBTYPE_raw)
return;
spa_video_info_raw raw{};
if (spa_format_video_raw_parse(param, &raw) < 0) return;
self->format_ = raw.format;
self->width_ = static_cast<int>(raw.size.width);
self->height_ = static_cast<int>(raw.size.height);
std::uint8_t buffer[1024];
spa_pod_builder b = SPA_POD_BUILDER_INIT(buffer, sizeof buffer);
const spa_pod* params[3];
params[0] = static_cast<const spa_pod*>(spa_pod_builder_add_object(
&b, SPA_TYPE_OBJECT_ParamBuffers, SPA_PARAM_Buffers,
SPA_PARAM_BUFFERS_dataType, SPA_POD_CHOICE_FLAGS_Int((1 << SPA_DATA_MemPtr) | (1 << SPA_DATA_MemFd))));
params[1] = static_cast<const spa_pod*>(spa_pod_builder_add_object(
&b, SPA_TYPE_OBJECT_ParamMeta, SPA_PARAM_Meta,
SPA_PARAM_META_type, SPA_POD_Id(SPA_META_Header),
SPA_PARAM_META_size, SPA_POD_Int(sizeof(spa_meta_header))));
params[2] = static_cast<const spa_pod*>(spa_pod_builder_add_object(
&b, SPA_TYPE_OBJECT_ParamMeta, SPA_PARAM_Meta,
SPA_PARAM_META_type, SPA_POD_Id(SPA_META_VideoCrop),
SPA_PARAM_META_size, SPA_POD_Int(sizeof(spa_meta_region))));
pw_stream_update_params(self->stream_, params, 3);
}
static void onProcess(void* data) {
auto* self = static_cast<PortalCapture*>(data);
pw_buffer* b = pw_stream_dequeue_buffer(self->stream_);
if (!b) return;
// Skip to the newest queued buffer; we only care about the latest image.
while (pw_buffer* next = pw_stream_dequeue_buffer(self->stream_)) {
pw_stream_queue_buffer(self->stream_, b);
b = next;
}
self->consume(b->buffer);
pw_stream_queue_buffer(self->stream_, b);
}
void consume(spa_buffer* buf) {
if (auto* h = static_cast<spa_meta_header*>(spa_buffer_find_meta_data(buf, SPA_META_Header, sizeof(spa_meta_header))))
if (h->flags & SPA_META_HEADER_FLAG_CORRUPTED) return;
const spa_data& d = buf->datas[0];
// Empty chunks carry cursor-only updates; no new image.
if (!d.data || !d.chunk || d.chunk->size == 0 || (d.chunk->flags & SPA_CHUNK_FLAG_CORRUPTED)) return;
Rect region{0, 0, width_, height_};
if (auto* crop = static_cast<spa_meta_region*>(spa_buffer_find_meta_data(buf, SPA_META_VideoCrop, sizeof(spa_meta_region))))
if (spa_meta_region_is_valid(crop))
region = {crop->region.position.x, crop->region.position.y, static_cast<int>(crop->region.size.width),
static_cast<int>(crop->region.size.height)};
std::size_t stride = d.chunk->stride > 0 ? static_cast<std::size_t>(d.chunk->stride) : static_cast<std::size_t>(width_) * 4;
const auto* base = static_cast<const std::uint8_t*>(d.data) + d.chunk->offset;
std::size_t needed = stride * static_cast<std::size_t>(region.y + region.height);
if (region.empty() || needed > d.maxsize - d.chunk->offset) return;
if (scratch_.width() != region.width || scratch_.height() != region.height)
scratch_ = Frame(region.width, region.height);
for (int y = 0; y < region.height; ++y)
convertRow(format_, base + (region.y + y) * stride + region.x * 4, scratch_.row(y), region.width);
Rect desk = info_.desktopRect;
if (desk.empty()) desk = {0, 0, region.width, region.height};
scratch_.setDesktopRect(desk);
scratch_.setTimestampNs(static_cast<std::int64_t>(pw_stream_get_nsec(stream_)));
std::lock_guard lock(mutex_);
std::swap(latest_, scratch_);
++sequence_;
frameReady_.notify_all();
}
PipeWireGuard pw_;
std::unique_ptr<PortalSession> session_; // closing it ends the stream
StreamInfo info_;
pw_thread_loop* loop_ = nullptr;
pw_context* context_ = nullptr;
pw_core* core_ = nullptr;
pw_stream* stream_ = nullptr;
spa_hook listener_{};
// Touched only on the PipeWire thread.
spa_video_format format_ = SPA_VIDEO_FORMAT_UNKNOWN;
int width_ = 0, height_ = 0;
Frame scratch_;
std::mutex mutex_;
std::condition_variable frameReady_;
Frame latest_;
std::uint64_t sequence_ = 0;
std::uint64_t lastReturned_ = 0;
std::string error_;
};
} // namespace
Result<std::unique_ptr<Capture>> createPortalCapture(const CaptureOptions& options) {
return PortalCapture::create(options);
}
} // namespace alvin::linux_impl
+107
View File
@@ -0,0 +1,107 @@
// Backend selection for Linux.
#include <cstdlib>
#include <string>
#include "../common/platform.hpp"
#include "backends.hpp"
#include "wayland.hpp"
namespace alvin::detail {
namespace {
bool envSet(const char* name) {
const char* v = std::getenv(name);
return v && *v;
}
Result<InputBackend> inputBackendFromEnv() {
const char* v = std::getenv("ALVIN_INPUT_BACKEND");
if (!v || !*v) return InputBackend::Auto;
std::string s = v;
if (s == "wlr") return InputBackend::WlrVirtual;
if (s == "libei") return InputBackend::LibEi;
if (s == "uinput") return InputBackend::UInput;
return fail(ErrorCode::InvalidArgument, "unknown ALVIN_INPUT_BACKEND '" + s + "'");
}
Result<std::unique_ptr<Input>> createInputBackend(InputBackend backend, const InputOptions& options) {
switch (backend) {
#ifdef ALVIN_HAVE_WAYLAND
case InputBackend::WlrVirtual: return linux_impl::createWlrInput(options);
#endif
#ifdef ALVIN_HAVE_LIBEI
case InputBackend::LibEi: return linux_impl::createLibEiInput(options);
#endif
#ifdef ALVIN_HAVE_UINPUT
case InputBackend::UInput: return linux_impl::createUInput(options);
#endif
default:
return fail(ErrorCode::Unsupported,
"input backend '" + std::string(toString(backend)) + "' is not compiled into this build");
}
}
} // namespace
Result<std::unique_ptr<Input>> createInput(const InputOptions& options) {
InputBackend chosen = options.backend;
if (chosen == InputBackend::Auto) {
auto env = inputBackendFromEnv();
if (!env) return std::unexpected(env.error());
chosen = *env;
}
if (chosen != InputBackend::Auto) return createInputBackend(chosen, options);
// Prefer what needs no dialog or extra privileges, then the portal, then uinput.
std::string tried;
InputBackend order[] = {InputBackend::WlrVirtual, InputBackend::LibEi, InputBackend::UInput};
for (InputBackend b : order) {
if (b == InputBackend::WlrVirtual && !envSet("WAYLAND_DISPLAY")) continue;
auto in = createInputBackend(b, options);
if (in) return in;
// A user saying "no" to the portal is an answer, not a reason to try something else.
auto code = in.error().code;
if (b == InputBackend::LibEi && (code == ErrorCode::Cancelled || code == ErrorCode::PermissionDenied)) return in;
tried += "\n " + std::string(toString(b)) + ": " + in.error().message;
}
return fail(ErrorCode::NotAvailable, "no usable input backend:" + tried);
}
Result<std::unique_ptr<Capture>> createCapture(const CaptureOptions& options) {
switch (options.backend) {
case CaptureBackend::Auto:
case CaptureBackend::Portal:
#ifdef ALVIN_HAVE_PORTAL
return linux_impl::createPortalCapture(options);
#else
return fail(ErrorCode::Unsupported, "portal capture is not compiled into this build");
#endif
default:
return fail(ErrorCode::Unsupported, "capture backend '" + std::string(toString(options.backend)) +
"' is not available on Linux");
}
}
Result<std::unique_ptr<WindowManager>> createWindowManager(WindowBackend backend) {
switch (backend) {
case WindowBackend::Auto:
case WindowBackend::Hyprland:
return linux_impl::createHyprlandWindows();
default:
return fail(ErrorCode::Unsupported,
"window backend '" + std::string(toString(backend)) + "' is not available on Linux");
}
}
Result<std::vector<Monitor>> listMonitors() {
#ifdef ALVIN_HAVE_WAYLAND
auto wl = linux_impl::WaylandConnection::connect();
if (!wl) return std::unexpected(wl.error());
return (*wl)->monitors();
#else
return fail(ErrorCode::Unsupported, "monitor enumeration needs Wayland support in this build");
#endif
}
} // namespace alvin::detail
+232
View File
@@ -0,0 +1,232 @@
#include "portal.hpp"
#include <algorithm>
#include <gio/gio.h>
#include <gio/gunixfdlist.h>
#include <unistd.h>
namespace alvin::linux_impl {
namespace {
constexpr const char* kBusName = "org.freedesktop.portal.Desktop";
constexpr const char* kObjectPath = "/org/freedesktop/portal/desktop";
struct ErrorGuard {
GError* error = nullptr;
~ErrorGuard() {
if (error) g_error_free(error);
}
std::string message() const { return error ? error->message : "unknown error"; }
ErrorCode code() const {
if (error && g_error_matches(error, G_DBUS_ERROR, G_DBUS_ERROR_ACCESS_DENIED)) return ErrorCode::PermissionDenied;
if (error && (g_error_matches(error, G_DBUS_ERROR, G_DBUS_ERROR_UNKNOWN_METHOD) ||
g_error_matches(error, G_DBUS_ERROR, G_DBUS_ERROR_UNKNOWN_INTERFACE) ||
g_error_matches(error, G_DBUS_ERROR, G_DBUS_ERROR_SERVICE_UNKNOWN)))
return ErrorCode::NotAvailable;
return ErrorCode::Protocol;
}
};
// Makes `ctx` the thread-default context for the current scope so that signal
// subscriptions made here are dispatched when we iterate it.
struct ContextScope {
GMainContext* ctx;
explicit ContextScope(GMainContext* c) : ctx(c) { g_main_context_push_thread_default(ctx); }
~ContextScope() { g_main_context_pop_thread_default(ctx); }
};
} // namespace
void VariantDeleter::operator()(GVariant* v) const {
if (v) g_variant_unref(v);
}
Result<std::unique_ptr<PortalSession>> PortalSession::create(const char* iface, std::chrono::milliseconds timeout) {
std::unique_ptr<PortalSession> s(new PortalSession);
s->timeout_ = timeout;
s->context_ = g_main_context_new();
ContextScope scope(s->context_);
// A private connection, so the session's lifetime is tied to this object.
ErrorGuard err;
gchar* address = g_dbus_address_get_for_bus_sync(G_BUS_TYPE_SESSION, nullptr, &err.error);
if (!address) return fail(ErrorCode::NotAvailable, "no D-Bus session bus: " + err.message());
s->bus_ = g_dbus_connection_new_for_address_sync(
address,
static_cast<GDBusConnectionFlags>(G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT |
G_DBUS_CONNECTION_FLAGS_MESSAGE_BUS_CONNECTION),
nullptr, nullptr, &err.error);
g_free(address);
if (!s->bus_) return fail(ErrorCode::NotAvailable, "cannot connect to D-Bus session bus: " + err.message());
// ":1.42" -> "1_42", as used in portal request object paths.
std::string sender = g_dbus_connection_get_unique_name(s->bus_) + 1;
std::replace(sender.begin(), sender.end(), '.', '_');
s->senderPath_ = sender;
GVariantBuilder opts;
g_variant_builder_init(&opts, G_VARIANT_TYPE_VARDICT);
std::string sessionToken = s->newToken();
g_variant_builder_add(&opts, "{sv}", "session_handle_token", g_variant_new_string(sessionToken.c_str()));
std::string token = s->newToken();
g_variant_builder_add(&opts, "{sv}", "handle_token", g_variant_new_string(token.c_str()));
std::string path = s->requestPath(token);
unsigned sub = g_dbus_connection_signal_subscribe(
s->bus_, kBusName, "org.freedesktop.portal.Request", "Response", path.c_str(), nullptr,
G_DBUS_SIGNAL_FLAGS_NO_MATCH_RULE,
&PortalSession::onResponse,
s.get(), nullptr);
VariantPtr reply(g_dbus_connection_call_sync(s->bus_, kBusName, kObjectPath, iface, "CreateSession",
g_variant_new("(a{sv})", &opts), G_VARIANT_TYPE("(o)"),
G_DBUS_CALL_FLAGS_NONE, -1, nullptr, &err.error));
if (!reply) {
g_dbus_connection_signal_unsubscribe(s->bus_, sub);
return fail(err.code(), std::string(iface) + ".CreateSession failed: " + err.message());
}
auto results = s->waitForResponse(path, std::move(reply), sub);
if (!results) return std::unexpected(results.error());
const gchar* handle = nullptr;
if (!g_variant_lookup(results->get(), "session_handle", "&s", &handle))
return fail(ErrorCode::Protocol, "CreateSession response lacks session_handle");
s->sessionHandle_ = handle;
return s;
}
PortalSession::~PortalSession() {
if (bus_) {
if (!sessionHandle_.empty()) {
VariantPtr r(g_dbus_connection_call_sync(bus_, kBusName, sessionHandle_.c_str(),
"org.freedesktop.portal.Session", "Close", nullptr, nullptr,
G_DBUS_CALL_FLAGS_NONE, 1000, nullptr, nullptr));
}
g_dbus_connection_close_sync(bus_, nullptr, nullptr);
g_object_unref(bus_);
}
if (context_) g_main_context_unref(context_);
}
void PortalSession::onResponse(GDBusConnection*, const char*, const char*, const char*, const char*, GVariant* params,
void* data) {
auto* self = static_cast<PortalSession*>(data);
GVariant* results = nullptr;
g_variant_get(params, "(u@a{sv})", &self->responseCode_, &results);
self->responseResults_.reset(results);
self->responded_ = true;
}
std::string PortalSession::newToken() {
return "alvin" + std::to_string(::getpid()) + "_" + std::to_string(++tokenCounter_);
}
std::string PortalSession::requestPath(const std::string& token) const {
return std::string(kObjectPath) + "/request/" + senderPath_ + "/" + token;
}
Result<std::uint32_t> PortalSession::uintProperty(const char* iface, const char* name) {
ErrorGuard err;
VariantPtr reply(g_dbus_connection_call_sync(bus_, kBusName, kObjectPath, "org.freedesktop.DBus.Properties", "Get",
g_variant_new("(ss)", iface, name), G_VARIANT_TYPE("(v)"),
G_DBUS_CALL_FLAGS_NONE, -1, nullptr, &err.error));
if (!reply) return fail(err.code(), std::string("cannot read ") + iface + "." + name + ": " + err.message());
GVariant* inner = nullptr;
g_variant_get(reply.get(), "(v)", &inner);
VariantPtr value(inner);
if (!g_variant_is_of_type(value.get(), G_VARIANT_TYPE_UINT32))
return fail(ErrorCode::Protocol, std::string(iface) + "." + name + " is not a uint32");
return g_variant_get_uint32(value.get());
}
Result<VariantPtr> PortalSession::request(const char* iface, const char* method, GVariantBuilder* options,
const char* parentWindow) {
ContextScope scope(context_);
std::string token = newToken();
g_variant_builder_add(options, "{sv}", "handle_token", g_variant_new_string(token.c_str()));
std::string path = requestPath(token);
responded_ = false;
unsigned sub = g_dbus_connection_signal_subscribe(
bus_, kBusName, "org.freedesktop.portal.Request", "Response", path.c_str(), nullptr,
G_DBUS_SIGNAL_FLAGS_NO_MATCH_RULE,
&PortalSession::onResponse,
this, nullptr);
GVariant* params = parentWindow
? g_variant_new("(osa{sv})", sessionHandle_.c_str(), parentWindow, options)
: g_variant_new("(oa{sv})", sessionHandle_.c_str(), options);
ErrorGuard err;
VariantPtr reply(g_dbus_connection_call_sync(bus_, kBusName, kObjectPath, iface, method, params,
G_VARIANT_TYPE("(o)"), G_DBUS_CALL_FLAGS_NONE, -1, nullptr,
&err.error));
if (!reply) {
g_dbus_connection_signal_unsubscribe(bus_, sub);
return fail(err.code(), std::string(iface) + "." + method + " failed: " + err.message());
}
return waitForResponse(path, std::move(reply), sub);
}
Result<VariantPtr> PortalSession::waitForResponse(const std::string& path, VariantPtr callResult, unsigned sub) {
// Old portal versions ignore handle_token and return a different request path.
const gchar* actual = nullptr;
g_variant_get(callResult.get(), "(&o)", &actual);
if (path != actual) {
g_dbus_connection_signal_unsubscribe(bus_, sub);
return fail(ErrorCode::Protocol, "xdg-desktop-portal is too old (request path mismatch)");
}
bool timedOut = false;
GSource* timer = g_timeout_source_new(static_cast<guint>(std::max<long long>(timeout_.count(), 1)));
g_source_set_callback(
timer,
[](gpointer data) -> gboolean {
*static_cast<bool*>(data) = true;
return G_SOURCE_REMOVE;
},
&timedOut, nullptr);
g_source_attach(timer, context_);
while (!responded_ && !timedOut) g_main_context_iteration(context_, TRUE);
g_source_destroy(timer);
g_source_unref(timer);
g_dbus_connection_signal_unsubscribe(bus_, sub);
if (!responded_) {
// Dismiss the dialog so it doesn't linger on screen.
VariantPtr r(g_dbus_connection_call_sync(bus_, kBusName, path.c_str(), "org.freedesktop.portal.Request",
"Close", nullptr, nullptr, G_DBUS_CALL_FLAGS_NONE, 1000, nullptr,
nullptr));
return fail(ErrorCode::Timeout, "timed out waiting for the portal dialog");
}
responded_ = false;
switch (responseCode_) {
case 0: return std::move(responseResults_);
case 1: return fail(ErrorCode::Cancelled, "the portal request was cancelled by the user");
default: return fail(ErrorCode::PermissionDenied, "the portal request was denied");
}
}
Result<UniqueFd> PortalSession::requestFd(const char* iface, const char* method) {
ContextScope scope(context_);
GVariantBuilder opts;
g_variant_builder_init(&opts, G_VARIANT_TYPE_VARDICT);
GUnixFDList* fds = nullptr;
ErrorGuard err;
VariantPtr reply(g_dbus_connection_call_with_unix_fd_list_sync(
bus_, kBusName, kObjectPath, iface, method, g_variant_new("(oa{sv})", sessionHandle_.c_str(), &opts),
G_VARIANT_TYPE("(h)"), G_DBUS_CALL_FLAGS_NONE, -1, nullptr, &fds, nullptr, &err.error));
if (!reply) return fail(err.code(), std::string(iface) + "." + method + " failed: " + err.message());
gint32 index = -1;
g_variant_get(reply.get(), "(h)", &index);
int fd = fds ? g_unix_fd_list_get(fds, index, &err.error) : -1;
if (fds) g_object_unref(fds);
if (fd < 0) return fail(ErrorCode::Protocol, std::string(method) + " returned no file descriptor");
return UniqueFd(fd);
}
} // namespace alvin::linux_impl
+73
View File
@@ -0,0 +1,73 @@
#pragma once
// Minimal synchronous client for xdg-desktop-portal's request/response
// pattern, built on GDBus. Only used during session setup, so blocking is fine.
#include <chrono>
#include <cstdint>
#include <memory>
#include <string>
#include "alvin/error.hpp"
#include "util.hpp"
typedef struct _GDBusConnection GDBusConnection;
typedef struct _GMainContext GMainContext;
typedef struct _GVariant GVariant;
typedef struct _GVariantBuilder GVariantBuilder;
namespace alvin::linux_impl {
// Owning GVariant pointer.
struct VariantDeleter {
void operator()(GVariant* v) const;
};
using VariantPtr = std::unique_ptr<GVariant, VariantDeleter>;
class PortalSession {
public:
static constexpr const char* kScreenCast = "org.freedesktop.portal.ScreenCast";
static constexpr const char* kRemoteDesktop = "org.freedesktop.portal.RemoteDesktop";
// Connects to the session bus and calls <iface>.CreateSession.
static Result<std::unique_ptr<PortalSession>> create(const char* iface, std::chrono::milliseconds timeout);
~PortalSession(); // closes the portal session
// Property of the portal interface, e.g. ("...ScreenCast", "AvailableSourceTypes").
Result<std::uint32_t> uintProperty(const char* iface, const char* name);
// Calls <iface>.<method>(session_handle, [parent_window,] options) and waits for
// the matching Request.Response signal. `options` is an a{sv} builder the
// caller filled; handle_token is added here and the builder is consumed.
// Pass parentWindow (usually "") for methods like Start that take one.
// Returns the Response's results dictionary.
Result<VariantPtr> request(const char* iface, const char* method, GVariantBuilder* options,
const char* parentWindow = nullptr);
// Methods returning a file descriptor directly: OpenPipeWireRemote, ConnectToEIS.
Result<UniqueFd> requestFd(const char* iface, const char* method);
const std::string& handle() const { return sessionHandle_; }
private:
PortalSession() = default;
std::string newToken();
std::string requestPath(const std::string& token) const;
static void onResponse(GDBusConnection*, const char* sender, const char* path, const char* iface,
const char* signal, GVariant* params, void* self);
Result<VariantPtr> waitForResponse(const std::string& path, VariantPtr callResult, unsigned subscription);
GMainContext* context_ = nullptr;
GDBusConnection* bus_ = nullptr;
std::string senderPath_; // unique bus name, sanitised for object paths
std::string sessionHandle_;
std::chrono::milliseconds timeout_{};
unsigned tokenCounter_ = 0;
// Filled by the Response signal handler.
bool responded_ = false;
std::uint32_t responseCode_ = 0;
VariantPtr responseResults_;
};
} // namespace alvin::linux_impl
+261
View File
@@ -0,0 +1,261 @@
// Input through kernel virtual devices (/dev/uinput). Works under any Linux
// session - Wayland, X11 or a bare TTY - because events enter the stack below
// the compositor. Requires write access to /dev/uinput (e.g. the `input` group
// or a udev rule granting it to the seat user).
//
// Caveats: the compositor applies pointer acceleration to relative motion, and
// typed text goes through whatever keyboard layout the compositor uses; set
// XKB_DEFAULT_LAYOUT (and _VARIANT) if it isn't "us".
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cstring>
#include <filesystem>
#include <thread>
#include <fcntl.h>
#include <linux/uinput.h>
#include <sys/ioctl.h>
#include "alvin/types.hpp"
#include "backends.hpp"
#include "keycodes.hpp"
#include "util.hpp"
#include "xkb.hpp"
namespace alvin::linux_impl {
namespace {
constexpr int kAbsMax = 65535;
Status ioctlOk(int rc, const char* what) {
if (rc < 0) return fail(ErrorCode::Io, errnoMessage(what));
return {};
}
Result<UniqueFd> openUinput() {
UniqueFd fd(::open("/dev/uinput", O_WRONLY | O_NONBLOCK | O_CLOEXEC));
if (!fd) {
auto code = (errno == EACCES || errno == EPERM) ? ErrorCode::PermissionDenied : ErrorCode::NotAvailable;
return fail(code, errnoMessage("open /dev/uinput"));
}
return fd;
}
Status finishDevice(int fd, const char* name, bool absolute) {
uinput_setup setup{};
setup.id.bustype = BUS_VIRTUAL;
setup.id.vendor = 0x414c; // "AL"
setup.id.product = absolute ? 0x5601 : 0x5600;
std::snprintf(setup.name, UINPUT_MAX_NAME_SIZE, "%s", name);
if (absolute) {
for (int axis : {ABS_X, ABS_Y}) {
uinput_abs_setup abs{};
abs.code = static_cast<__u16>(axis);
abs.absinfo.minimum = 0;
abs.absinfo.maximum = kAbsMax;
if (auto s = ioctlOk(ioctl(fd, UI_ABS_SETUP, &abs), "UI_ABS_SETUP"); !s) return s;
}
}
if (auto s = ioctlOk(ioctl(fd, UI_DEV_SETUP, &setup), "UI_DEV_SETUP"); !s) return s;
return ioctlOk(ioctl(fd, UI_DEV_CREATE), "UI_DEV_CREATE");
}
class UInput final : public Input {
public:
static Result<std::unique_ptr<Input>> create() {
auto keymap = XkbKeymap::fromNames();
if (!keymap) return std::unexpected(keymap.error());
std::unique_ptr<UInput> in(new UInput(std::move(*keymap)));
if (auto s = in->init(); !s) return std::unexpected(s.error());
return in;
}
~UInput() override {
for (int fd : {keyboard_.get(), pointer_.get()})
if (fd >= 0) ioctl(fd, UI_DEV_DESTROY);
}
InputBackend backend() const override { return InputBackend::UInput; }
Status moveTo(Point p) override {
if (desktop_.empty()) {
auto box = desktopBounds();
if (!box) return fail(ErrorCode::Unsupported, "absolute motion needs the monitor layout: " + box.error().message);
desktop_ = *box;
}
// The compositor maps the device's absolute range onto the whole layout.
auto scale = [](int v, int origin, int extent) {
long long rel = std::clamp(v - origin, 0, extent - 1);
return static_cast<int>((rel * kAbsMax + extent / 2) / std::max(1, extent - 1));
};
emit(pointer_.get(), EV_ABS, ABS_X, scale(p.x, desktop_.x, desktop_.width));
emit(pointer_.get(), EV_ABS, ABS_Y, scale(p.y, desktop_.y, desktop_.height));
return syn(pointer_.get());
}
Status moveBy(int dx, int dy) override {
if (dx) emit(keyboard_.get(), EV_REL, REL_X, dx);
if (dy) emit(keyboard_.get(), EV_REL, REL_Y, dy);
return syn(keyboard_.get());
}
Status buttonDown(MouseButton b) override {
emit(pointer_.get(), EV_KEY, static_cast<int>(toEvdev(b)), 1);
return syn(pointer_.get());
}
Status buttonUp(MouseButton b) override {
emit(pointer_.get(), EV_KEY, static_cast<int>(toEvdev(b)), 0);
return syn(pointer_.get());
}
Status scroll(int dx, int dy) override {
// REL_WHEEL is positive for "up"; Alvin's dy is positive for "down".
if (dy) {
emit(pointer_.get(), EV_REL, REL_WHEEL, -dy);
emit(pointer_.get(), EV_REL, REL_WHEEL_HI_RES, -dy * 120);
}
if (dx) {
emit(pointer_.get(), EV_REL, REL_HWHEEL, dx);
emit(pointer_.get(), EV_REL, REL_HWHEEL_HI_RES, dx * 120);
}
return syn(pointer_.get());
}
Status keyDown(Key k) override { return rawKey(toEvdev(k), true); }
Status keyUp(Key k) override { return rawKey(toEvdev(k), false); }
Status typeText(std::string_view utf8) override {
auto text = decodeUtf8(utf8);
if (!text) return std::unexpected(text.error());
auto strokes = keymap_.strokesFor(*text);
if (!strokes) return std::unexpected(strokes.error());
for (const auto& s : *strokes) {
for (auto m : s.modifiers)
if (auto r = rawKey(m, true); !r) return r;
auto r = rawKey(s.key, true);
if (r) r = rawKey(s.key, false);
for (auto m : s.modifiers) (void)rawKey(m, false);
if (!r) return r;
pause();
}
return {};
}
private:
explicit UInput(XkbKeymap keymap) : keymap_(std::move(keymap)) {}
Status init() {
auto kb = openUinput();
if (!kb) return std::unexpected(kb.error());
keyboard_ = std::move(*kb);
int fd = keyboard_.get();
// Keyboard + relative mouse, like a wireless combo receiver.
if (auto s = ioctlOk(ioctl(fd, UI_SET_EVBIT, EV_KEY), "UI_SET_EVBIT"); !s) return s;
for (int code = KEY_ESC; code <= KEY_MICMUTE; ++code) ioctl(fd, UI_SET_KEYBIT, code);
for (int code = BTN_LEFT; code <= BTN_TASK; ++code) ioctl(fd, UI_SET_KEYBIT, code);
ioctl(fd, UI_SET_EVBIT, EV_REL);
ioctl(fd, UI_SET_RELBIT, REL_X);
ioctl(fd, UI_SET_RELBIT, REL_Y);
if (auto s = finishDevice(fd, "Alvin virtual keyboard", false); !s) return s;
// Absolute pointer (like QEMU's USB tablet): buttons + wheel + ABS_X/Y.
auto ptr = openUinput();
if (!ptr) return std::unexpected(ptr.error());
pointer_ = std::move(*ptr);
fd = pointer_.get();
ioctl(fd, UI_SET_EVBIT, EV_KEY);
for (int code = BTN_LEFT; code <= BTN_TASK; ++code) ioctl(fd, UI_SET_KEYBIT, code);
ioctl(fd, UI_SET_EVBIT, EV_REL);
for (int code : {REL_WHEEL, REL_HWHEEL, REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES}) ioctl(fd, UI_SET_RELBIT, code);
ioctl(fd, UI_SET_EVBIT, EV_ABS);
ioctl(fd, UI_SET_ABSBIT, ABS_X);
ioctl(fd, UI_SET_ABSBIT, ABS_Y);
ioctl(fd, UI_SET_PROPBIT, INPUT_PROP_POINTER);
if (auto s = finishDevice(fd, "Alvin virtual pointer", true); !s) return s;
// Events written before the compositor (via udev and libinput) adopts the
// devices are silently lost. That can take seconds, so wait until some other
// process has opened each event node, falling back to a fixed wait.
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(4);
for (int dev : {keyboard_.get(), pointer_.get()}) waitForConsumer(dev, deadline);
// The consumer opens the node slightly before it starts reading it.
std::this_thread::sleep_for(std::chrono::milliseconds(100));
return {};
}
static std::filesystem::path eventNode(int uinputFd) {
namespace fs = std::filesystem;
char sysname[64] = {};
if (ioctl(uinputFd, UI_GET_SYSNAME(sizeof sysname), sysname) < 0) return {};
std::error_code ec;
for (const auto& entry : fs::directory_iterator(fs::path("/sys/devices/virtual/input") / sysname, ec))
if (entry.path().filename().string().starts_with("event")) return fs::path("/dev/input") / entry.path().filename();
return {};
}
// Is `node` open in any process other than ours? Only processes we may
// inspect (same user) are seen, which covers Wayland compositors.
static bool openedElsewhere(const std::filesystem::path& node) {
namespace fs = std::filesystem;
const std::string self = std::to_string(::getpid());
std::error_code ec;
for (const auto& proc : fs::directory_iterator("/proc", ec)) {
const std::string pid = proc.path().filename().string();
if (pid.empty() || !std::isdigit(static_cast<unsigned char>(pid[0])) || pid == self) continue;
std::error_code fdEc;
for (const auto& fd : fs::directory_iterator(proc.path() / "fd", fdEc)) {
std::error_code linkEc;
if (fs::read_symlink(fd.path(), linkEc) == node) return true;
}
}
return false;
}
static void waitForConsumer(int uinputFd, std::chrono::steady_clock::time_point deadline) {
auto node = eventNode(uinputFd);
if (node.empty()) {
std::this_thread::sleep_until(deadline);
return;
}
while (std::chrono::steady_clock::now() < deadline && !openedElsewhere(node))
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
Status rawKey(std::uint32_t code, bool down) {
if (code == 0) return fail(ErrorCode::InvalidArgument, "key has no Linux mapping");
emit(keyboard_.get(), EV_KEY, static_cast<int>(code), down ? 1 : 0);
return syn(keyboard_.get());
}
void emit(int fd, int type, int code, int value) {
input_event ev{};
ev.type = static_cast<__u16>(type);
ev.code = static_cast<__u16>(code);
ev.value = value;
if (::write(fd, &ev, sizeof ev) != static_cast<ssize_t>(sizeof ev) && writeError_.empty())
writeError_ = errnoMessage("write /dev/uinput");
}
Status syn(int fd) {
emit(fd, EV_SYN, SYN_REPORT, 0);
if (!writeError_.empty()) return fail(ErrorCode::Io, std::exchange(writeError_, {}));
return {};
}
XkbKeymap keymap_;
UniqueFd keyboard_;
UniqueFd pointer_;
Rect desktop_;
std::string writeError_;
};
} // namespace
Result<std::unique_ptr<Input>> createUInput(const InputOptions&) { return UInput::create(); }
} // namespace alvin::linux_impl
+69
View File
@@ -0,0 +1,69 @@
#pragma once
#include <cerrno>
#include <chrono>
#include <cstdint>
#include <cstring>
#include <string>
#include <string_view>
#include <utility>
#include <sys/mman.h>
#include <unistd.h>
#include "alvin/error.hpp"
namespace alvin::linux_impl {
class UniqueFd {
public:
UniqueFd() = default;
explicit UniqueFd(int fd) : fd_(fd) {}
UniqueFd(UniqueFd&& o) noexcept : fd_(std::exchange(o.fd_, -1)) {}
UniqueFd& operator=(UniqueFd&& o) noexcept {
if (this != &o) reset(std::exchange(o.fd_, -1));
return *this;
}
~UniqueFd() { reset(); }
int get() const { return fd_; }
int release() { return std::exchange(fd_, -1); }
explicit operator bool() const { return fd_ >= 0; }
void reset(int fd = -1) {
if (fd_ >= 0) ::close(fd_);
fd_ = fd;
}
private:
int fd_ = -1;
};
inline std::string errnoMessage(std::string_view what) {
return std::string(what) + ": " + std::strerror(errno);
}
// Anonymous memfd holding `contents` plus a terminating NUL (what Wayland keymaps expect).
inline Result<UniqueFd> memfdWithContents(std::string_view contents) {
UniqueFd fd(memfd_create("alvin", MFD_CLOEXEC | MFD_ALLOW_SEALING));
if (!fd) return fail(ErrorCode::Io, errnoMessage("memfd_create"));
std::size_t total = contents.size() + 1;
if (ftruncate(fd.get(), static_cast<off_t>(total)) < 0) return fail(ErrorCode::Io, errnoMessage("ftruncate"));
void* map = mmap(nullptr, total, PROT_WRITE, MAP_SHARED, fd.get(), 0);
if (map == MAP_FAILED) return fail(ErrorCode::Io, errnoMessage("mmap"));
std::memcpy(map, contents.data(), contents.size());
static_cast<char*>(map)[contents.size()] = '\0';
munmap(map, total);
return fd;
}
inline std::uint32_t monotonicMs() {
using namespace std::chrono;
return static_cast<std::uint32_t>(duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count());
}
inline std::uint64_t monotonicUs() {
using namespace std::chrono;
return static_cast<std::uint64_t>(duration_cast<microseconds>(steady_clock::now().time_since_epoch()).count());
}
} // namespace alvin::linux_impl
+200
View File
@@ -0,0 +1,200 @@
#include "wayland.hpp"
#include <algorithm>
#include <cerrno>
#include <cstring>
#include <wayland-client.h>
#include "virtual-keyboard-unstable-v1-client-protocol.h"
#include "wlr-virtual-pointer-unstable-v1-client-protocol.h"
#include "xdg-output-unstable-v1-client-protocol.h"
namespace alvin::linux_impl {
namespace {
using Output = WaylandConnection::Output;
// ---- wl_output ----
void outputGeometry(void* data, wl_output*, int32_t x, int32_t y, int32_t, int32_t, int32_t, const char*,
const char*, int32_t) {
auto* o = static_cast<Output*>(data);
o->physicalX = x;
o->physicalY = y;
}
void outputMode(void* data, wl_output*, uint32_t flags, int32_t w, int32_t h, int32_t) {
if (flags & WL_OUTPUT_MODE_CURRENT) static_cast<Output*>(data)->info.pixelSize = {w, h};
}
void outputDone(void*, wl_output*) {}
void outputScale(void* data, wl_output*, int32_t factor) { static_cast<Output*>(data)->info.scale = factor; }
void outputName(void* data, wl_output*, const char* name) { static_cast<Output*>(data)->info.name = name; }
void outputDescription(void* data, wl_output*, const char* desc) {
static_cast<Output*>(data)->info.description = desc;
}
const wl_output_listener kOutputListener = {
.geometry = outputGeometry,
.mode = outputMode,
.done = outputDone,
.scale = outputScale,
.name = outputName,
.description = outputDescription,
};
// ---- zxdg_output_v1 (logical position/size; handles fractional scaling) ----
void xdgLogicalPosition(void* data, zxdg_output_v1*, int32_t x, int32_t y) {
auto* o = static_cast<Output*>(data);
o->info.bounds.x = x;
o->info.bounds.y = y;
o->haveLogical = true;
}
void xdgLogicalSize(void* data, zxdg_output_v1*, int32_t w, int32_t h) {
auto* o = static_cast<Output*>(data);
o->info.bounds.width = w;
o->info.bounds.height = h;
}
void xdgDone(void*, zxdg_output_v1*) {}
void xdgName(void* data, zxdg_output_v1*, const char* name) {
auto* o = static_cast<Output*>(data);
if (o->info.name.empty()) o->info.name = name;
}
void xdgDescription(void* data, zxdg_output_v1*, const char* desc) {
auto* o = static_cast<Output*>(data);
if (o->info.description.empty()) o->info.description = desc;
}
const zxdg_output_v1_listener kXdgOutputListener = {
.logical_position = xdgLogicalPosition,
.logical_size = xdgLogicalSize,
.done = xdgDone,
.name = xdgName,
.description = xdgDescription,
};
const wl_registry_listener kRegistryListener = {
.global = [](void* d, wl_registry* r, uint32_t n, const char* i, uint32_t v) {
WaylandConnection::onGlobal(d, r, n, i, v);
},
.global_remove = [](void* d, wl_registry* r, uint32_t n) { WaylandConnection::onGlobalRemove(d, r, n); },
};
} // namespace
Result<std::unique_ptr<WaylandConnection>> WaylandConnection::connect() {
std::unique_ptr<WaylandConnection> c(new WaylandConnection);
c->display_ = wl_display_connect(nullptr);
if (!c->display_) return fail(ErrorCode::NotAvailable, "cannot connect to Wayland display (is WAYLAND_DISPLAY set?)");
c->registry_ = wl_display_get_registry(c->display_);
wl_registry_add_listener(c->registry_, &kRegistryListener, c.get());
// First roundtrip: globals. Second: output/xdg-output state events.
if (auto s = c->roundtrip(); !s) return std::unexpected(s.error());
if (c->xdgOutputManager_)
for (auto& o : c->outputs_) c->bindXdgOutput(*o);
if (auto s = c->roundtrip(); !s) return std::unexpected(s.error());
return c;
}
WaylandConnection::~WaylandConnection() {
for (auto& o : outputs_) {
if (o->xdgOutput) zxdg_output_v1_destroy(o->xdgOutput);
if (o->output) wl_output_release(o->output);
}
if (virtualPointerManager_) zwlr_virtual_pointer_manager_v1_destroy(virtualPointerManager_);
if (virtualKeyboardManager_) zwp_virtual_keyboard_manager_v1_destroy(virtualKeyboardManager_);
if (xdgOutputManager_) zxdg_output_manager_v1_destroy(xdgOutputManager_);
if (seat_) wl_seat_release(seat_);
if (registry_) wl_registry_destroy(registry_);
if (display_) wl_display_disconnect(display_);
}
void WaylandConnection::onGlobal(void* data, wl_registry* registry, uint32_t name, const char* interface,
uint32_t version) {
auto* self = static_cast<WaylandConnection*>(data);
std::string_view iface = interface;
if (iface == wl_output_interface.name && version >= 4) {
auto o = std::make_unique<Output>();
o->owner = self;
o->globalName = name;
o->output = static_cast<wl_output*>(wl_registry_bind(registry, name, &wl_output_interface, 4));
wl_output_add_listener(o->output, &kOutputListener, o.get());
if (self->xdgOutputManager_) self->bindXdgOutput(*o);
self->outputs_.push_back(std::move(o));
} else if (iface == wl_seat_interface.name && !self->seat_) {
self->seat_ = static_cast<wl_seat*>(wl_registry_bind(registry, name, &wl_seat_interface, std::min(version, 5u)));
} else if (iface == zxdg_output_manager_v1_interface.name) {
self->xdgOutputManager_ = static_cast<zxdg_output_manager_v1*>(
wl_registry_bind(registry, name, &zxdg_output_manager_v1_interface, std::min(version, 3u)));
} else if (iface == zwlr_virtual_pointer_manager_v1_interface.name) {
self->virtualPointerManager_ = static_cast<zwlr_virtual_pointer_manager_v1*>(
wl_registry_bind(registry, name, &zwlr_virtual_pointer_manager_v1_interface, std::min(version, 2u)));
} else if (iface == zwp_virtual_keyboard_manager_v1_interface.name) {
self->virtualKeyboardManager_ = static_cast<zwp_virtual_keyboard_manager_v1*>(
wl_registry_bind(registry, name, &zwp_virtual_keyboard_manager_v1_interface, 1));
}
}
void WaylandConnection::onGlobalRemove(void* data, wl_registry*, uint32_t name) {
auto* self = static_cast<WaylandConnection*>(data);
std::erase_if(self->outputs_, [&](const std::unique_ptr<Output>& o) {
if (o->globalName != name) return false;
if (o->xdgOutput) zxdg_output_v1_destroy(o->xdgOutput);
wl_output_release(o->output);
return true;
});
}
void WaylandConnection::bindXdgOutput(Output& o) {
if (o.xdgOutput) return;
o.xdgOutput = zxdg_output_manager_v1_get_xdg_output(xdgOutputManager_, o.output);
zxdg_output_v1_add_listener(o.xdgOutput, &kXdgOutputListener, &o);
}
std::vector<Monitor> WaylandConnection::monitors() const {
std::vector<Monitor> out;
for (const auto& o : outputs_) {
Monitor m = o->info;
if (!o->haveLogical) {
// No xdg-output: derive logical size from the mode and integer scale.
m.bounds = {o->physicalX, o->physicalY, m.pixelSize.width / std::max(1, static_cast<int>(m.scale)),
m.pixelSize.height / std::max(1, static_cast<int>(m.scale))};
} else if (m.bounds.width > 0) {
m.scale = static_cast<double>(m.pixelSize.width) / m.bounds.width;
}
out.push_back(std::move(m));
}
std::sort(out.begin(), out.end(), [](const Monitor& a, const Monitor& b) {
return a.bounds.x != b.bounds.x ? a.bounds.x < b.bounds.x : a.bounds.y < b.bounds.y;
});
// Wayland has no notion of a primary output; call the one at the origin primary.
for (auto& m : out)
if (m.bounds.x == 0 && m.bounds.y == 0) m.primary = true;
return out;
}
Status WaylandConnection::roundtrip() {
if (wl_display_roundtrip(display_) < 0)
return fail(ErrorCode::Protocol, std::string("Wayland roundtrip failed: ") + std::strerror(errno));
return {};
}
Status WaylandConnection::flush() {
while (wl_display_flush(display_) < 0) {
if (errno != EAGAIN) return fail(ErrorCode::Io, std::string("Wayland flush failed: ") + std::strerror(errno));
// Socket buffer full: let the compositor drain it.
if (wl_display_roundtrip(display_) < 0) return fail(ErrorCode::Protocol, "Wayland roundtrip failed");
}
return {};
}
} // namespace alvin::linux_impl
+67
View File
@@ -0,0 +1,67 @@
#pragma once
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
#include "alvin/error.hpp"
#include "alvin/types.hpp"
struct wl_display;
struct wl_registry;
struct wl_seat;
struct wl_output;
struct zxdg_output_manager_v1;
struct zxdg_output_v1;
struct zwlr_virtual_pointer_manager_v1;
struct zwp_virtual_keyboard_manager_v1;
namespace alvin::linux_impl {
// A client connection to the Wayland compositor with the globals Alvin cares about bound.
class WaylandConnection {
public:
static Result<std::unique_ptr<WaylandConnection>> connect();
~WaylandConnection();
wl_display* display() const { return display_; }
wl_seat* seat() const { return seat_; }
zwlr_virtual_pointer_manager_v1* virtualPointerManager() const { return virtualPointerManager_; }
zwp_virtual_keyboard_manager_v1* virtualKeyboardManager() const { return virtualKeyboardManager_; }
// Logical layout as reported by wl_output + xdg_output at connect time.
std::vector<Monitor> monitors() const;
Status roundtrip();
Status flush();
struct Output {
WaylandConnection* owner = nullptr;
std::uint32_t globalName = 0;
wl_output* output = nullptr;
zxdg_output_v1* xdgOutput = nullptr;
Monitor info;
bool haveLogical = false;
int physicalX = 0, physicalY = 0;
};
// Registry callbacks (public so the C listener table can reach them).
static void onGlobal(void* data, wl_registry* registry, std::uint32_t name, const char* interface,
std::uint32_t version);
static void onGlobalRemove(void* data, wl_registry* registry, std::uint32_t name);
private:
WaylandConnection() = default;
void bindXdgOutput(Output& o);
wl_display* display_ = nullptr;
wl_registry* registry_ = nullptr;
wl_seat* seat_ = nullptr;
zxdg_output_manager_v1* xdgOutputManager_ = nullptr;
zwlr_virtual_pointer_manager_v1* virtualPointerManager_ = nullptr;
zwp_virtual_keyboard_manager_v1* virtualKeyboardManager_ = nullptr;
std::vector<std::unique_ptr<Output>> outputs_;
};
} // namespace alvin::linux_impl
+196
View File
@@ -0,0 +1,196 @@
// Input through the zwlr_virtual_pointer_v1 / zwp_virtual_keyboard_v1 Wayland
// protocols. Supported by Hyprland, sway and most wlroots compositors, and needs
// no permission dialog.
#include <algorithm>
#include <memory>
#include <linux/input-event-codes.h>
#include <wayland-client.h>
#include <xkbcommon/xkbcommon.h>
#include "backends.hpp"
#include "keycodes.hpp"
#include "util.hpp"
#include "virtual-keyboard-unstable-v1-client-protocol.h"
#include "wayland.hpp"
#include "wlr-virtual-pointer-unstable-v1-client-protocol.h"
#include "xkb.hpp"
namespace alvin::linux_impl {
namespace {
struct XkbStateDeleter {
void operator()(xkb_state* s) const { xkb_state_unref(s); }
};
class WlrInput final : public Input {
public:
static Result<std::unique_ptr<Input>> create() {
auto conn = WaylandConnection::connect();
if (!conn) return std::unexpected(conn.error());
auto& wl = **conn;
if (!wl.seat()) return fail(ErrorCode::NotAvailable, "compositor has no wl_seat");
if (!wl.virtualPointerManager() || !wl.virtualKeyboardManager())
return fail(ErrorCode::NotAvailable,
"compositor lacks zwlr_virtual_pointer_manager_v1 / zwp_virtual_keyboard_manager_v1");
auto keymap = XkbKeymap::fromNames();
if (!keymap) return std::unexpected(keymap.error());
std::unique_ptr<WlrInput> in(new WlrInput(std::move(*conn), std::move(*keymap)));
if (auto s = in->init(); !s) return std::unexpected(s.error());
return in;
}
~WlrInput() override {
if (keyboard_) zwp_virtual_keyboard_v1_destroy(keyboard_);
if (pointer_) zwlr_virtual_pointer_v1_destroy(pointer_);
if (wl_) (void)wl_->roundtrip();
}
InputBackend backend() const override { return InputBackend::WlrVirtual; }
Status moveTo(Point p) override {
// Absolute motion is expressed as a fraction of the whole output layout.
Rect box = desktop_;
std::uint32_t x = static_cast<std::uint32_t>(std::clamp(p.x - box.x, 0, box.width - 1));
std::uint32_t y = static_cast<std::uint32_t>(std::clamp(p.y - box.y, 0, box.height - 1));
zwlr_virtual_pointer_v1_motion_absolute(pointer_, monotonicMs(), x, y, static_cast<std::uint32_t>(box.width),
static_cast<std::uint32_t>(box.height));
zwlr_virtual_pointer_v1_frame(pointer_);
return sync();
}
Status moveBy(int dx, int dy) override {
zwlr_virtual_pointer_v1_motion(pointer_, monotonicMs(), wl_fixed_from_int(dx), wl_fixed_from_int(dy));
zwlr_virtual_pointer_v1_frame(pointer_);
return sync();
}
Status buttonDown(MouseButton b) override { return button(b, WL_POINTER_BUTTON_STATE_PRESSED); }
Status buttonUp(MouseButton b) override { return button(b, WL_POINTER_BUTTON_STATE_RELEASED); }
Status scroll(int dx, int dy) override {
std::uint32_t t = monotonicMs();
zwlr_virtual_pointer_v1_axis_source(pointer_, WL_POINTER_AXIS_SOURCE_WHEEL);
// 15 is the conventional per-notch distance (matches libinput / wlroots).
if (dy) zwlr_virtual_pointer_v1_axis_discrete(pointer_, t, WL_POINTER_AXIS_VERTICAL_SCROLL,
wl_fixed_from_int(15 * dy), dy);
if (dx) zwlr_virtual_pointer_v1_axis_discrete(pointer_, t, WL_POINTER_AXIS_HORIZONTAL_SCROLL,
wl_fixed_from_int(15 * dx), dx);
zwlr_virtual_pointer_v1_frame(pointer_);
return sync();
}
Status keyDown(Key key) override { return this->key(key, true); }
Status keyUp(Key key) override { return this->key(key, false); }
Status typeText(std::string_view utf8) override {
auto text = decodeUtf8(utf8);
if (!text) return std::unexpected(text.error());
// Upload a keymap containing exactly the characters we need, type them
// with no modifiers, then switch back. Chunked because XKB caps keycodes at 255.
std::size_t pos = 0;
while (pos < text->size()) {
std::u32string alphabet;
std::size_t end = pos;
for (; end < text->size(); ++end) {
if (alphabet.find((*text)[end]) != std::u32string::npos) continue;
if (alphabet.size() == 247) break;
alphabet.push_back((*text)[end]);
}
auto source = synthesizeKeymap(alphabet);
if (!source) return std::unexpected(source.error());
if (auto s = uploadKeymap(*source); !s) return s;
zwp_virtual_keyboard_v1_modifiers(keyboard_, 0, 0, 0, 0);
for (std::size_t i = pos; i < end; ++i) {
auto code = static_cast<std::uint32_t>(alphabet.find((*text)[i]) + 1);
zwp_virtual_keyboard_v1_key(keyboard_, monotonicMs(), code, WL_KEYBOARD_KEY_STATE_PRESSED);
zwp_virtual_keyboard_v1_key(keyboard_, monotonicMs(), code, WL_KEYBOARD_KEY_STATE_RELEASED);
if (auto s = sync(); !s) return s;
pause();
}
pos = end;
}
if (auto s = uploadKeymap(baseKeymapSource_); !s) return s;
sendModifiers();
return sync();
}
private:
WlrInput(std::unique_ptr<WaylandConnection> wl, XkbKeymap keymap) : wl_(std::move(wl)), keymap_(std::move(keymap)) {}
Status init() {
for (const auto& m : wl_->monitors()) desktop_ = desktop_.united(m.bounds);
if (desktop_.empty()) return fail(ErrorCode::NotAvailable, "no outputs reported by the compositor");
pointer_ = zwlr_virtual_pointer_manager_v1_create_virtual_pointer(wl_->virtualPointerManager(), wl_->seat());
keyboard_ = zwp_virtual_keyboard_manager_v1_create_virtual_keyboard(wl_->virtualKeyboardManager(), wl_->seat());
char* text = xkb_keymap_get_as_string(keymap_.get(), XKB_KEYMAP_FORMAT_TEXT_V1);
if (!text) return fail(ErrorCode::Unknown, "cannot serialise XKB keymap");
baseKeymapSource_ = text;
std::free(text);
state_.reset(xkb_state_new(keymap_.get()));
if (auto s = uploadKeymap(baseKeymapSource_); !s) return s;
// The compositor sends a protocol error here if we're not allowed to use these.
return wl_->roundtrip();
}
Status uploadKeymap(const std::string& source) {
auto fd = memfdWithContents(source);
if (!fd) return std::unexpected(fd.error());
zwp_virtual_keyboard_v1_keymap(keyboard_, WL_KEYBOARD_KEYMAP_FORMAT_XKB_V1, fd->get(),
static_cast<std::uint32_t>(source.size() + 1));
return sync();
}
Status button(MouseButton b, wl_pointer_button_state state) {
zwlr_virtual_pointer_v1_button(pointer_, monotonicMs(), toEvdev(b), state);
zwlr_virtual_pointer_v1_frame(pointer_);
return sync();
}
Status key(Key k, bool down) {
std::uint32_t code = toEvdev(k);
if (code == 0) return fail(ErrorCode::InvalidArgument, "key has no Linux mapping");
zwp_virtual_keyboard_v1_key(keyboard_, monotonicMs(), code,
down ? WL_KEYBOARD_KEY_STATE_PRESSED : WL_KEYBOARD_KEY_STATE_RELEASED);
// Virtual keyboards must report modifier state themselves; track it with XKB.
xkb_state_update_key(state_.get(), code + 8, down ? XKB_KEY_DOWN : XKB_KEY_UP);
sendModifiers();
return sync();
}
void sendModifiers() {
zwp_virtual_keyboard_v1_modifiers(keyboard_, xkb_state_serialize_mods(state_.get(), XKB_STATE_MODS_DEPRESSED),
xkb_state_serialize_mods(state_.get(), XKB_STATE_MODS_LATCHED),
xkb_state_serialize_mods(state_.get(), XKB_STATE_MODS_LOCKED),
xkb_state_serialize_layout(state_.get(), XKB_STATE_LAYOUT_EFFECTIVE));
}
// Flush and wait for the compositor to process everything, so a following
// capture or IPC query observes the effect.
Status sync() {
if (auto s = wl_->flush(); !s) return s;
return wl_->roundtrip();
}
std::unique_ptr<WaylandConnection> wl_;
XkbKeymap keymap_;
std::unique_ptr<xkb_state, XkbStateDeleter> state_;
std::string baseKeymapSource_;
zwlr_virtual_pointer_v1* pointer_ = nullptr;
zwp_virtual_keyboard_v1* keyboard_ = nullptr;
Rect desktop_;
};
} // namespace
Result<std::unique_ptr<Input>> createWlrInput(const InputOptions&) { return WlrInput::create(); }
} // namespace alvin::linux_impl
+150
View File
@@ -0,0 +1,150 @@
#include "xkb.hpp"
#include <cstdio>
#include <linux/input-event-codes.h>
#include <xkbcommon/xkbcommon.h>
namespace alvin::linux_impl {
void XkbKeymap::Deleter::operator()(xkb_keymap* k) const { xkb_keymap_unref(k); }
static std::shared_ptr<xkb_context> newContext() {
xkb_context* ctx = xkb_context_new(XKB_CONTEXT_NO_FLAGS);
if (!ctx) return nullptr;
return {ctx, [](xkb_context* c) { xkb_context_unref(c); }};
}
Result<XkbKeymap> XkbKeymap::fromNames(const std::string& layout, const std::string& variant,
const std::string& options) {
XkbKeymap km;
km.context_ = newContext();
if (!km.context_) return fail(ErrorCode::Unknown, "xkb_context_new failed");
xkb_rule_names names{};
if (!layout.empty()) names.layout = layout.c_str();
if (!variant.empty()) names.variant = variant.c_str();
if (!options.empty()) names.options = options.c_str();
km.keymap_.reset(xkb_keymap_new_from_names(km.context_.get(), &names, XKB_KEYMAP_COMPILE_NO_FLAGS));
if (!km.keymap_) return fail(ErrorCode::InvalidArgument, "cannot compile XKB keymap for layout '" + layout + "'");
return km;
}
Result<XkbKeymap> XkbKeymap::fromString(std::string_view text) {
XkbKeymap km;
km.context_ = newContext();
if (!km.context_) return fail(ErrorCode::Unknown, "xkb_context_new failed");
km.keymap_.reset(xkb_keymap_new_from_buffer(km.context_.get(), text.data(), text.size(),
XKB_KEYMAP_FORMAT_TEXT_V1, XKB_KEYMAP_COMPILE_NO_FLAGS));
if (!km.keymap_) return fail(ErrorCode::Protocol, "cannot parse XKB keymap");
return km;
}
std::uint32_t keysymFor(char32_t cp) {
switch (cp) {
case U'\n': case U'\r': return XKB_KEY_Return;
case U'\t': return XKB_KEY_Tab;
case U'\b': return XKB_KEY_BackSpace;
case 0x1b: return XKB_KEY_Escape;
default: return xkb_utf32_to_keysym(cp);
}
}
std::optional<Stroke> XkbKeymap::strokeFor(char32_t cp) const {
xkb_keysym_t want = keysymFor(cp);
if (want == XKB_KEY_NoSymbol) return std::nullopt;
xkb_keymap* km = keymap_.get();
const xkb_mod_index_t shift = xkb_keymap_mod_get_index(km, XKB_MOD_NAME_SHIFT);
// AltGr ("ISO_Level3_Shift") is bound to Mod5 in the stock XKB data.
const xkb_mod_index_t level3 = xkb_keymap_mod_get_index(km, "Mod5");
std::optional<Stroke> best;
int bestMods = 99;
const xkb_layout_index_t layout = 0;
for (xkb_keycode_t kc = xkb_keymap_min_keycode(km); kc <= xkb_keymap_max_keycode(km); ++kc) {
xkb_level_index_t levels = xkb_keymap_num_levels_for_key(km, kc, layout);
for (xkb_level_index_t level = 0; level < levels; ++level) {
const xkb_keysym_t* syms = nullptr;
int n = xkb_keymap_key_get_syms_by_level(km, kc, layout, level, &syms);
if (n != 1 || syms[0] != want) continue;
xkb_mod_mask_t masks[16];
std::size_t nmasks = xkb_keymap_key_get_mods_for_level(km, kc, layout, level, masks, 16);
for (std::size_t i = 0; i < nmasks; ++i) {
xkb_mod_mask_t mask = masks[i];
Stroke s{kc - 8, {}};
if (shift != XKB_MOD_INVALID && (mask & (1u << shift))) {
s.modifiers.push_back(KEY_LEFTSHIFT);
mask &= ~(1u << shift);
}
if (level3 != XKB_MOD_INVALID && (mask & (1u << level3))) {
s.modifiers.push_back(KEY_RIGHTALT);
mask &= ~(1u << level3);
}
if (mask != 0) continue; // needs a modifier we can't press (e.g. Lock)
int count = static_cast<int>(s.modifiers.size());
if (count < bestMods) {
best = std::move(s);
bestMods = count;
}
}
}
if (bestMods == 0) break;
}
return best;
}
Result<std::vector<Stroke>> XkbKeymap::strokesFor(std::u32string_view text) const {
std::vector<Stroke> out;
out.reserve(text.size());
for (char32_t cp : text) {
auto s = strokeFor(cp);
if (!s) {
char buf[16];
std::snprintf(buf, sizeof buf, "U+%04X", static_cast<unsigned>(cp));
return fail(ErrorCode::Unsupported, std::string("character ") + buf + " is not on the active keyboard layout");
}
out.push_back(std::move(*s));
}
return out;
}
Result<std::string> synthesizeKeymap(std::u32string_view codepoints) {
// XKB keycodes are 8..255 and evdev keycode = xkb keycode - 8.
if (codepoints.size() > 247) return fail(ErrorCode::InvalidArgument, "too many distinct characters for one keymap");
std::string keycodes, symbols;
for (std::size_t i = 0; i < codepoints.size(); ++i) {
char name[64];
if (xkb_keysym_get_name(keysymFor(codepoints[i]), name, sizeof name) < 0)
return fail(ErrorCode::InvalidArgument, "no keysym for character");
std::size_t xkbCode = i + 9;
keycodes += " <K" + std::to_string(xkbCode) + "> = " + std::to_string(xkbCode) + ";\n";
symbols += " key <K" + std::to_string(xkbCode) + "> { [ " + name + " ] };\n";
}
return "xkb_keymap {\n"
" xkb_keycodes \"alvin\" {\n minimum = 8;\n maximum = 255;\n" + keycodes + " };\n"
" xkb_types \"alvin\" { include \"complete\" };\n"
" xkb_compatibility \"alvin\" { include \"complete\" };\n"
" xkb_symbols \"alvin\" {\n" + symbols + " };\n"
"};\n";
}
Result<std::u32string> decodeUtf8(std::string_view text) {
std::u32string out;
out.reserve(text.size());
for (std::size_t i = 0; i < text.size();) {
auto c = static_cast<unsigned char>(text[i]);
int len = c < 0x80 ? 1 : (c >> 5) == 0x6 ? 2 : (c >> 4) == 0xE ? 3 : (c >> 3) == 0x1E ? 4 : 0;
if (len == 0 || i + len > text.size()) return fail(ErrorCode::InvalidArgument, "invalid UTF-8");
char32_t cp = len == 1 ? c : c & (0x7F >> len);
for (int k = 1; k < len; ++k) {
auto cc = static_cast<unsigned char>(text[i + k]);
if ((cc & 0xC0) != 0x80) return fail(ErrorCode::InvalidArgument, "invalid UTF-8");
cp = (cp << 6) | (cc & 0x3F);
}
out.push_back(cp);
i += len;
}
return out;
}
} // namespace alvin::linux_impl
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include <cstdint>
#include <memory>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
#include "alvin/error.hpp"
struct xkb_context;
struct xkb_keymap;
namespace alvin::linux_impl {
// One character's worth of key presses: hold `modifiers` (evdev codes), tap `key`.
struct Stroke {
std::uint32_t key = 0;
std::vector<std::uint32_t> modifiers;
};
class XkbKeymap {
public:
// Empty strings fall back to XKB_DEFAULT_* environment variables, then "us".
static Result<XkbKeymap> fromNames(const std::string& layout = {}, const std::string& variant = {},
const std::string& options = {});
static Result<XkbKeymap> fromString(std::string_view text);
// Which physical keys produce this code point on the first layout group.
std::optional<Stroke> strokeFor(char32_t codepoint) const;
// Resolves every code point up front so we fail before typing anything.
Result<std::vector<Stroke>> strokesFor(std::u32string_view text) const;
xkb_keymap* get() const { return keymap_.get(); }
private:
struct Deleter {
void operator()(xkb_keymap*) const;
};
std::shared_ptr<xkb_context> context_;
std::unique_ptr<xkb_keymap, Deleter> keymap_;
};
// Keymap source text where entry i of `codepoints` is produced by evdev keycode
// i + 1 with no modifiers. Used with the Wayland virtual keyboard to type any
// Unicode text regardless of the user's layout (the approach wtype uses).
Result<std::string> synthesizeKeymap(std::u32string_view codepoints);
std::uint32_t keysymFor(char32_t codepoint);
Result<std::u32string> decodeUtf8(std::string_view text);
} // namespace alvin::linux_impl
+102
View File
@@ -0,0 +1,102 @@
#include "alvin/vision.hpp"
#include <algorithm>
#include <opencv2/imgcodecs.hpp>
#include <opencv2/imgproc.hpp>
namespace alvin::vision {
cv::Mat view(const Frame& frame) {
return cv::Mat(frame.height(), frame.width(), CV_8UC4, const_cast<std::uint8_t*>(frame.data()), frame.stride());
}
cv::Mat toBgr(const Frame& frame) {
cv::Mat out;
cv::cvtColor(view(frame), out, cv::COLOR_BGRA2BGR);
return out;
}
Result<Frame> fromMat(const cv::Mat& mat) {
if (mat.empty()) return fail(ErrorCode::InvalidArgument, "empty image");
if (mat.depth() != CV_8U) return fail(ErrorCode::InvalidArgument, "only 8-bit images are supported");
Frame frame(mat.cols, mat.rows);
cv::Mat dst = view(frame);
switch (mat.channels()) {
case 1: cv::cvtColor(mat, dst, cv::COLOR_GRAY2BGRA); break;
case 3: cv::cvtColor(mat, dst, cv::COLOR_BGR2BGRA); break;
case 4: mat.copyTo(dst); break;
default: return fail(ErrorCode::InvalidArgument, "unsupported channel count");
}
return frame;
}
Result<cv::Mat> loadImage(const std::filesystem::path& path) {
cv::Mat img = cv::imread(path.string(), cv::IMREAD_COLOR);
if (img.empty()) return fail(ErrorCode::Io, "cannot read image " + path.string());
return img;
}
Status saveImage(const Frame& frame, const std::filesystem::path& path) {
try {
if (!cv::imwrite(path.string(), view(frame))) return fail(ErrorCode::Io, "cannot write image " + path.string());
} catch (const cv::Exception& e) {
return fail(ErrorCode::Io, e.what());
}
return {};
}
namespace {
// Brings both images to the same channel layout and returns the TM_CCOEFF_NORMED score map.
Result<cv::Mat> scoreMap(const Frame& haystack, const cv::Mat& needle, bool grayscale) {
if (needle.empty()) return fail(ErrorCode::InvalidArgument, "empty template");
if (needle.cols > haystack.width() || needle.rows > haystack.height())
return fail(ErrorCode::InvalidArgument, "template is larger than the frame");
cv::Mat hay, tpl;
if (grayscale) {
cv::cvtColor(view(haystack), hay, cv::COLOR_BGRA2GRAY);
if (needle.channels() == 1) tpl = needle;
else cv::cvtColor(needle, tpl, needle.channels() == 4 ? cv::COLOR_BGRA2GRAY : cv::COLOR_BGR2GRAY);
} else {
cv::cvtColor(view(haystack), hay, cv::COLOR_BGRA2BGR);
if (needle.channels() == 3) tpl = needle;
else cv::cvtColor(needle, tpl, needle.channels() == 4 ? cv::COLOR_BGRA2BGR : cv::COLOR_GRAY2BGR);
}
cv::Mat scores;
cv::matchTemplate(hay, tpl, scores, cv::TM_CCOEFF_NORMED);
return scores;
}
} // namespace
Result<std::optional<Match>> find(const Frame& haystack, const cv::Mat& needle, const MatchOptions& opts) {
auto scores = scoreMap(haystack, needle, opts.grayscale);
if (!scores) return std::unexpected(scores.error());
double best = 0;
cv::Point loc;
cv::minMaxLoc(*scores, nullptr, &best, nullptr, &loc);
if (best < opts.threshold) return std::optional<Match>{};
return std::optional{Match{{loc.x, loc.y, needle.cols, needle.rows}, best}};
}
Result<std::vector<Match>> findAll(const Frame& haystack, const cv::Mat& needle, const MatchOptions& opts) {
auto scores = scoreMap(haystack, needle, opts.grayscale);
if (!scores) return std::unexpected(scores.error());
std::vector<Match> out;
// Greedy non-maximum suppression: take the best peak, blank out its neighbourhood, repeat.
while (static_cast<int>(out.size()) < opts.maxResults) {
double best = 0;
cv::Point loc;
cv::minMaxLoc(*scores, nullptr, &best, nullptr, &loc);
if (best < opts.threshold) break;
out.push_back({{loc.x, loc.y, needle.cols, needle.rows}, best});
cv::Rect suppress(loc.x - needle.cols / 2, loc.y - needle.rows / 2, needle.cols, needle.rows);
suppress &= cv::Rect(0, 0, scores->cols, scores->rows);
(*scores)(suppress).setTo(-1.0f);
}
return out;
}
} // namespace alvin::vision
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <memory>
#include "alvin/capture.hpp"
#include "alvin/input.hpp"
#include "alvin/window.hpp"
namespace alvin::win_impl {
Result<std::unique_ptr<Input>> createSendInput(const InputOptions& options);
Result<std::unique_ptr<Capture>> createGdiCapture(const CaptureOptions& options);
Result<std::unique_ptr<WindowManager>> createWin32Windows();
} // namespace alvin::win_impl
+128
View File
@@ -0,0 +1,128 @@
// Capture with GDI: BitBlt for monitors, PrintWindow for individual windows.
// Simple and dependency-free. Hardware-accelerated or protected content may
// come back black; DXGI Desktop Duplication / Windows.Graphics.Capture are the
// planned faster alternatives.
#include <cstring>
#include <vector>
#include "alvin/types.hpp"
#include "backends.hpp"
#include "win_util.hpp"
namespace alvin::win_impl {
namespace {
// Owns a screen-compatible memory DC with a top-down 32-bit DIB selected in.
class DibCanvas {
public:
DibCanvas(HDC reference, int w, int h) : width_(w), height_(h) {
dc_ = CreateCompatibleDC(reference);
BITMAPINFO bi{};
bi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
bi.bmiHeader.biWidth = w;
bi.bmiHeader.biHeight = -h; // top-down
bi.bmiHeader.biPlanes = 1;
bi.bmiHeader.biBitCount = 32;
bi.bmiHeader.biCompression = BI_RGB;
bitmap_ = CreateDIBSection(dc_, &bi, DIB_RGB_COLORS, &bits_, nullptr, 0);
if (bitmap_) previous_ = SelectObject(dc_, bitmap_);
}
~DibCanvas() {
if (previous_) SelectObject(dc_, previous_);
if (bitmap_) DeleteObject(bitmap_);
if (dc_) DeleteDC(dc_);
}
DibCanvas(const DibCanvas&) = delete;
DibCanvas& operator=(const DibCanvas&) = delete;
bool ok() const { return dc_ && bitmap_ && bits_; }
HDC dc() const { return dc_; }
Frame toFrame(Rect desktop) const {
Frame f(width_, height_);
std::memcpy(f.data(), bits_, static_cast<std::size_t>(width_) * height_ * 4);
// GDI leaves the alpha byte undefined; make the image opaque.
for (std::size_t i = 3; i < static_cast<std::size_t>(width_) * height_ * 4; i += 4) f.data()[i] = 255;
f.setDesktopRect(desktop);
f.setTimestampNs(static_cast<std::int64_t>(GetTickCount64()) * 1'000'000);
return f;
}
private:
int width_, height_;
HDC dc_ = nullptr;
HBITMAP bitmap_ = nullptr;
HGDIOBJ previous_ = nullptr;
void* bits_ = nullptr;
};
class GdiCapture final : public Capture {
public:
explicit GdiCapture(const CaptureOptions& o) : options_(o) {}
CaptureBackend backend() const override { return CaptureBackend::Gdi; }
Result<Frame> grab(std::chrono::milliseconds) override {
return options_.window ? captureWindow(reinterpret_cast<HWND>(options_.window->value)) : captureMonitor();
}
// GDI captures on demand, so every grab is a new frame.
Result<Frame> nextFrame(std::chrono::milliseconds timeout) override { return grab(timeout); }
private:
Result<Frame> captureMonitor() {
auto monitors = listMonitors();
if (!monitors) return std::unexpected(monitors.error());
if (options_.monitorIndex < 0 || options_.monitorIndex >= static_cast<int>(monitors->size()))
return fail(ErrorCode::NotFound, "monitor index out of range");
Rect r = (*monitors)[options_.monitorIndex].bounds;
HDC screen = GetDC(nullptr);
DibCanvas canvas(screen, r.width, r.height);
bool ok = canvas.ok() && BitBlt(canvas.dc(), 0, 0, r.width, r.height, screen, r.x, r.y, SRCCOPY | CAPTUREBLT);
ReleaseDC(nullptr, screen);
if (!ok) return fail(ErrorCode::Io, lastErrorMessage("BitBlt"));
if (options_.showCursor) drawCursor(canvas.dc(), r);
return canvas.toFrame(r);
}
Result<Frame> captureWindow(HWND hwnd) {
if (!IsWindow(hwnd)) return fail(ErrorCode::NotFound, "window no longer exists");
RECT wr;
if (!GetWindowRect(hwnd, &wr)) return fail(ErrorCode::Io, lastErrorMessage("GetWindowRect"));
int w = wr.right - wr.left, h = wr.bottom - wr.top;
if (w <= 0 || h <= 0) return fail(ErrorCode::NotAvailable, "window has no visible area (minimised?)");
HDC screen = GetDC(nullptr);
DibCanvas canvas(screen, w, h);
ReleaseDC(nullptr, screen);
if (!canvas.ok()) return fail(ErrorCode::Io, lastErrorMessage("CreateDIBSection"));
// PW_RENDERFULLCONTENT (2) captures DirectComposition / GPU-rendered windows on Windows 8.1+.
if (!PrintWindow(hwnd, canvas.dc(), 2)) return fail(ErrorCode::Io, lastErrorMessage("PrintWindow"));
return canvas.toFrame({wr.left, wr.top, w, h});
}
static void drawCursor(HDC dc, Rect r) {
CURSORINFO ci{};
ci.cbSize = sizeof ci;
if (!GetCursorInfo(&ci) || !(ci.flags & CURSOR_SHOWING)) return;
ICONINFO ii{};
if (!GetIconInfo(ci.hCursor, &ii)) return;
DrawIconEx(dc, ci.ptScreenPos.x - r.x - static_cast<int>(ii.xHotspot),
ci.ptScreenPos.y - r.y - static_cast<int>(ii.yHotspot), ci.hCursor, 0, 0, 0, nullptr, DI_NORMAL);
if (ii.hbmMask) DeleteObject(ii.hbmMask);
if (ii.hbmColor) DeleteObject(ii.hbmColor);
}
CaptureOptions options_;
};
} // namespace
Result<std::unique_ptr<Capture>> createGdiCapture(const CaptureOptions& options) {
ensureDpiAware();
return std::make_unique<GdiCapture>(options);
}
} // namespace alvin::win_impl
+122
View File
@@ -0,0 +1,122 @@
// Backend selection and shared helpers for Windows.
#include <cstdlib>
#include <mutex>
#include "../common/platform.hpp"
#include "backends.hpp"
#include "win_util.hpp"
namespace alvin {
namespace win_impl {
std::string narrow(std::wstring_view w) {
if (w.empty()) return {};
int n = WideCharToMultiByte(CP_UTF8, 0, w.data(), static_cast<int>(w.size()), nullptr, 0, nullptr, nullptr);
std::string out(static_cast<std::size_t>(n), '\0');
WideCharToMultiByte(CP_UTF8, 0, w.data(), static_cast<int>(w.size()), out.data(), n, nullptr, nullptr);
return out;
}
std::wstring widen(std::string_view s) {
if (s.empty()) return {};
int n = MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast<int>(s.size()), nullptr, 0);
std::wstring out(static_cast<std::size_t>(n), L'\0');
MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast<int>(s.size()), out.data(), n);
return out;
}
std::string lastErrorMessage(std::string_view what) {
DWORD code = GetLastError();
wchar_t* buf = nullptr;
FormatMessageW(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, nullptr,
code, 0, reinterpret_cast<wchar_t*>(&buf), 0, nullptr);
std::string msg = std::string(what) + ": " + (buf ? narrow(buf) : "error " + std::to_string(code));
if (buf) LocalFree(buf);
while (!msg.empty() && (msg.back() == '\n' || msg.back() == '\r')) msg.pop_back();
return msg;
}
void ensureDpiAware() {
static std::once_flag once;
std::call_once(once, [] {
// Fails harmlessly if the host application already chose a mode.
SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
});
}
} // namespace win_impl
namespace detail {
Result<std::unique_ptr<Input>> createInput(const InputOptions& options) {
switch (options.backend) {
case InputBackend::Auto:
case InputBackend::SendInput:
return win_impl::createSendInput(options);
default:
return fail(ErrorCode::Unsupported,
"input backend '" + std::string(toString(options.backend)) + "' is not available on Windows");
}
}
Result<std::unique_ptr<Capture>> createCapture(const CaptureOptions& options) {
switch (options.backend) {
case CaptureBackend::Auto:
case CaptureBackend::Gdi:
return win_impl::createGdiCapture(options);
default:
return fail(ErrorCode::Unsupported, "capture backend '" + std::string(toString(options.backend)) +
"' is not available on Windows");
}
}
Result<std::unique_ptr<WindowManager>> createWindowManager(WindowBackend backend) {
switch (backend) {
case WindowBackend::Auto:
case WindowBackend::Win32:
return win_impl::createWin32Windows();
default:
return fail(ErrorCode::Unsupported,
"window backend '" + std::string(toString(backend)) + "' is not available on Windows");
}
}
Result<std::vector<Monitor>> listMonitors() {
win_impl::ensureDpiAware();
std::vector<Monitor> out;
auto callback = [](HMONITOR mon, HDC, LPRECT, LPARAM data) -> BOOL {
auto* list = reinterpret_cast<std::vector<Monitor>*>(data);
MONITORINFOEXW info{};
info.cbSize = sizeof info;
if (!GetMonitorInfoW(mon, &info)) return TRUE;
Monitor m;
m.name = win_impl::narrow(info.szDevice);
const RECT& r = info.rcMonitor;
m.bounds = {r.left, r.top, r.right - r.left, r.bottom - r.top};
m.pixelSize = {m.bounds.width, m.bounds.height};
m.primary = (info.dwFlags & MONITORINFOF_PRIMARY) != 0;
UINT dpi = GetDpiForWindow(GetDesktopWindow());
if (HMODULE shcore = LoadLibraryW(L"Shcore.dll")) {
using Fn = HRESULT(WINAPI*)(HMONITOR, int, UINT*, UINT*);
if (auto fn = reinterpret_cast<Fn>(GetProcAddress(shcore, "GetDpiForMonitor"))) {
UINT dx = 0, dy = 0;
if (SUCCEEDED(fn(mon, 0 /* MDT_EFFECTIVE_DPI */, &dx, &dy))) dpi = dx;
}
FreeLibrary(shcore);
}
m.scale = dpi / 96.0;
DISPLAY_DEVICEW dev{};
dev.cb = sizeof dev;
if (EnumDisplayDevicesW(info.szDevice, 0, &dev, 0)) m.description = win_impl::narrow(dev.DeviceString);
list->push_back(std::move(m));
return TRUE;
};
if (!EnumDisplayMonitors(nullptr, nullptr, callback, reinterpret_cast<LPARAM>(&out)))
return fail(ErrorCode::Io, win_impl::lastErrorMessage("EnumDisplayMonitors"));
return out;
}
} // namespace detail
} // namespace alvin
+220
View File
@@ -0,0 +1,220 @@
// Input through the Win32 SendInput API.
//
// Note: Windows' UIPI blocks injected input into windows of elevated (admin)
// processes unless this process is elevated too.
#include <algorithm>
#include <vector>
#include "backends.hpp"
#include "win_util.hpp"
namespace alvin::win_impl {
namespace {
WORD toVirtualKey(Key key) {
if (key >= Key::A && key <= Key::Z) return static_cast<WORD>('A' + (static_cast<int>(key) - static_cast<int>(Key::A)));
if (key >= Key::Num0 && key <= Key::Num9)
return static_cast<WORD>('0' + (static_cast<int>(key) - static_cast<int>(Key::Num0)));
if (key >= Key::F1 && key <= Key::F24)
return static_cast<WORD>(VK_F1 + (static_cast<int>(key) - static_cast<int>(Key::F1)));
if (key >= Key::Kp0 && key <= Key::Kp9)
return static_cast<WORD>(VK_NUMPAD0 + (static_cast<int>(key) - static_cast<int>(Key::Kp0)));
switch (key) {
case Key::Escape: return VK_ESCAPE;
case Key::Enter: return VK_RETURN;
case Key::Tab: return VK_TAB;
case Key::Backspace: return VK_BACK;
case Key::Space: return VK_SPACE;
case Key::Minus: return VK_OEM_MINUS;
case Key::Equal: return VK_OEM_PLUS;
case Key::LeftBracket: return VK_OEM_4;
case Key::RightBracket: return VK_OEM_6;
case Key::Backslash: return VK_OEM_5;
case Key::Semicolon: return VK_OEM_1;
case Key::Apostrophe: return VK_OEM_7;
case Key::Grave: return VK_OEM_3;
case Key::Comma: return VK_OEM_COMMA;
case Key::Period: return VK_OEM_PERIOD;
case Key::Slash: return VK_OEM_2;
case Key::CapsLock: return VK_CAPITAL;
case Key::NumLock: return VK_NUMLOCK;
case Key::ScrollLock: return VK_SCROLL;
case Key::LeftShift: return VK_LSHIFT;
case Key::RightShift: return VK_RSHIFT;
case Key::LeftCtrl: return VK_LCONTROL;
case Key::RightCtrl: return VK_RCONTROL;
case Key::LeftAlt: return VK_LMENU;
case Key::RightAlt: return VK_RMENU;
case Key::LeftSuper: return VK_LWIN;
case Key::RightSuper: return VK_RWIN;
case Key::Menu: return VK_APPS;
case Key::Insert: return VK_INSERT;
case Key::Delete: return VK_DELETE;
case Key::Home: return VK_HOME;
case Key::End: return VK_END;
case Key::PageUp: return VK_PRIOR;
case Key::PageDown: return VK_NEXT;
case Key::Left: return VK_LEFT;
case Key::Right: return VK_RIGHT;
case Key::Up: return VK_UP;
case Key::Down: return VK_DOWN;
case Key::PrintScreen: return VK_SNAPSHOT;
case Key::Pause: return VK_PAUSE;
case Key::KpDecimal: return VK_DECIMAL;
case Key::KpDivide: return VK_DIVIDE;
case Key::KpMultiply: return VK_MULTIPLY;
case Key::KpSubtract: return VK_SUBTRACT;
case Key::KpAdd: return VK_ADD;
case Key::KpEnter: return VK_RETURN; // distinguished by the extended flag
case Key::VolumeMute: return VK_VOLUME_MUTE;
case Key::VolumeDown: return VK_VOLUME_DOWN;
case Key::VolumeUp: return VK_VOLUME_UP;
case Key::MediaPlayPause: return VK_MEDIA_PLAY_PAUSE;
case Key::MediaStop: return VK_MEDIA_STOP;
case Key::MediaNext: return VK_MEDIA_NEXT_TRACK;
case Key::MediaPrevious: return VK_MEDIA_PREV_TRACK;
default: return 0;
}
}
// Keys whose scan codes carry the E0 prefix.
bool isExtended(Key key) {
switch (key) {
case Key::RightCtrl: case Key::RightAlt: case Key::LeftSuper: case Key::RightSuper: case Key::Menu:
case Key::Insert: case Key::Delete: case Key::Home: case Key::End: case Key::PageUp: case Key::PageDown:
case Key::Left: case Key::Right: case Key::Up: case Key::Down:
case Key::KpDivide: case Key::KpEnter: case Key::NumLock: case Key::PrintScreen:
return true;
default:
return false;
}
}
class SendInputBackend final : public Input {
public:
InputBackend backend() const override { return InputBackend::SendInput; }
Status moveTo(Point p) override {
// Normalised 0..65535 across the whole virtual desktop.
int vx = GetSystemMetrics(SM_XVIRTUALSCREEN), vy = GetSystemMetrics(SM_YVIRTUALSCREEN);
int vw = GetSystemMetrics(SM_CXVIRTUALSCREEN), vh = GetSystemMetrics(SM_CYVIRTUALSCREEN);
INPUT in{};
in.type = INPUT_MOUSE;
in.mi.dx = static_cast<LONG>((static_cast<long long>(std::clamp(p.x - vx, 0, vw - 1)) * 65535) / std::max(1, vw - 1));
in.mi.dy = static_cast<LONG>((static_cast<long long>(std::clamp(p.y - vy, 0, vh - 1)) * 65535) / std::max(1, vh - 1));
in.mi.dwFlags = MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK;
return send({in});
}
Status moveBy(int dx, int dy) override {
// Relative SendInput motion is subject to pointer acceleration; go absolute for exactness.
POINT cur;
if (!GetCursorPos(&cur)) return fail(ErrorCode::Io, lastErrorMessage("GetCursorPos"));
return moveTo({cur.x + dx, cur.y + dy});
}
Status buttonDown(MouseButton b) override { return button(b, true); }
Status buttonUp(MouseButton b) override { return button(b, false); }
Status scroll(int dx, int dy) override {
std::vector<INPUT> events;
if (dy) {
INPUT in{};
in.type = INPUT_MOUSE;
in.mi.dwFlags = MOUSEEVENTF_WHEEL;
in.mi.mouseData = static_cast<DWORD>(-dy * WHEEL_DELTA); // positive = away from user (up)
events.push_back(in);
}
if (dx) {
INPUT in{};
in.type = INPUT_MOUSE;
in.mi.dwFlags = MOUSEEVENTF_HWHEEL;
in.mi.mouseData = static_cast<DWORD>(dx * WHEEL_DELTA);
events.push_back(in);
}
return send(events);
}
Status keyDown(Key k) override { return key(k, true); }
Status keyUp(Key k) override { return key(k, false); }
Status typeText(std::string_view utf8) override {
std::wstring text = widen(utf8);
std::size_t i = 0;
while (i < text.size()) {
wchar_t c = text[i];
if (c == L'\r') {
++i;
continue;
}
if (c == L'\n') {
if (auto s = keyPress(Key::Enter); !s) return s;
++i;
continue;
}
// KEYEVENTF_UNICODE sends UTF-16 units; a surrogate pair goes as two units.
std::size_t units = (IS_HIGH_SURROGATE(c) && i + 1 < text.size()) ? 2 : 1;
std::vector<INPUT> events;
for (std::size_t k = 0; k < units; ++k) {
INPUT down{};
down.type = INPUT_KEYBOARD;
down.ki.wScan = text[i + k];
down.ki.dwFlags = KEYEVENTF_UNICODE;
INPUT up = down;
up.ki.dwFlags |= KEYEVENTF_KEYUP;
events.push_back(down);
events.push_back(up);
}
i += units;
if (auto s = send(std::move(events)); !s) return s;
pause();
}
return {};
}
private:
Status button(MouseButton b, bool down) {
INPUT in{};
in.type = INPUT_MOUSE;
switch (b) {
case MouseButton::Left: in.mi.dwFlags = down ? MOUSEEVENTF_LEFTDOWN : MOUSEEVENTF_LEFTUP; break;
case MouseButton::Right: in.mi.dwFlags = down ? MOUSEEVENTF_RIGHTDOWN : MOUSEEVENTF_RIGHTUP; break;
case MouseButton::Middle: in.mi.dwFlags = down ? MOUSEEVENTF_MIDDLEDOWN : MOUSEEVENTF_MIDDLEUP; break;
case MouseButton::Back:
case MouseButton::Forward:
in.mi.dwFlags = down ? MOUSEEVENTF_XDOWN : MOUSEEVENTF_XUP;
in.mi.mouseData = b == MouseButton::Back ? XBUTTON1 : XBUTTON2;
break;
}
return send({in});
}
Status key(Key k, bool down) {
WORD vk = toVirtualKey(k);
if (vk == 0) return fail(ErrorCode::InvalidArgument, "key has no Windows mapping");
INPUT in{};
in.type = INPUT_KEYBOARD;
in.ki.wVk = vk;
in.ki.wScan = static_cast<WORD>(MapVirtualKeyW(vk, MAPVK_VK_TO_VSC));
in.ki.dwFlags = (down ? 0 : KEYEVENTF_KEYUP) | (isExtended(k) ? KEYEVENTF_EXTENDEDKEY : 0);
return send({in});
}
Status send(std::vector<INPUT> events) {
if (events.empty()) return {};
UINT sent = SendInput(static_cast<UINT>(events.size()), events.data(), sizeof(INPUT));
if (sent != events.size())
return fail(ErrorCode::PermissionDenied, lastErrorMessage("SendInput (blocked by UIPI?)"));
return {};
}
};
} // namespace
Result<std::unique_ptr<Input>> createSendInput(const InputOptions&) {
ensureDpiAware();
return std::make_unique<SendInputBackend>();
}
} // namespace alvin::win_impl
+118
View File
@@ -0,0 +1,118 @@
// Top-level window enumeration and focus via Win32 + DWM.
#include <dwmapi.h>
#include "backends.hpp"
#include "win_util.hpp"
namespace alvin::win_impl {
namespace {
std::string processName(DWORD pid) {
HANDLE proc = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
if (!proc) return {};
wchar_t path[MAX_PATH];
DWORD size = MAX_PATH;
std::string name;
if (QueryFullProcessImageNameW(proc, 0, path, &size)) {
std::wstring_view p(path, size);
if (auto slash = p.find_last_of(L"\\/"); slash != std::wstring_view::npos) p.remove_prefix(slash + 1);
name = narrow(p);
}
CloseHandle(proc);
return name;
}
// Windows a user would consider "open windows" (roughly what Alt+Tab shows).
bool isUserWindow(HWND hwnd) {
if (!IsWindowVisible(hwnd) || GetWindow(hwnd, GW_OWNER) != nullptr) return false;
if (GetWindowLongPtrW(hwnd, GWL_EXSTYLE) & WS_EX_TOOLWINDOW) return false;
DWORD cloaked = 0; // hidden UWP frames and windows on other virtual desktops
if (SUCCEEDED(DwmGetWindowAttribute(hwnd, DWMWA_CLOAKED, &cloaked, sizeof cloaked)) && cloaked) return false;
return GetWindowTextLengthW(hwnd) > 0;
}
WindowInfo describe(HWND hwnd, HWND foreground) {
WindowInfo w;
w.id.value = reinterpret_cast<std::uint64_t>(hwnd);
int len = GetWindowTextLengthW(hwnd);
std::wstring title(static_cast<std::size_t>(len) + 1, L'\0');
GetWindowTextW(hwnd, title.data(), len + 1);
title.resize(static_cast<std::size_t>(len));
w.title = narrow(title);
DWORD pid = 0;
GetWindowThreadProcessId(hwnd, &pid);
w.pid = pid;
w.appId = processName(pid);
// Extended frame bounds exclude the invisible resize borders of Windows 10+.
RECT r{};
if (FAILED(DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, &r, sizeof r))) GetWindowRect(hwnd, &r);
w.bounds = {r.left, r.top, r.right - r.left, r.bottom - r.top};
w.focused = hwnd == foreground;
w.minimized = IsIconic(hwnd) != 0;
w.visible = !w.minimized;
HMONITOR mon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONULL);
MONITORINFO mi{};
mi.cbSize = sizeof mi;
if (mon && GetMonitorInfoW(mon, &mi)) {
const RECT& m = mi.rcMonitor;
w.fullscreen = r.left <= m.left && r.top <= m.top && r.right >= m.right && r.bottom >= m.bottom;
}
return w;
}
class Win32Windows final : public WindowManager {
public:
WindowBackend backend() const override { return WindowBackend::Win32; }
Result<std::vector<WindowInfo>> list() override {
struct Ctx {
HWND foreground;
std::vector<WindowInfo> out;
} ctx{GetForegroundWindow(), {}};
EnumWindows(
[](HWND hwnd, LPARAM data) -> BOOL {
auto* c = reinterpret_cast<Ctx*>(data);
if (isUserWindow(hwnd)) c->out.push_back(describe(hwnd, c->foreground));
return TRUE;
},
reinterpret_cast<LPARAM>(&ctx));
return std::move(ctx.out);
}
Result<std::optional<WindowInfo>> active() override {
HWND fg = GetForegroundWindow();
if (!fg) return std::optional<WindowInfo>{};
return std::optional{describe(fg, fg)};
}
Status focus(WindowId id) override {
HWND hwnd = reinterpret_cast<HWND>(id.value);
if (!IsWindow(hwnd)) return fail(ErrorCode::NotFound, "no such window");
if (IsIconic(hwnd)) ShowWindow(hwnd, SW_RESTORE);
if (SetForegroundWindow(hwnd)) return {};
// The foreground lock only lets the process that received the last input
// change focus. A synthetic Alt tap makes us that process.
INPUT alt[2]{};
alt[0].type = alt[1].type = INPUT_KEYBOARD;
alt[0].ki.wVk = alt[1].ki.wVk = VK_MENU;
alt[1].ki.dwFlags = KEYEVENTF_KEYUP;
SendInput(2, alt, sizeof(INPUT));
if (SetForegroundWindow(hwnd)) return {};
return fail(ErrorCode::PermissionDenied, "Windows refused to change the foreground window");
}
};
} // namespace
Result<std::unique_ptr<WindowManager>> createWin32Windows() {
ensureDpiAware();
return std::make_unique<Win32Windows>();
}
} // namespace alvin::win_impl
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#ifndef NOMINMAX
#define NOMINMAX
#endif
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#include <string>
#include <string_view>
#include "alvin/error.hpp"
namespace alvin::win_impl {
std::string narrow(std::wstring_view w);
std::wstring widen(std::string_view s);
// Message for GetLastError(), prefixed with `what`.
std::string lastErrorMessage(std::string_view what);
// Opt the process into per-monitor DPI awareness once, so that every Win32
// coordinate Alvin sees or produces is in physical pixels and consistent
// between capture, input and window bounds.
void ensureDpiAware();
} // namespace alvin::win_impl
+22
View File
@@ -0,0 +1,22 @@
find_package(Catch2 3 QUIET)
if(NOT Catch2_FOUND)
include(FetchContent)
FetchContent_Declare(Catch2
GIT_REPOSITORY https://github.com/catchorg/Catch2.git
GIT_TAG v3.7.1)
FetchContent_MakeAvailable(Catch2)
endif()
add_executable(alvin_tests
test_core.cpp
test_json.cpp)
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
target_sources(alvin_tests PRIVATE test_linux.cpp)
target_link_libraries(alvin_tests PRIVATE PkgConfig::XKBCOMMON)
endif()
# Tests exercise internal helpers too, so they see the private source tree.
target_include_directories(alvin_tests PRIVATE ${PROJECT_SOURCE_DIR}/src)
target_link_libraries(alvin_tests PRIVATE alvin::alvin Catch2::Catch2WithMain)
include(Catch)
catch_discover_tests(alvin_tests)
+66
View File
@@ -0,0 +1,66 @@
#include <catch2/catch_test_macros.hpp>
#include "alvin/frame.hpp"
#include "alvin/keys.hpp"
#include "alvin/types.hpp"
using namespace alvin;
TEST_CASE("key names round-trip and aliases resolve") {
CHECK(keyFromName("A") == Key::A);
CHECK(keyFromName("ctrl") == Key::LeftCtrl);
CHECK(keyFromName("Control") == Key::LeftCtrl);
CHECK(keyFromName("esc") == Key::Escape);
CHECK(keyFromName("F12") == Key::F12);
CHECK_FALSE(keyFromName("nope"));
for (int k = 1; k < static_cast<int>(Key::Count_); ++k) {
auto key = static_cast<Key>(k);
INFO("key index " << k);
CHECK(keyFromName(keyName(key)) == key);
}
}
TEST_CASE("chords parse in order") {
auto c = parseChord("ctrl+shift+t");
REQUIRE(c);
CHECK(*c == std::vector{Key::LeftCtrl, Key::LeftShift, Key::T});
CHECK(parseChord(" super + enter ") == std::vector{Key::LeftSuper, Key::Enter});
CHECK(parseChord("ctrl++") == std::vector{Key::LeftCtrl, Key::Equal});
CHECK_FALSE(parseChord("ctrl+"));
CHECK_FALSE(parseChord(""));
CHECK_FALSE(parseChord("ctrl+bogus"));
}
TEST_CASE("rect union ignores empty rects") {
Rect a{0, 0, 10, 10}, b{20, -5, 5, 5};
CHECK(a.united(b) == Rect{0, -5, 25, 15});
CHECK(Rect{}.united(a) == a);
CHECK(a.united(Rect{}) == a);
CHECK(a.contains({9, 9}));
CHECK_FALSE(a.contains({10, 0}));
}
TEST_CASE("frame maps between pixels and desktop coordinates") {
// A 1.5x scaled monitor: 300x150 pixels covering 200x100 logical at (1000, 50).
Frame f(300, 150);
f.setDesktopRect({1000, 50, 200, 100});
CHECK(f.toDesktop({0, 0}) == Point{1000, 50});
CHECK(f.toDesktop({150, 75}) == Point{1100, 100});
CHECK(f.fromDesktop({1100, 100}) == Point{150, 75});
Frame c = f.crop({150, 0, 1000, 75}); // clipped to the frame
CHECK(c.width() == 150);
CHECK(c.height() == 75);
CHECK(c.desktopRect() == Rect{1100, 50, 100, 50});
}
TEST_CASE("frame pixel reads BGRA") {
Frame f(2, 1);
f.data()[4] = 1; // B
f.data()[5] = 2; // G
f.data()[6] = 3; // R
f.data()[7] = 4; // A
CHECK(f.pixel(1, 0) == Color{3, 2, 1, 4});
CHECK(f.pixel(5, 5) == Color{});
}
+33
View File
@@ -0,0 +1,33 @@
#include <catch2/catch_test_macros.hpp>
#include "common/json.hpp"
using namespace alvin;
TEST_CASE("json parses a Hyprland client entry") {
auto v = json::parse(R"([{
"address": "0x55a4165aec60",
"at": [2903, 58], "size": [2834, 1539],
"workspace": {"id": 8, "name": "8"},
"hidden": false, "title": "Café \"quoted\"", "pid": 3163
}])");
REQUIRE(v);
const auto& c = (*v)[0];
CHECK(c["address"].string() == "0x55a4165aec60");
CHECK(c["at"][0].number() == 2903);
CHECK(c["size"][1].number() == 1539);
CHECK(c["workspace"]["name"].string() == "8");
CHECK(c["hidden"].boolean(true) == false);
CHECK(c["title"].string() == "Caf\xc3\xa9 \"quoted\"");
CHECK(c["missing"].isNull());
CHECK(c["missing"]["deeper"].isNull());
}
TEST_CASE("json rejects malformed input") {
CHECK_FALSE(json::parse("{"));
CHECK_FALSE(json::parse("[1,]"));
CHECK_FALSE(json::parse("{\"a\" 1}"));
CHECK_FALSE(json::parse("[1] trailing"));
CHECK(json::parse(" {} "));
CHECK(json::parse("\"\\ud83d\\ude00\"")->string() == "\xf0\x9f\x98\x80");
}
+60
View File
@@ -0,0 +1,60 @@
#include <catch2/catch_test_macros.hpp>
#include <linux/input-event-codes.h>
#include "linux/keycodes.hpp"
#include "linux/xkb.hpp"
using namespace alvin;
using namespace alvin::linux_impl;
TEST_CASE("utf8 decoding") {
auto t = decodeUtf8("a\xc3\xa9\xe2\x82\xac\xf0\x9f\x98\x80");
REQUIRE(t);
CHECK(*t == U"aé€\U0001F600");
CHECK_FALSE(decodeUtf8("\xc3"));
CHECK_FALSE(decodeUtf8("\xff"));
}
TEST_CASE("us layout strokes") {
auto km = XkbKeymap::fromNames("us");
REQUIRE(km);
auto a = km->strokeFor(U'a');
REQUIRE(a);
CHECK(a->key == KEY_A);
CHECK(a->modifiers.empty());
auto upper = km->strokeFor(U'A');
REQUIRE(upper);
CHECK(upper->key == KEY_A);
CHECK(upper->modifiers == std::vector<std::uint32_t>{KEY_LEFTSHIFT});
auto bang = km->strokeFor(U'!');
REQUIRE(bang);
CHECK(bang->key == KEY_1);
CHECK(km->strokeFor(U'\n')->key == KEY_ENTER);
CHECK_FALSE(km->strokeFor(U'é')); // not on plain US
CHECK_FALSE(km->strokesFor(U"oké"));
}
TEST_CASE("synthesized keymaps compile and map codes 1..n") {
std::u32string alphabet = U"hé\U0001F600\n";
auto source = synthesizeKeymap(alphabet);
REQUIRE(source);
auto km = XkbKeymap::fromString(*source);
REQUIRE(km);
for (std::size_t i = 0; i < alphabet.size(); ++i) {
auto s = km->strokeFor(alphabet[i]);
REQUIRE(s);
CHECK(s->key == i + 1);
CHECK(s->modifiers.empty());
}
}
TEST_CASE("every key maps to an evdev code") {
for (int k = 1; k < static_cast<int>(Key::Count_); ++k) {
INFO("key index " << k);
CHECK(toEvdev(static_cast<Key>(k)) != 0);
}
}