Initial commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
artifacts/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,104 @@
|
||||
# Desktop harness — first slice
|
||||
|
||||
Manual SSH and X11 controls. There is **no model loop, persistent conversation, scheduler, or Jev integration yet**. The package name is a placeholder.
|
||||
|
||||
## On berlin (or your development computer)
|
||||
|
||||
Requires Node.js 22+ and an SSH client. Configure an SSH alias `home` for the VM's desktop user, with key authentication. Connect manually first to verify and save its host key:
|
||||
|
||||
```sh
|
||||
ssh home
|
||||
```
|
||||
|
||||
The CLI requires known host keys and noninteractive authentication. It does not bypass SSH verification.
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run build
|
||||
npm run home -- install home
|
||||
```
|
||||
|
||||
## Inside home
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```sh
|
||||
sudo pacman -S --needed python python-pillow xdotool xclip
|
||||
```
|
||||
|
||||
From a terminal **inside the logged-in XFCE desktop**, run:
|
||||
|
||||
```sh
|
||||
python3 ~/.local/lib/desktop-harness/desktop.py --register-session
|
||||
```
|
||||
|
||||
This records the current graphical session environment for SSH requests. Installation also creates `~/.config/autostart/desktop-harness-session.desktop`, so subsequent XFCE logins refresh registration automatically. Re-running `install` updates both the helper and its autostart entry. No periodic refresh is needed within the same session. Keep the desktop logged in and unlocked for these initial checks.
|
||||
|
||||
To test automatic registration, log out and back into XFCE, then capture from berlin without running the registration command manually. The helper assumes one graphical session for this account; it does not log in, unlock the screen, or choose between concurrent sessions. To disable automatic registration, remove the autostart file.
|
||||
|
||||
## Try capturing
|
||||
|
||||
From berlin:
|
||||
|
||||
```sh
|
||||
npm run home -- capture home artifacts/first.png
|
||||
```
|
||||
|
||||
Open the PNG. Check dimensions and that it shows the actual desktop rather than a blank or different display.
|
||||
|
||||
## Try input
|
||||
|
||||
Open Mousepad or another ordinary GUI text editor manually, focus a blank document, and create `actions.json` on berlin:
|
||||
|
||||
```json
|
||||
{
|
||||
"actions": [
|
||||
{ "type": "text", "text": "Hello from berlin!\nUnicode: café — こんにちは" },
|
||||
{ "type": "wait", "milliseconds": 300 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```sh
|
||||
npm run home -- act home actions.json artifacts/typed.png
|
||||
```
|
||||
|
||||
Verify the text and screenshot. Text insertion **replaces the clipboard** and pastes with Ctrl+V; this is for ordinary GUI text fields, not terminals (which often need Ctrl+Shift+V). For shell work use the shell command below.
|
||||
|
||||
Other action examples:
|
||||
|
||||
```json
|
||||
{
|
||||
"expectedSize": [1280, 800],
|
||||
"actions": [
|
||||
{ "type": "click", "x": 300, "y": 200, "button": "left" },
|
||||
{ "type": "keys", "keys": ["ctrl", "a"] },
|
||||
{ "type": "scroll", "direction": "down", "steps": 2 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Use coordinates from your own screenshot, not these example coordinates. `expectedSize` is optional and rejects input if the screen size changed. Supported actions are click, scroll, keys, text, and wait. Drag is deferred. Actions are serialized by a VM-side lock; avoid manual interaction while a request runs. The full request is validated before any actions execute, but runtime failures can still leave partial effects.
|
||||
|
||||
## Try shell execution
|
||||
|
||||
```sh
|
||||
npm run home -- shell home 'printf "hello\n"; uname -a'
|
||||
npm run home -- shell home 'sleep 10' 2
|
||||
```
|
||||
|
||||
The second command should time out. GNU `timeout` runs inside the VM and sends TERM, then KILL after two seconds. This is not a sandbox: commands have the desktop user's permissions, and processes that deliberately detach can escape the timeout. Output is limited to 32 MiB across stdout/stderr; exceeding it disconnects SSH and reports an unknown outcome. Commands must be one quoted local argument. Default timeout is 30 seconds, maximum 300.
|
||||
|
||||
## Local checks
|
||||
|
||||
```sh
|
||||
npm run check
|
||||
npm test
|
||||
python3 -m unittest discover -s test -p 'test_*.py'
|
||||
```
|
||||
|
||||
Desktop dependencies are imported only when operating the display, so validation tests can run without X11 or Pillow.
|
||||
|
||||
## Next milestone
|
||||
|
||||
Once capture, Unicode insertion, key combinations, and shell timeout work on the real VM, add the llama.cpp adapter, durable tool-call records, and a single model/tool loop. Wake/sleep and restart recovery follow. No SSH connection or real graphical session is available in the development sandbox, so those checks must be run on your setup.
|
||||
Generated
+46
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "desktop-harness",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "desktop-harness",
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.0",
|
||||
"typescript": "^5.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.9.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.8.tgz",
|
||||
"integrity": "sha512-VfMrScDmMhUJQmd5hArdQnFvK0OIeD36uN2Va1FcpYaLB/BgkgM8Ulc50XtYISPMz7APJ30+N0T5EM0jlcdfRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "desktop-harness",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"check": "tsc --noEmit",
|
||||
"test": "npm run build && node --test dist/test/*.test.js",
|
||||
"home": "node dist/src/main.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.0",
|
||||
"typescript": "^5.9.0"
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { readFile, mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { quoteShell, remote } from "./ssh.js";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const [operation, destination, argument, extra] = process.argv.slice(2);
|
||||
if (!operation || !destination) {
|
||||
throw new Error("Usage: home <install|capture|act|shell> <ssh-alias> [output.png|actions.json|command] [seconds]");
|
||||
}
|
||||
const helper = '"$HOME/.local/lib/desktop-harness/desktop.py"';
|
||||
if (operation === "install") {
|
||||
const source = await readFile(new URL("../../vm/desktop.py", import.meta.url), "utf8");
|
||||
const result = await remote(destination, `mkdir -p "$HOME/.local/lib/desktop-harness" && cat > ${helper} && python3 ${helper} --install-autostart`, source);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || "Installation failed.");
|
||||
}
|
||||
console.log("Installed desktop helper and XFCE autostart. Future logins register automatically; for the current session, run registration once in an XFCE terminal (see README.md).");
|
||||
return;
|
||||
}
|
||||
if (operation === "shell") {
|
||||
if (!argument) {
|
||||
throw new Error("Supply a shell command as one quoted argument.");
|
||||
}
|
||||
const seconds = Number(extra ?? 30);
|
||||
if (!Number.isInteger(seconds) || seconds < 1 || seconds > 300) {
|
||||
throw new Error("Timeout must be 1–300 seconds.");
|
||||
}
|
||||
const result = await remote(destination,
|
||||
`timeout --signal=TERM --kill-after=2s ${seconds}s bash -lc ${quoteShell(argument)}`,
|
||||
"", (seconds + 20) * 1000);
|
||||
process.stdout.write(result.stdout);
|
||||
process.stderr.write(result.stderr);
|
||||
if (result.code === 255 || result.code === null) {
|
||||
throw new Error("SSH disconnected; command outcome is unknown.");
|
||||
}
|
||||
console.error(`\nExit status: ${result.code}${result.code === 124 || result.code === 137 ? " (timeout/termination)" : ""}`);
|
||||
process.exitCode = result.code;
|
||||
return;
|
||||
}
|
||||
if (operation !== "capture" && operation !== "act") {
|
||||
throw new Error(`Unknown operation: ${operation}`);
|
||||
}
|
||||
const request = operation === "capture"
|
||||
? { actions: [] }
|
||||
: JSON.parse(await readFile(argument ?? "actions.json", "utf8")) as unknown;
|
||||
const result = await remote(destination, `python3 ${helper}`, JSON.stringify(request));
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || "Desktop request failed; some actions may already have executed.");
|
||||
}
|
||||
const response: unknown = JSON.parse(result.stdout.toString("utf8"));
|
||||
if (typeof response !== "object" || response === null || !("image" in response)
|
||||
|| typeof response.image !== "string") {
|
||||
throw new Error("Invalid desktop response.");
|
||||
}
|
||||
const output = resolve(operation === "capture" ? argument ?? "artifacts/desktop.png" : extra ?? "artifacts/desktop.png");
|
||||
await mkdir(dirname(output), { recursive: true });
|
||||
await writeFile(output, Buffer.from(response.image, "base64"));
|
||||
const { image, ...metadata } = response;
|
||||
console.log(JSON.stringify({ ...metadata, screenshot: output }, null, 2));
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
export function quoteShell(value: string): string {
|
||||
return "'" + value.replaceAll("'", "'\\''") + "'";
|
||||
}
|
||||
|
||||
export interface RemoteResult {
|
||||
code: number | null;
|
||||
stdout: Buffer;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export function remote(destination: string, command: string, input = "", timeoutMs = 30_000): Promise<RemoteResult> {
|
||||
if (!destination || destination.startsWith("-") || /\s/.test(destination)) {
|
||||
throw new Error("Use an SSH config alias or user@hostname as the destination.");
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("ssh", [
|
||||
"-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes",
|
||||
"-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=10",
|
||||
"-o", "ServerAliveCountMax=2", destination, command,
|
||||
], { stdio: "pipe" });
|
||||
const stdout: Buffer[] = [];
|
||||
const stderr: Buffer[] = [];
|
||||
let size = 0;
|
||||
let failure: Error | undefined;
|
||||
const timer = setTimeout(() => {
|
||||
failure = new Error("SSH deadline exceeded; remote outcome is unknown.");
|
||||
child.kill("SIGKILL");
|
||||
}, timeoutMs);
|
||||
const collect = (chunks: Buffer[], chunk: Buffer): void => {
|
||||
size += chunk.length;
|
||||
if (size > 32 * 1024 * 1024) {
|
||||
failure = new Error("SSH output exceeded 32 MiB; remote outcome is unknown.");
|
||||
child.kill("SIGKILL");
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
};
|
||||
child.stdout.on("data", (chunk: Buffer) => collect(stdout, chunk));
|
||||
child.stderr.on("data", (chunk: Buffer) => collect(stderr, chunk));
|
||||
child.stdin.on("error", () => {
|
||||
// SSH's exit status and stderr explain a closed input pipe.
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (failure) {
|
||||
reject(failure);
|
||||
} else {
|
||||
resolve({ code, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr).toString("utf8") });
|
||||
}
|
||||
});
|
||||
child.stdin.end(input);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { quoteShell, remote } from "../src/ssh.js";
|
||||
|
||||
test("shell quoting preserves literal input", () => {
|
||||
for (const value of ["", "hello", "a'b", "$(echo unsafe);\n*", "こんにちは"]) {
|
||||
assert.equal(execFileSync("sh", ["-c", `printf %s ${quoteShell(value)}`], { encoding: "utf8" }), value);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects option-like SSH destinations", () => {
|
||||
assert.throws(() => remote("-oProxyCommand=bad", "true"));
|
||||
assert.throws(() => remote("home extra", "true"));
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
import tempfile
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
spec = importlib.util.spec_from_file_location("desktop", Path(__file__).parents[1] / "vm/desktop.py")
|
||||
desktop = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(desktop)
|
||||
|
||||
|
||||
class ValidationTests(unittest.TestCase):
|
||||
def test_session_save(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
state = Path(directory)
|
||||
with patch.object(desktop, "STATE", state):
|
||||
desktop.save_session({"DISPLAY": ":0"})
|
||||
desktop.save_session({"DISPLAY": ":1"})
|
||||
path = state / "session.json"
|
||||
self.assertEqual(json.loads(path.read_text()), {"DISPLAY": ":1"})
|
||||
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual(len(list(state.iterdir())), 1)
|
||||
|
||||
def test_autostart(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with patch.object(Path, "home", return_value=Path(directory)):
|
||||
desktop.install_autostart()
|
||||
desktop.install_autostart()
|
||||
entries = list((Path(directory) / ".config/autostart").iterdir())
|
||||
self.assertEqual(len(entries), 1)
|
||||
self.assertIn("--register-session\n", entries[0].read_text())
|
||||
self.assertIn("OnlyShowIn=XFCE;", entries[0].read_text())
|
||||
|
||||
def test_capture(self):
|
||||
self.assertEqual(desktop.validate({"actions": []}, 100, 100), [])
|
||||
|
||||
def test_bounds(self):
|
||||
for x in (-1, 100, True, "2"):
|
||||
with self.assertRaises(ValueError):
|
||||
desktop.validate({"actions": [{"type": "click", "x": x, "y": 0}]}, 100, 100)
|
||||
|
||||
def test_invalid_keys(self):
|
||||
with self.assertRaises(ValueError):
|
||||
desktop.validate({"actions": [{"type": "keys", "keys": ["--repeat"]}]}, 100, 100)
|
||||
|
||||
def test_wait_budget(self):
|
||||
with self.assertRaises(ValueError):
|
||||
desktop.validate({"actions": [{"type": "wait", "milliseconds": 3000}] * 2}, 100, 100)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
import fcntl
|
||||
import tempfile
|
||||
|
||||
STATE = Path.home() / ".local/state/desktop-harness"
|
||||
|
||||
|
||||
def run(*args, input=None):
|
||||
return subprocess.run(args, input=input, check=True, capture_output=True, timeout=10).stdout
|
||||
|
||||
|
||||
def integer(value, low, high):
|
||||
if type(value) is not int or not low <= value <= high:
|
||||
raise ValueError(f"Expected integer between {low} and {high}")
|
||||
return value
|
||||
|
||||
|
||||
def validate(request, width, height):
|
||||
if not isinstance(request, dict) or not isinstance(request.get("actions"), list):
|
||||
raise ValueError("Request must contain an actions array")
|
||||
actions = request["actions"]
|
||||
if len(actions) > 20:
|
||||
raise ValueError("At most 20 actions per request")
|
||||
total_wait = 0
|
||||
for action in actions:
|
||||
if not isinstance(action, dict):
|
||||
raise ValueError("Actions must be objects")
|
||||
kind = action.get("type")
|
||||
if kind == "click":
|
||||
integer(action.get("x"), 0, width - 1)
|
||||
integer(action.get("y"), 0, height - 1)
|
||||
if action.get("button", "left") not in ("left", "middle", "right"):
|
||||
raise ValueError("Unknown mouse button")
|
||||
elif kind == "scroll":
|
||||
if action.get("direction") not in ("up", "down"):
|
||||
raise ValueError("Unknown scroll direction")
|
||||
integer(action.get("steps"), 1, 30)
|
||||
elif kind == "keys":
|
||||
keys = action.get("keys")
|
||||
if not isinstance(keys, list) or not 1 <= len(keys) <= 8:
|
||||
raise ValueError("Supply 1–8 key names")
|
||||
if any(not isinstance(key, str) or not key or not all(c.isalnum() or c == "_" for c in key) for key in keys):
|
||||
raise ValueError("Invalid X11 key name")
|
||||
elif kind == "text":
|
||||
if not isinstance(action.get("text"), str) or len(action["text"]) > 20000 or "\0" in action["text"]:
|
||||
raise ValueError("Text must contain at most 20000 characters and no NUL")
|
||||
elif kind == "wait":
|
||||
total_wait += integer(action.get("milliseconds"), 0, 5000)
|
||||
else:
|
||||
raise ValueError(f"Unsupported action: {kind}")
|
||||
if total_wait > 5000:
|
||||
raise ValueError("Total requested wait exceeds 5 seconds")
|
||||
return actions
|
||||
|
||||
|
||||
def install_autostart():
|
||||
directory = Path.home() / ".config/autostart"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
# Desktop Exec quoting is not shell quoting; percent signs are field codes.
|
||||
helper = str(Path(__file__).resolve()).replace("%", "%%")
|
||||
for character in ("\\", '"', "`", "$"):
|
||||
helper = helper.replace(character, "\\" + character)
|
||||
(directory / "desktop-harness-session.desktop").write_text(
|
||||
"[Desktop Entry]\n"
|
||||
"Type=Application\n"
|
||||
"Name=Desktop harness session registration\n"
|
||||
f'Exec=python3 "{helper}" --register-session\n'
|
||||
"Terminal=false\n"
|
||||
"OnlyShowIn=XFCE;\n"
|
||||
)
|
||||
|
||||
|
||||
def save_session(environment):
|
||||
# Readers must see either the previous complete environment or the new one.
|
||||
with tempfile.NamedTemporaryFile(mode="w", dir=STATE, delete=False) as file:
|
||||
temporary = Path(file.name)
|
||||
try:
|
||||
json.dump(environment, file)
|
||||
file.close()
|
||||
temporary.replace(STATE / "session.json")
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def main():
|
||||
STATE.mkdir(parents=True, exist_ok=True)
|
||||
if sys.argv[1:] == ["--install-autostart"]:
|
||||
install_autostart()
|
||||
print("Installed XFCE session registration autostart")
|
||||
return
|
||||
if sys.argv[1:] == ["--register-session"]:
|
||||
if not os.environ.get("DISPLAY") or os.environ.get("XDG_SESSION_TYPE") == "wayland":
|
||||
raise ValueError("Run registration from a terminal inside the XFCE/X11 desktop")
|
||||
environment = {key: os.environ[key] for key in ("DISPLAY", "XAUTHORITY", "DBUS_SESSION_BUS_ADDRESS") if key in os.environ}
|
||||
save_session(environment)
|
||||
print("Registered graphical session")
|
||||
return
|
||||
if not (STATE / "session.json").exists():
|
||||
raise ValueError("No desktop session registered. Log into XFCE or run desktop.py --register-session in an XFCE terminal.")
|
||||
environment = json.loads((STATE / "session.json").read_text())
|
||||
os.environ.update(environment)
|
||||
with (STATE / "desktop.lock").open("w") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
from PIL import ImageGrab
|
||||
import io
|
||||
import time
|
||||
screenshot = ImageGrab.grab(xdisplay=os.environ["DISPLAY"])
|
||||
width, height = screenshot.size
|
||||
raw = sys.stdin.read(256001)
|
||||
if len(raw) > 256000:
|
||||
raise ValueError("Request too large")
|
||||
request = json.loads(raw)
|
||||
actions = validate(request, width, height)
|
||||
expected = request.get("expectedSize")
|
||||
if expected is not None and expected != [width, height]:
|
||||
raise ValueError("Display geometry changed; capture again before acting")
|
||||
completed = 0
|
||||
for action in actions:
|
||||
kind = action["type"]
|
||||
try:
|
||||
if kind == "click":
|
||||
button = {"left": "1", "middle": "2", "right": "3"}[action.get("button", "left")]
|
||||
run("xdotool", "mousemove", "--sync", str(action["x"]), str(action["y"]), "click", button)
|
||||
elif kind == "scroll":
|
||||
run("xdotool", "click", "--repeat", str(action["steps"]), "--delay", "50", "4" if action["direction"] == "up" else "5")
|
||||
elif kind == "keys":
|
||||
run("xdotool", "key", "--clearmodifiers", "+".join(action["keys"]))
|
||||
elif kind == "text":
|
||||
# xclip owns the selection in the background. Replacing the clipboard is intentional.
|
||||
subprocess.run(["xclip", "-selection", "clipboard"], input=action["text"].encode(), check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5)
|
||||
run("xdotool", "key", "--clearmodifiers", "ctrl+v")
|
||||
elif kind == "wait":
|
||||
time.sleep(action["milliseconds"] / 1000)
|
||||
completed += 1
|
||||
except Exception as error:
|
||||
raise RuntimeError(f"Action {completed} failed after {completed} completed actions; partial effects possible: {error}") from error
|
||||
screenshot = ImageGrab.grab(xdisplay=os.environ["DISPLAY"])
|
||||
buffer = io.BytesIO()
|
||||
screenshot.save(buffer, format="PNG")
|
||||
print(json.dumps({
|
||||
"observationId": str(uuid.uuid4()),
|
||||
"capturedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"width": screenshot.width,
|
||||
"height": screenshot.height,
|
||||
"completedActions": completed,
|
||||
"image": base64.b64encode(buffer.getvalue()).decode(),
|
||||
}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as error:
|
||||
print(str(error), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user