From 1f14c5483ee111105f94d66fb3b82208946d914a Mon Sep 17 00:00:00 2001 From: Andrew Branson Date: Fri, 15 May 2026 12:21:39 +0200 Subject: Initial Sailfish devel MCP --- src/sailfish_devel_mcp/tools.py | 1282 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 1282 insertions(+) create mode 100644 src/sailfish_devel_mcp/tools.py (limited to 'src/sailfish_devel_mcp/tools.py') diff --git a/src/sailfish_devel_mcp/tools.py b/src/sailfish_devel_mcp/tools.py new file mode 100644 index 0000000..1061e6c --- /dev/null +++ b/src/sailfish_devel_mcp/tools.py @@ -0,0 +1,1282 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path, PurePosixPath +import re +import shlex +import shutil +from typing import Any, Callable + +from .config import Config, DeviceConfig +from .runner import ( + CommandResult, + remote_command, + run, + scp_from_argv, + scp_to_argv, + ssh_argv, + truncate, + user_bus_env, +) + + +ToolHandler = Callable[[dict[str, Any]], dict[str, Any]] + + +@dataclass(frozen=True) +class Tool: + spec: dict[str, Any] + handler: ToolHandler + + +def build_registry(config: Config) -> dict[str, Tool]: + tools = [ + Tool(_spec_devices(), lambda args: handle_devices(config, args)), + Tool(_spec_device_journal(), lambda args: handle_device_journal(config, args)), + Tool(_spec_device_topmost_pid(), lambda args: handle_device_topmost_pid(config, args)), + Tool(_spec_device_proc_maps(), lambda args: handle_device_proc_maps(config, args)), + Tool( + _spec_device_lipstick_screenshot(), + lambda args: handle_device_lipstick_screenshot(config, args), + ), + Tool( + _spec_device_touch(), + lambda args: handle_device_touch(config, args), + ), + Tool( + _spec_device_user_bus_call(), + lambda args: handle_device_user_bus_call(config, args), + ), + Tool(_spec_device_install_rpm(), lambda args: handle_device_install_rpm(config, args)), + Tool( + _spec_device_restart_service(), + lambda args: handle_device_restart_service(config, args), + ), + Tool(_spec_build_rpm(), lambda args: handle_build_rpm(config, args)), + Tool(_spec_obs_results(), lambda args: handle_obs_results(config, args)), + Tool(_spec_obs_buildlog(), lambda args: handle_obs_buildlog(config, args)), + Tool(_spec_repo_status(), lambda args: handle_repo_status(config, args)), + Tool(_spec_repo_find(), lambda args: handle_repo_find(config, args)), + Tool(_spec_spec_summary(), lambda args: handle_spec_summary(config, args)), + Tool( + _spec_qml_find_translations(), + lambda args: handle_qml_find_translations(config, args), + ), + Tool( + _spec_qml_check_translator_ternaries(), + lambda args: handle_qml_check_translator_ternaries(config, args), + ), + ] + return {tool.spec["name"]: tool for tool in tools} + + +def handle_devices(config: Config, args: dict[str, Any]) -> dict[str, Any]: + structured = config.public_dict() + text = "\n".join( + ( + f"{name}: {device.ssh_target}" + f" user={device.username}" + f"{f' arch={device.architecture}' if device.architecture else ''}" + f"{f' release={device.release}' if device.release else ''}" + ) + for name, device in sorted(config.devices.items(), key=lambda item: item[0]) + ) + return ok_text(text or "No configured devices", structured) + + +def handle_device_journal(config: Config, args: dict[str, Any]) -> dict[str, Any]: + device = _device(config, args) + lines = _int_arg(args, "lines", default=200, minimum=1, maximum=5000) + timeout = _int_arg(args, "timeout", default=30, minimum=1, maximum=600) + + command = ["journalctl", "--no-pager", "-n", str(lines)] + unit = _optional_str(args, "unit") + since = _optional_str(args, "since") + grep = _optional_str(args, "grep") + if unit: + command += ["-u", unit] + if since: + command += ["--since", since] + result = _run_ssh(config, device, command, timeout=timeout) + stdout = result.stdout + if grep: + stdout = "\n".join(line for line in stdout.splitlines() if grep in line) + result = CommandResult(result.argv, result.returncode, stdout, result.stderr) + return command_result("device journal", result) + + +def handle_device_topmost_pid(config: Config, args: dict[str, Any]) -> dict[str, Any]: + device = _device(config, args) + timeout = _int_arg(args, "timeout", default=20, minimum=1, maximum=120) + command = user_bus_env(device) + [ + "dbus-send", + "--session", + "--print-reply", + "--dest=org.nemomobile.lipstick", + "/", + "org.freedesktop.DBus.Properties.Get", + "string:org.nemomobile.compositor", + "string:privateTopmostWindowProcessId", + ] + result = _run_ssh(config, device, command, timeout=timeout) + pid = _parse_dbus_integer(result.stdout) + structured = result.public_dict() + structured["pid"] = pid + text = f"topmost PID: {pid}" if pid is not None else _command_text("topmost PID", result) + return { + "content": [{"type": "text", "text": text}], + "structuredContent": structured, + "isError": not result.ok or pid is None, + } + + +def handle_device_proc_maps(config: Config, args: dict[str, Any]) -> dict[str, Any]: + device = _device(config, args) + pid = _int_arg(args, "pid", minimum=1) + contains = _optional_str(args, "contains") + max_lines = _int_arg(args, "max_lines", default=200, minimum=1, maximum=5000) + timeout = _int_arg(args, "timeout", default=20, minimum=1, maximum=120) + + result = _run_ssh(config, device, ["cat", f"/proc/{pid}/maps"], timeout=timeout) + lines = result.stdout.splitlines() + if contains: + lines = [line for line in lines if contains in line] + limited = lines[:max_lines] + stdout = "\n".join(limited) + if len(lines) > max_lines: + stdout += f"\n[truncated after {max_lines} lines]" + filtered = CommandResult(result.argv, result.returncode, stdout, result.stderr) + return command_result("process maps", filtered, {"matched_lines": len(lines), "pid": pid}) + + +def handle_device_lipstick_screenshot(config: Config, args: dict[str, Any]) -> dict[str, Any]: + device = _device(config, args) + timestamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S") + home_path = _device_home_path(device) + remote_path = _optional_str(args, "remote_path") or ( + f"{home_path}/Pictures/Screenshots/lipstick-{timestamp}.png" + ) + local_path_arg = _optional_str(args, "local_path") + privileged = _bool_arg(args, "privileged", default=True) + timeout = _int_arg(args, "timeout", default=30, minimum=1, maximum=120) + + inner = remote_command( + user_bus_env(device) + + [ + "dbus-send", + "--session", + "--print-reply", + "--dest=org.nemomobile.lipstick", + "/org/nemomobile/lipstick/screenshot", + "org.nemomobile.lipstick.saveScreenshot", + f"string:{remote_path}", + ] + ) + prepare_dir = _screenshot_prepare_command(device, remote_path) + dbus_call = f"sg privileged -c {shlex.quote(inner)}" if privileged else inner + remote = f"{prepare_dir} && {dbus_call}" + result = run(ssh_argv(device, config.paths.ssh_config, remote), timeout=timeout) + + structured: dict[str, Any] = result.public_dict() + structured["remote_path"] = remote_path + if result.ok and local_path_arg: + local_path = _safe_output_path(config, local_path_arg) + local_path.parent.mkdir(parents=True, exist_ok=True) + pull = run( + scp_from_argv(device, config.paths.ssh_config, remote_path, local_path), + timeout=timeout, + ) + structured["pull"] = pull.public_dict() + structured["local_path"] = str(local_path) + if not pull.ok: + return command_result("pull screenshot", pull, structured) + return command_result("lipstick screenshot", result, structured) + + +def handle_device_touch(config: Config, args: dict[str, Any]) -> dict[str, Any]: + device = _device(config, args) + action = _enum_arg(args, "action", ["discover", "tap", "swipe"]) + timeout = _int_arg(args, "timeout", default=10, minimum=1, maximum=120) + input_device = _optional_str(args, "input_device") + if input_device and not re.fullmatch(r"/dev/input/event\d+", input_device): + return tool_error("input_device must look like /dev/input/event") + + if action == "discover": + include_evdev_trace = _bool_arg(args, "include_evdev_trace", default=False) + remote = _touch_discover_command(include_evdev_trace) + return command_result( + "touchscreen discovery", + run(ssh_argv(device, config.paths.ssh_config, remote), timeout=timeout), + ) + + values: dict[str, str | int] = {"ACTION": action} + if input_device: + values["INPUT_DEVICE"] = input_device + + if action == "tap": + values["X"] = _int_arg(args, "x", minimum=0, maximum=10000) + values["Y"] = _int_arg(args, "y", minimum=0, maximum=10000) + values["HOLD_MS"] = _int_arg(args, "hold_ms", default=80, minimum=1, maximum=5000) + else: + values["START_X"] = _int_arg(args, "start_x", minimum=0, maximum=10000) + values["START_Y"] = _int_arg(args, "start_y", minimum=0, maximum=10000) + values["END_X"] = _int_arg(args, "end_x", minimum=0, maximum=10000) + values["END_Y"] = _int_arg(args, "end_y", minimum=0, maximum=10000) + values["DURATION_MS"] = _int_arg(args, "duration_ms", default=300, minimum=1, maximum=10000) + values["STEPS"] = _int_arg(args, "steps", default=12, minimum=1, maximum=200) + + remote = _touch_inject_command(values) + structured = { + "action": action, + "input_device": input_device, + } + return command_result( + "device touch", + run(ssh_argv(device, config.paths.ssh_config, remote), timeout=timeout), + structured, + ) + + +def handle_device_user_bus_call(config: Config, args: dict[str, Any]) -> dict[str, Any]: + device = _device(config, args) + destination = _str_arg(args, "destination") + path = _str_arg(args, "path") + interface = _str_arg(args, "interface") + member = _str_arg(args, "member") + dbus_args = args.get("arguments") or [] + if not isinstance(dbus_args, list) or not all(isinstance(item, str) for item in dbus_args): + return tool_error("arguments must be a list of dbus-send argument strings") + timeout = _int_arg(args, "timeout", default=30, minimum=1, maximum=300) + command = user_bus_env(device) + [ + "dbus-send", + "--session", + "--print-reply", + f"--dest={destination}", + path, + f"{interface}.{member}", + *dbus_args, + ] + return command_result("user bus call", _run_ssh(config, device, command, timeout=timeout)) + + +def handle_device_install_rpm(config: Config, args: dict[str, Any]) -> dict[str, Any]: + device = _device(config, args) + rpm_path = _safe_input_path(config, _str_arg(args, "rpm_path"), allow_tmp=True) + remote_path = _optional_str(args, "remote_path") or f"/tmp/{rpm_path.name}" + installer = _enum_arg(args, "installer", ["pkcon", "rpm"], default="pkcon") + timeout = _int_arg(args, "timeout", default=180, minimum=1, maximum=1200) + + copy_result = run( + scp_to_argv(device, config.paths.ssh_config, rpm_path, remote_path), + timeout=timeout, + ) + if not copy_result.ok: + return command_result("copy RPM to device", copy_result) + + if installer == "pkcon": + remote = ["pkcon", "install-local", "-y", remote_path] + else: + remote = ["rpm", "-Uvh", "--replacepkgs", remote_path] + install_result = _run_ssh(config, device, remote, timeout=timeout) + structured = { + "copy": copy_result.public_dict(), + "install": install_result.public_dict(), + "remote_path": remote_path, + "installer": installer, + } + return command_result("install RPM", install_result, structured) + + +def handle_device_restart_service(config: Config, args: dict[str, Any]) -> dict[str, Any]: + device = _device(config, args) + unit = _service_unit_arg(args, "unit") + action = _enum_arg(args, "action", ["restart", "start", "stop", "status"], default="restart") + mode = _enum_arg(args, "mode", ["system", "user"], default="system") + timeout = _int_arg(args, "timeout", default=60, minimum=1, maximum=300) + if mode == "user": + command = user_bus_env(device) + ["systemctl", "--user", action, unit] + else: + command = ["systemctl", action, unit] + return command_result( + f"{mode} service {action}", + _run_ssh(config, device, command, timeout=timeout), + ) + + +def handle_build_rpm(config: Config, args: dict[str, Any]) -> dict[str, Any]: + project_path = _safe_input_path(config, _str_arg(args, "project_path"), allow_tmp=False) + device = config.device(_optional_str(args, "device")) if args.get("device") else None + script = config.paths.build_sailfishos + if not script.exists(): + return tool_error(f"build helper not found: {script}") + + command = ["python3", str(script), "--project-dir", str(project_path)] + release = _optional_str(args, "release") or (device.release if device else None) + arches = args.get("arch") + artifacts_dir = _optional_str(args, "artifacts_dir") + if config.paths.local_sdk: + command += ["--local-sdk", str(config.paths.local_sdk)] + if release: + command += ["--release", release] + if isinstance(arches, str): + command += ["--arch", arches] + elif isinstance(arches, list): + for arch in arches: + if not isinstance(arch, str): + return tool_error("arch must be a string or list of strings") + command += ["--arch", arch] + elif arches is not None: + return tool_error("arch must be a string or list of strings") + elif device and device.architecture: + command += ["--arch", device.architecture] + if artifacts_dir: + output = _safe_output_path(config, artifacts_dir) + command += ["--artifacts-dir", str(output)] + if _bool_arg(args, "all_arches", default=False): + command.append("--all") + if _bool_arg(args, "clean", default=False): + command.append("--clean") + if _bool_arg(args, "debug", default=False): + command.append("--debug") + if _bool_arg(args, "no_pull", default=False): + command.append("--no-pull") + for local_dir in _string_list_arg(args, "local_rpms_dir"): + command += ["--local-rpms-dir", str(_safe_input_path(config, local_dir, allow_tmp=True))] + timeout = _int_arg(args, "timeout", default=3600, minimum=1, maximum=21600) + return command_result("build Sailfish RPM", run(command, timeout=timeout)) + + +def handle_obs_results(config: Config, args: dict[str, Any]) -> dict[str, Any]: + project = _str_arg(args, "project") + package = _optional_str(args, "package") + api_alias = _optional_str(args, "api_alias") or config.paths.osc_api_alias + command = ["osc"] + if api_alias: + command += ["-A", api_alias] + command += ["results", project] + if package: + command.append(package) + timeout = _int_arg(args, "timeout", default=60, minimum=1, maximum=600) + return command_result("OBS results", run(command, timeout=timeout)) + + +def handle_obs_buildlog(config: Config, args: dict[str, Any]) -> dict[str, Any]: + project = _str_arg(args, "project") + package = _str_arg(args, "package") + repository = _str_arg(args, "repository") + arch = _str_arg(args, "arch") + api_alias = _optional_str(args, "api_alias") or config.paths.osc_api_alias + timeout = _int_arg(args, "timeout", default=90, minimum=1, maximum=1800) + command = ["osc"] + if api_alias: + command += ["-A", api_alias] + command += ["remotebuildlog", project, package, repository, arch] + return command_result("OBS build log", run(command, timeout=timeout)) + + +def handle_repo_status(config: Config, args: dict[str, Any]) -> dict[str, Any]: + path = _safe_input_path(config, _optional_str(args, "path") or ".", allow_tmp=False) + timeout = _int_arg(args, "timeout", default=30, minimum=1, maximum=120) + result = run(["git", "status", "--short", "--branch"], cwd=path, timeout=timeout) + return command_result("git status", result, {"path": str(path)}) + + +def handle_repo_find(config: Config, args: dict[str, Any]) -> dict[str, Any]: + path = _safe_input_path(config, _optional_str(args, "path") or ".", allow_tmp=False) + query = _str_arg(args, "query") + max_count = _int_arg(args, "max_count", default=100, minimum=1, maximum=1000) + fixed_strings = _bool_arg(args, "fixed_strings", default=True) + timeout = _int_arg(args, "timeout", default=30, minimum=1, maximum=300) + + if shutil.which("rg"): + command = ["rg", "--line-number", "--hidden", "--glob", "!.git"] + if fixed_strings: + command.append("--fixed-strings") + command += ["--max-count", str(max_count), query, str(path)] + else: + command = ["grep", "-R", "-n", query, str(path)] + result = run(command, timeout=timeout) + if result.returncode == 1 and not result.stdout: + return ok_text("no matches", {"path": str(path), "query": query, "matches": 0}) + return command_result("repo find", result, {"path": str(path), "query": query}) + + +def handle_spec_summary(config: Config, args: dict[str, Any]) -> dict[str, Any]: + path_arg = _optional_str(args, "spec_path") + if path_arg: + spec_path = _safe_input_path(config, path_arg, allow_tmp=False) + else: + repo = _safe_input_path(config, _optional_str(args, "repo_path") or ".", allow_tmp=False) + specs = sorted((repo / "rpm").glob("*.spec")) + if not specs: + return tool_error(f"no rpm/*.spec file found under {repo}") + spec_path = specs[0] + + if not spec_path.exists(): + return tool_error(f"spec file does not exist: {spec_path}") + data = _parse_spec(spec_path) + text_lines = [f"{key}: {value}" for key, value in data.items() if value] + return ok_text("\n".join(text_lines), {"spec_path": str(spec_path), "summary": data}) + + +def handle_qml_find_translations(config: Config, args: dict[str, Any]) -> dict[str, Any]: + path = _safe_input_path(config, _optional_str(args, "path") or ".", allow_tmp=False) + timeout = _int_arg(args, "timeout", default=30, minimum=1, maximum=300) + if not shutil.which("rg"): + return tool_error("rg is required for qml_find_translations") + command = [ + "rg", + "--line-number", + "--glob", + "*.qml", + r"qsTrId|//%|//:", + str(path), + ] + result = run(command, timeout=timeout) + if result.returncode == 1 and not result.stdout: + return ok_text("no QML translation markers found", {"path": str(path), "matches": 0}) + return command_result("QML translations", result, {"path": str(path)}) + + +def handle_qml_check_translator_ternaries(config: Config, args: dict[str, Any]) -> dict[str, Any]: + path = _safe_input_path(config, _optional_str(args, "path") or ".", allow_tmp=False) + files = [path] if path.is_file() and path.suffix == ".qml" else sorted(path.rglob("*.qml")) + findings: list[dict[str, Any]] = [] + for qml in files: + if ".git" in qml.parts: + continue + try: + lines = qml.read_text(encoding="utf-8").splitlines() + except UnicodeDecodeError: + continue + for index, line in enumerate(lines, start=1): + if "qsTrId(" in line and "?" in line and ":" in line: + findings.append( + { + "path": str(qml), + "line": index, + "text": line.strip(), + "message": ( + "ternary qsTrId expression should give each branch " + "its own translator comment and source text" + ), + } + ) + if not findings: + return ok_text("no ternary translation issues found", {"path": str(path), "findings": []}) + text = "\n".join( + f"{item['path']}:{item['line']}: {item['message']}\n {item['text']}" + for item in findings + ) + return { + "content": [{"type": "text", "text": text}], + "structuredContent": {"path": str(path), "findings": findings}, + "isError": True, + } + + +def _run_ssh( + config: Config, + device: DeviceConfig, + command: list[str], + *, + timeout: int, +) -> CommandResult: + return run( + ssh_argv(device, config.paths.ssh_config, remote_command(command)), + timeout=timeout, + ) + + +def _screenshot_prepare_command(device: DeviceConfig, remote_path: str) -> str: + remote_dir = str(PurePosixPath(remote_path).parent) + home_path = _device_home_path(device) + script = f""" +set -e +home={shlex.quote(home_path)} +remote_dir={shlex.quote(remote_dir)} +owner=$(stat -Lc %U "$home") +group=$(stat -Lc %G "$home") +if [ "$remote_dir" = "$home/Pictures/Screenshots" ]; then + if [ ! -d "$home/Pictures" ]; then + install -d -m 775 -o "$owner" -g "$group" "$home/Pictures" + fi + if getent group privileged >/dev/null 2>&1; then + screenshot_group=privileged + else + screenshot_group=$group + fi + if [ ! -d "$remote_dir" ]; then + install -d -m 755 -o "$owner" -g "$screenshot_group" "$remote_dir" + fi +else + if [ ! -d "$remote_dir" ]; then + install -d -m 755 -o "$owner" -g "$group" "$remote_dir" + fi +fi +""".strip() + return remote_command(["sh", "-lc", script]) + + +def _touch_discover_command(include_evdev_trace: bool) -> str: + evdev_trace = """ +if command -v evdev_trace >/dev/null 2>&1; then + echo "### evdev_trace -i" + if command -v timeout >/dev/null 2>&1; then + timeout 5 evdev_trace -i || true + else + evdev_trace -i & + pid=$! + sleep 5 + kill "$pid" >/dev/null 2>&1 || true + wait "$pid" >/dev/null 2>&1 || true + fi + echo +fi +""".strip() + script = f""" +set -e +{evdev_trace if include_evdev_trace else ""} +echo "### /proc/bus/input/devices" +cat /proc/bus/input/devices +""".strip() + return remote_command(["sh", "-lc", script]) + + +def _touch_inject_command(values: dict[str, str | int]) -> str: + env = " ".join( + f"{key}={shlex.quote(str(value))}" + for key, value in sorted(values.items()) + ) + script = f""" +set -e +{env} python3 - <<'PY' +{_touch_inject_python()} +PY +""".strip() + return remote_command(["sh", "-lc", script]) + + +def _touch_inject_python() -> str: + return r''' +import os +import re +import struct +import sys +import time + +EV_SYN = 0 +EV_KEY = 1 +EV_ABS = 3 +SYN_REPORT = 0 +BTN_TOUCH = 0x14a +ABS_X = 0x00 +ABS_Y = 0x01 +ABS_MT_SLOT = 0x2f +ABS_MT_POSITION_X = 0x35 +ABS_MT_POSITION_Y = 0x36 +ABS_MT_TRACKING_ID = 0x39 + +TOUCH_KEYWORDS = ( + "touch", + "touchscreen", + "digitizer", + "fts", + "ft5x", + "goodix", + "synaptics", + "cyttsp", + "atmel", + "elan", + "himax", + "novatek", + "nvt", + "silead", + "gt9", + "spi3", +) + + +def die(message): + print(message, file=sys.stderr) + raise SystemExit(2) + + +def input_blocks(): + try: + text = open("/proc/bus/input/devices", encoding="utf-8").read() + except OSError as exc: + die(f"failed to read /proc/bus/input/devices: {exc}") + return [block for block in text.split("\n\n") if block.strip()] + + +def field(block, prefix): + for line in block.splitlines(): + if line.startswith(prefix): + return line[len(prefix):].strip() + return "" + + +def event_handler(block): + match = re.search(r"\bevent\d+\b", field(block, "H: Handlers=")) + return match.group(0) if match else "" + + +def name(block): + value = field(block, "N: Name=") + return value.strip('"') + + +def score(block): + lower = block.lower() + value = 0 + if any(keyword in lower for keyword in TOUCH_KEYWORDS): + value += 100 + if "b: abs=" in lower: + value += 20 + if "b: prop=" in lower: + value += 5 + if "mouse" in lower or "keyboard" in lower or "keypad" in lower: + value -= 100 + return value + + +def discover(): + devices = [] + for block in input_blocks(): + event = event_handler(block) + if not event: + continue + devices.append( + { + "path": f"/dev/input/{event}", + "name": name(block), + "score": score(block), + } + ) + devices.sort(key=lambda item: item["score"], reverse=True) + return devices + + +def choose_device(): + explicit = os.environ.get("INPUT_DEVICE", "") + if explicit: + return explicit + devices = discover() + if not devices or devices[0]["score"] <= 0: + summary = ", ".join(f"{item['path']}:{item['name']}" for item in devices) + die(f"could not identify touchscreen input device; candidates: {summary}") + return devices[0]["path"] + + +def event(fileobj, event_type, code, value): + fileobj.write(struct.pack("@llHHi", 0, 0, event_type, code, value)) + + +def sync(fileobj): + event(fileobj, EV_SYN, SYN_REPORT, 0) + + +def move(fileobj, x, y): + event(fileobj, EV_ABS, ABS_MT_POSITION_X, x) + event(fileobj, EV_ABS, ABS_MT_POSITION_Y, y) + event(fileobj, EV_ABS, ABS_X, x) + event(fileobj, EV_ABS, ABS_Y, y) + + +def down(fileobj, x, y): + event(fileobj, EV_ABS, ABS_MT_SLOT, 0) + event(fileobj, EV_ABS, ABS_MT_TRACKING_ID, int(time.time() * 1000) & 0x7fffffff) + move(fileobj, x, y) + event(fileobj, EV_KEY, BTN_TOUCH, 1) + sync(fileobj) + + +def up(fileobj): + event(fileobj, EV_ABS, ABS_MT_SLOT, 0) + event(fileobj, EV_ABS, ABS_MT_TRACKING_ID, -1) + event(fileobj, EV_KEY, BTN_TOUCH, 0) + sync(fileobj) + + +def integer(name): + try: + return int(os.environ[name]) + except KeyError: + die(f"missing {name}") + except ValueError: + die(f"{name} must be an integer") + + +action = os.environ.get("ACTION", "") +device = choose_device() +print(f"input_device={device}") +with open(device, "wb", buffering=0) as fileobj: + if action == "tap": + down(fileobj, integer("X"), integer("Y")) + time.sleep(integer("HOLD_MS") / 1000) + up(fileobj) + elif action == "swipe": + start_x = integer("START_X") + start_y = integer("START_Y") + end_x = integer("END_X") + end_y = integer("END_Y") + steps = integer("STEPS") + duration = integer("DURATION_MS") / 1000 + down(fileobj, start_x, start_y) + for step in range(1, steps + 1): + fraction = step / steps + x = round(start_x + ((end_x - start_x) * fraction)) + y = round(start_y + ((end_y - start_y) * fraction)) + move(fileobj, x, y) + sync(fileobj) + time.sleep(duration / steps) + up(fileobj) + else: + die(f"unsupported ACTION {action!r}") +print(f"{action}=ok") +'''.strip() + + +def _device_home_path(device: DeviceConfig) -> str: + return f"/home/{device.username}" + + +def command_result( + title: str, + result: CommandResult, + structured: dict[str, Any] | None = None, +) -> dict[str, Any]: + data = result.public_dict() + if structured: + data.update(structured) + return { + "content": [{"type": "text", "text": _command_text(title, result)}], + "structuredContent": data, + "isError": not result.ok, + } + + +def ok_text(text: str, structured: dict[str, Any] | None = None) -> dict[str, Any]: + return { + "content": [{"type": "text", "text": text}], + "structuredContent": structured or {}, + "isError": False, + } + + +def tool_error(message: str, structured: dict[str, Any] | None = None) -> dict[str, Any]: + return { + "content": [{"type": "text", "text": message}], + "structuredContent": structured or {"error": message}, + "isError": True, + } + + +def _command_text(title: str, result: CommandResult) -> str: + stdout, _ = truncate(result.stdout, 12000) + stderr, _ = truncate(result.stderr, 8000) + parts = [f"{title}: exit {result.returncode}"] + if stdout: + parts += ["", stdout.rstrip()] + if stderr: + parts += ["", "stderr:", stderr.rstrip()] + return "\n".join(parts) + + +def _device(config: Config, args: dict[str, Any]) -> DeviceConfig: + return config.device(_optional_str(args, "device")) + + +def _str_arg(args: dict[str, Any], name: str) -> str: + value = args.get(name) + if not isinstance(value, str) or not value: + raise ValueError(f"{name} must be a non-empty string") + return value + + +def _optional_str(args: dict[str, Any], name: str) -> str | None: + value = args.get(name) + if value is None or value == "": + return None + if not isinstance(value, str): + raise ValueError(f"{name} must be a string") + return value + + +def _int_arg( + args: dict[str, Any], + name: str, + default: int | None = None, + minimum: int | None = None, + maximum: int | None = None, +) -> int: + value = args.get(name, default) + if not isinstance(value, int): + raise ValueError(f"{name} must be an integer") + if minimum is not None and value < minimum: + raise ValueError(f"{name} must be >= {minimum}") + if maximum is not None and value > maximum: + raise ValueError(f"{name} must be <= {maximum}") + return value + + +def _bool_arg(args: dict[str, Any], name: str, default: bool = False) -> bool: + value = args.get(name, default) + if not isinstance(value, bool): + raise ValueError(f"{name} must be a boolean") + return value + + +def _enum_arg( + args: dict[str, Any], + name: str, + values: list[str], + default: str | None = None, +) -> str: + value = args.get(name, default) + if not isinstance(value, str) or value not in values: + raise ValueError(f"{name} must be one of: {', '.join(values)}") + return value + + +def _string_list_arg(args: dict[str, Any], name: str) -> list[str]: + value = args.get(name) + if value is None: + return [] + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + raise ValueError(f"{name} must be a list of strings") + return value + + +def _service_unit_arg(args: dict[str, Any], name: str) -> str: + unit = _str_arg(args, name) + if not re.fullmatch(r"[A-Za-z0-9@_.:\-]+", unit): + raise ValueError(f"{name} contains unsupported characters") + return unit + + +def _safe_input_path(config: Config, value: str, *, allow_tmp: bool) -> Path: + path = Path(value).expanduser() + if not path.is_absolute(): + path = config.paths.git_root / path + path = path.resolve(strict=False) + roots = [config.paths.git_root.resolve(strict=False)] + if allow_tmp: + roots.append(Path("/tmp").resolve(strict=False)) + if not _is_relative_to_any(path, roots): + raise ValueError(f"path is outside allowed roots: {path}") + return path + + +def _safe_output_path(config: Config, value: str) -> Path: + path = Path(value).expanduser() + if not path.is_absolute(): + path = config.paths.git_root / path + path = path.resolve(strict=False) + roots = [config.paths.git_root.resolve(strict=False), Path("/tmp").resolve(strict=False)] + if not _is_relative_to_any(path, roots): + raise ValueError(f"output path is outside allowed roots: {path}") + return path + + +def _is_relative_to_any(path: Path, roots: list[Path]) -> bool: + for root in roots: + try: + path.relative_to(root) + return True + except ValueError: + pass + return False + + +def _parse_dbus_integer(text: str) -> int | None: + match = re.search(r"\b(?:int32|uint32|int64|uint64)\s+(-?\d+)", text) + return int(match.group(1)) if match else None + + +def _parse_spec(path: Path) -> dict[str, Any]: + fields: dict[str, Any] = { + "Name": "", + "Version": "", + "Release": "", + "Summary": "", + "License": "", + "URL": "", + "BuildRequires": [], + "Requires": [], + } + for raw_line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + for field in ["Name", "Version", "Release", "Summary", "License", "URL"]: + prefix = field + ":" + if line.startswith(prefix) and not fields[field]: + fields[field] = line[len(prefix) :].strip() + if line.startswith("BuildRequires:"): + fields["BuildRequires"].append(line.split(":", 1)[1].strip()) + if line.startswith("Requires:"): + fields["Requires"].append(line.split(":", 1)[1].strip()) + return fields + + +def _read_only_annotations(title: str) -> dict[str, Any]: + return { + "title": title, + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True, + } + + +def _mutating_annotations(title: str, *, destructive: bool = False) -> dict[str, Any]: + return { + "title": title, + "readOnlyHint": False, + "destructiveHint": destructive, + "idempotentHint": False, + "openWorldHint": True, + } + + +def _object_schema(properties: dict[str, Any], required: list[str] | None = None) -> dict[str, Any]: + return { + "type": "object", + "properties": properties, + "required": required or [], + "additionalProperties": False, + } + + +def _device_prop() -> dict[str, Any]: + return {"type": "string", "description": "Configured device alias or ssh target."} + + +def _timeout_prop(default: int) -> dict[str, Any]: + return { + "type": "integer", + "minimum": 1, + "default": default, + "description": "Command timeout in seconds.", + } + + +def _spec_devices() -> dict[str, Any]: + return { + "name": "sailfish_devices", + "title": "List Sailfish Devices", + "description": "List configured Sailfish OS device aliases and SSH targets.", + "inputSchema": _object_schema({}), + "annotations": _read_only_annotations("List Sailfish Devices"), + } + + +def _spec_device_journal() -> dict[str, Any]: + return { + "name": "sailfish_device_journal", + "title": "Read Device Journal", + "description": "Read recent journalctl output from a Sailfish OS device.", + "inputSchema": _object_schema( + { + "device": _device_prop(), + "unit": {"type": "string"}, + "since": {"type": "string"}, + "grep": {"type": "string"}, + "lines": {"type": "integer", "minimum": 1, "maximum": 5000, "default": 200}, + "timeout": _timeout_prop(30), + } + ), + "annotations": _read_only_annotations("Read Device Journal"), + } + + +def _spec_device_topmost_pid() -> dict[str, Any]: + return { + "name": "sailfish_device_topmost_pid", + "title": "Topmost Window PID", + "description": "Query Lipstick for the current topmost window process id.", + "inputSchema": _object_schema({"device": _device_prop(), "timeout": _timeout_prop(20)}), + "annotations": _read_only_annotations("Topmost Window PID"), + } + + +def _spec_device_proc_maps() -> dict[str, Any]: + return { + "name": "sailfish_device_proc_maps", + "title": "Read Process Maps", + "description": "Read or filter /proc//maps on a Sailfish OS device.", + "inputSchema": _object_schema( + { + "device": _device_prop(), + "pid": {"type": "integer", "minimum": 1}, + "contains": {"type": "string"}, + "max_lines": {"type": "integer", "minimum": 1, "maximum": 5000, "default": 200}, + "timeout": _timeout_prop(20), + }, + ["pid"], + ), + "annotations": _read_only_annotations("Read Process Maps"), + } + + +def _spec_device_lipstick_screenshot() -> dict[str, Any]: + return { + "name": "sailfish_device_lipstick_screenshot", + "title": "Lipstick Screenshot", + "description": "Ask Lipstick to save a screenshot on the device, optionally pulling it locally.", + "inputSchema": _object_schema( + { + "device": _device_prop(), + "remote_path": { + "type": "string", + "default": "/home/defaultuser/Pictures/Screenshots/lipstick-.png", + "description": "Lipstick accepts screenshot paths under the user home directory.", + }, + "local_path": {"type": "string"}, + "privileged": {"type": "boolean", "default": True}, + "timeout": _timeout_prop(30), + } + ), + "annotations": _mutating_annotations("Lipstick Screenshot"), + } + + +def _spec_device_touch() -> dict[str, Any]: + return { + "name": "sailfish_device_touch", + "title": "Device Touch Input", + "description": "Discover the touchscreen input device or inject tap/swipe events over SSH.", + "inputSchema": _object_schema( + { + "device": _device_prop(), + "action": { + "type": "string", + "enum": ["discover", "tap", "swipe"], + "description": "discover lists input devices; tap and swipe inject Linux input events.", + }, + "include_evdev_trace": { + "type": "boolean", + "default": False, + "description": "Also run evdev_trace -i during discovery. Disabled by default because it may block on some devices.", + }, + "input_device": { + "type": "string", + "description": "Optional explicit device path, for example /dev/input/event5.", + }, + "x": {"type": "integer", "minimum": 0, "maximum": 10000}, + "y": {"type": "integer", "minimum": 0, "maximum": 10000}, + "hold_ms": {"type": "integer", "minimum": 1, "maximum": 5000, "default": 80}, + "start_x": {"type": "integer", "minimum": 0, "maximum": 10000}, + "start_y": {"type": "integer", "minimum": 0, "maximum": 10000}, + "end_x": {"type": "integer", "minimum": 0, "maximum": 10000}, + "end_y": {"type": "integer", "minimum": 0, "maximum": 10000}, + "duration_ms": {"type": "integer", "minimum": 1, "maximum": 10000, "default": 300}, + "steps": {"type": "integer", "minimum": 1, "maximum": 200, "default": 12}, + "timeout": _timeout_prop(10), + }, + ["action"], + ), + "annotations": _mutating_annotations("Device Touch Input"), + } + + +def _spec_device_user_bus_call() -> dict[str, Any]: + return { + "name": "sailfish_device_user_bus_call", + "title": "User Bus Call", + "description": "Run a typed dbus-send method call on the defaultuser session bus.", + "inputSchema": _object_schema( + { + "device": _device_prop(), + "destination": {"type": "string"}, + "path": {"type": "string"}, + "interface": {"type": "string"}, + "member": {"type": "string"}, + "arguments": { + "type": "array", + "items": {"type": "string"}, + "description": "Raw dbus-send argument strings, for example string:/tmp/file.", + }, + "timeout": _timeout_prop(30), + }, + ["destination", "path", "interface", "member"], + ), + "annotations": _mutating_annotations("User Bus Call"), + } + + +def _spec_device_install_rpm() -> dict[str, Any]: + return { + "name": "sailfish_device_install_rpm", + "title": "Install Device RPM", + "description": "Copy a local RPM to a Sailfish OS device and install it.", + "inputSchema": _object_schema( + { + "device": _device_prop(), + "rpm_path": {"type": "string"}, + "remote_path": {"type": "string"}, + "installer": {"type": "string", "enum": ["pkcon", "rpm"], "default": "pkcon"}, + "timeout": _timeout_prop(180), + }, + ["rpm_path"], + ), + "annotations": _mutating_annotations("Install Device RPM"), + } + + +def _spec_device_restart_service() -> dict[str, Any]: + return { + "name": "sailfish_device_restart_service", + "title": "Manage Device Service", + "description": "Run systemctl start/stop/restart/status for a system or user service.", + "inputSchema": _object_schema( + { + "device": _device_prop(), + "unit": {"type": "string"}, + "action": { + "type": "string", + "enum": ["restart", "start", "stop", "status"], + "default": "restart", + }, + "mode": {"type": "string", "enum": ["system", "user"], "default": "system"}, + "timeout": _timeout_prop(60), + }, + ["unit"], + ), + "annotations": _mutating_annotations("Manage Device Service"), + } + + +def _spec_build_rpm() -> dict[str, Any]: + return { + "name": "sailfish_build_rpm", + "title": "Build Sailfish RPM", + "description": "Run the local build-sailfishos helper; paths.local_sdk is used only when it has a matching target.", + "inputSchema": _object_schema( + { + "project_path": {"type": "string"}, + "device": { + "type": "string", + "description": "Optional configured device to supply default release and architecture.", + }, + "release": {"type": "string"}, + "arch": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}}, + ] + }, + "artifacts_dir": {"type": "string"}, + "all_arches": {"type": "boolean", "default": False}, + "clean": {"type": "boolean", "default": False}, + "debug": {"type": "boolean", "default": False}, + "no_pull": {"type": "boolean", "default": False}, + "local_rpms_dir": {"type": "array", "items": {"type": "string"}}, + "timeout": _timeout_prop(3600), + }, + ["project_path"], + ), + "annotations": _mutating_annotations("Build Sailfish RPM"), + } + + +def _spec_obs_results() -> dict[str, Any]: + return { + "name": "sailfish_obs_results", + "title": "OBS Results", + "description": "Run osc results using the configured OBS API alias when set.", + "inputSchema": _object_schema( + { + "project": {"type": "string"}, + "package": {"type": "string"}, + "api_alias": {"type": "string"}, + "timeout": _timeout_prop(60), + }, + ["project"], + ), + "annotations": _read_only_annotations("OBS Results"), + } + + +def _spec_obs_buildlog() -> dict[str, Any]: + return { + "name": "sailfish_obs_buildlog", + "title": "OBS Build Log", + "description": "Fetch an OBS remote build log with osc.", + "inputSchema": _object_schema( + { + "project": {"type": "string"}, + "package": {"type": "string"}, + "repository": {"type": "string"}, + "arch": {"type": "string"}, + "api_alias": {"type": "string"}, + "timeout": _timeout_prop(90), + }, + ["project", "package", "repository", "arch"], + ), + "annotations": _read_only_annotations("OBS Build Log"), + } + + +def _spec_repo_status() -> dict[str, Any]: + return { + "name": "sailfish_repo_status", + "title": "Repo Status", + "description": "Run git status --short --branch under the configured git root.", + "inputSchema": _object_schema({"path": {"type": "string"}, "timeout": _timeout_prop(30)}), + "annotations": _read_only_annotations("Repo Status"), + } + + +def _spec_repo_find() -> dict[str, Any]: + return { + "name": "sailfish_repo_find", + "title": "Repo Find", + "description": "Search a repo or subtree under the configured git root.", + "inputSchema": _object_schema( + { + "path": {"type": "string"}, + "query": {"type": "string"}, + "fixed_strings": {"type": "boolean", "default": True}, + "max_count": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 100}, + "timeout": _timeout_prop(30), + }, + ["query"], + ), + "annotations": _read_only_annotations("Repo Find"), + } + + +def _spec_spec_summary() -> dict[str, Any]: + return { + "name": "sailfish_spec_summary", + "title": "RPM Spec Summary", + "description": "Parse high-level metadata from a Sailfish RPM spec file.", + "inputSchema": _object_schema( + {"repo_path": {"type": "string"}, "spec_path": {"type": "string"}} + ), + "annotations": _read_only_annotations("RPM Spec Summary"), + } + + +def _spec_qml_find_translations() -> dict[str, Any]: + return { + "name": "sailfish_qml_find_translations", + "title": "Find QML Translations", + "description": "Find qsTrId and translator comments in QML files.", + "inputSchema": _object_schema({"path": {"type": "string"}, "timeout": _timeout_prop(30)}), + "annotations": _read_only_annotations("Find QML Translations"), + } + + +def _spec_qml_check_translator_ternaries() -> dict[str, Any]: + return { + "name": "sailfish_qml_check_translator_ternaries", + "title": "Check QML Ternary Translations", + "description": "Flag QML ternary qsTrId expressions that need branch-local comments.", + "inputSchema": _object_schema({"path": {"type": "string"}}), + "annotations": _read_only_annotations("Check QML Ternary Translations"), + } -- cgit v1.2.3