feat(automation): input.click / input.type / input.key handlers

This commit is contained in:
SoftFever
2026-06-03 02:01:40 +08:00
parent a8ed2b8dd5
commit 5a2f03adee
3 changed files with 176 additions and 3 deletions

View File

@@ -54,6 +54,80 @@ DumpOptions parse_dump_options(const nlohmann::json& p) {
}
} // namespace
namespace {
MouseButton parse_button(const nlohmann::json& p) {
auto b = opt_str(p, "button");
if (b && *b == "right") return MouseButton::Right;
if (b && *b == "middle") return MouseButton::Middle;
return MouseButton::Left;
}
std::vector<KeyModifier> parse_modifiers(const nlohmann::json& p) {
std::vector<KeyModifier> mods;
if (p.is_object() && p.contains("modifiers") && p.at("modifiers").is_array()) {
for (const auto& m : p.at("modifiers")) {
if (!m.is_string()) continue;
const std::string s = m.get<std::string>();
if (s == "ctrl") mods.push_back(KeyModifier::Ctrl);
else if (s == "shift") mods.push_back(KeyModifier::Shift);
else if (s == "alt") mods.push_back(KeyModifier::Alt);
else if (s == "cmd" || s == "meta") mods.push_back(KeyModifier::Cmd);
}
}
return mods;
}
// Parse one chord token list (already split): the last token is the key, the
// earlier ones are modifiers.
KeyChord chord_from_tokens(const std::vector<std::string>& tokens) {
KeyChord c;
for (size_t i = 0; i < tokens.size(); ++i) {
const std::string& t = tokens[i];
const bool is_mod = (t == "ctrl" || t == "shift" || t == "alt" ||
t == "cmd" || t == "meta");
if (is_mod && i + 1 < tokens.size()) {
if (t == "ctrl") c.modifiers.push_back(KeyModifier::Ctrl);
else if (t == "shift") c.modifiers.push_back(KeyModifier::Shift);
else if (t == "alt") c.modifiers.push_back(KeyModifier::Alt);
else c.modifiers.push_back(KeyModifier::Cmd);
} else {
c.key = t; // last token (or a lone token) is the key
}
}
return c;
}
std::vector<std::string> split(const std::string& s, char delim) {
std::vector<std::string> out;
std::string cur;
for (char ch : s) {
if (ch == delim) { if (!cur.empty()) out.push_back(cur); cur.clear(); }
else cur.push_back(ch);
}
if (!cur.empty()) out.push_back(cur);
return out;
}
// "keys" may be a string ("ctrl+s") or an array (["ctrl","s"]). Returns one chord.
std::vector<KeyChord> parse_keys(const nlohmann::json& params) {
if (!params.is_object() || !params.contains("keys"))
throw AutomationError(kInvalidParams, "input.key requires 'keys'");
const auto& k = params.at("keys");
std::vector<std::string> tokens;
if (k.is_string()) {
tokens = split(k.get<std::string>(), '+');
} else if (k.is_array()) {
for (const auto& e : k)
if (e.is_string()) tokens.push_back(e.get<std::string>());
} else {
throw AutomationError(kInvalidParams, "'keys' must be string or array");
}
if (tokens.empty())
throw AutomationError(kInvalidParams, "'keys' is empty");
return { chord_from_tokens(tokens) };
}
} // namespace
nlohmann::json JsonRpcDispatcher::m_version(const nlohmann::json&) {
return { {"version", kAutomationVersion},
{"protocol", "2.0"},
@@ -134,9 +208,49 @@ nlohmann::json JsonRpcDispatcher::m_widget_get(const nlohmann::json& params) {
return node_to_json(*node, /*include_children*/ true);
}
nlohmann::json JsonRpcDispatcher::m_input_click(const nlohmann::json&) { throw AutomationError(kMethodNotFound, "not implemented"); }
nlohmann::json JsonRpcDispatcher::m_input_type(const nlohmann::json&) { throw AutomationError(kMethodNotFound, "not implemented"); }
nlohmann::json JsonRpcDispatcher::m_input_key(const nlohmann::json&) { throw AutomationError(kMethodNotFound, "not implemented"); }
const UiNode JsonRpcDispatcher::resolve_actionable(const nlohmann::json& params,
UiNode& tree_out) {
if (!params.is_object() || !params.contains("target"))
throw AutomationError(kInvalidParams, "missing 'target'");
m_backend.refresh_ui();
tree_out = m_backend.dump_tree(DumpOptions{});
int count = 0;
const UiNode* node = resolve_unique(tree_out, parse_target(params.at("target")), count);
if (count == 0) throw AutomationError(kErrNotFound, "target not found");
if (count > 1) throw AutomationError(kErrNotFound, "target is ambiguous");
if (!node->enabled || !node->visible)
throw AutomationError(kErrNotActionable, "target is disabled or hidden");
return *node; // copy: stable even though tree_out outlives this call
}
nlohmann::json JsonRpcDispatcher::m_input_click(const nlohmann::json& params) {
UiNode tree;
const UiNode node = resolve_actionable(params, tree);
const bool dbl = params.contains("double") && params.at("double").is_boolean()
&& params.at("double").get<bool>();
const bool ok = m_backend.click(node, parse_button(params), dbl, parse_modifiers(params));
return { {"ok", ok} };
}
nlohmann::json JsonRpcDispatcher::m_input_type(const nlohmann::json& params) {
if (!params.is_object() || !params.contains("text") || !params.at("text").is_string())
throw AutomationError(kInvalidParams, "input.type requires string 'text'");
const std::string text = params.at("text").get<std::string>();
// Optional target: click to focus first.
if (params.contains("target")) {
UiNode tree;
const UiNode node = resolve_actionable(params, tree);
m_backend.click(node, MouseButton::Left, false, {});
}
const bool ok = m_backend.type_text(text);
return { {"ok", ok} };
}
nlohmann::json JsonRpcDispatcher::m_input_key(const nlohmann::json& params) {
const bool ok = m_backend.send_keys(parse_keys(params));
return { {"ok", ok} };
}
nlohmann::json JsonRpcDispatcher::m_sync_wait_for(const nlohmann::json&) { throw AutomationError(kMethodNotFound, "not implemented"); }
nlohmann::json JsonRpcDispatcher::m_app_state(const nlohmann::json&) { throw AutomationError(kMethodNotFound, "not implemented"); }
nlohmann::json JsonRpcDispatcher::m_screenshot_window(const nlohmann::json&) { throw AutomationError(kMethodNotFound, "not implemented"); }

View File

@@ -49,6 +49,11 @@ private:
nlohmann::json m_screenshot_window(const nlohmann::json& params);
nlohmann::json m_screenshot_viewport3d(const nlohmann::json& params);
// Resolve a unique, actionable (enabled+visible) node from params["target"].
// Throws kErrNotFound (missing/ambiguous) or kErrNotActionable (disabled/hidden).
// `tree_out` keeps the snapshot alive; the returned node is a stable copy.
const UiNode resolve_actionable(const nlohmann::json& params, UiNode& tree_out);
IUiBackend& m_backend;
};

View File

@@ -88,3 +88,57 @@ TEST_CASE("widget.get not found -> 1001", "[automation][rpc]") {
{"params",{{"target",{{"id","nope"}}}}}});
CHECK(resp.at("error").at("code") == kErrNotFound);
}
TEST_CASE("input.click resolves target and clicks it", "[automation][rpc]") {
MockUiBackend mock; mock.tree = dispatcher_tree();
JsonRpcDispatcher d(mock);
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",1},{"method","input.click"},
{"params",{{"target",{{"id","btn_slice"}}}}}});
CHECK(resp.at("result").at("ok") == true);
REQUIRE(mock.clicked_ids.size() == 1);
CHECK(mock.clicked_ids[0] == "btn_slice");
CHECK(mock.click_buttons[0] == MouseButton::Left);
}
TEST_CASE("input.click on disabled widget -> 1002", "[automation][rpc]") {
MockUiBackend mock; mock.tree = dispatcher_tree();
JsonRpcDispatcher d(mock);
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",2},{"method","input.click"},
{"params",{{"target",{{"id","btn_export"}}}}}});
CHECK(resp.at("error").at("code") == kErrNotActionable);
CHECK(mock.clicked_ids.empty());
}
TEST_CASE("input.type with target clicks to focus then types", "[automation][rpc]") {
MockUiBackend mock; mock.tree = dispatcher_tree();
JsonRpcDispatcher d(mock);
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",3},{"method","input.type"},
{"params",{{"target",{{"id","btn_slice"}}},{"text","hello"}}}});
CHECK(resp.at("result").at("ok") == true);
CHECK(mock.clicked_ids.size() == 1); // focused first
REQUIRE(mock.typed_text.size() == 1);
CHECK(mock.typed_text[0] == "hello");
}
TEST_CASE("input.key parses 'ctrl+s' string form", "[automation][rpc]") {
MockUiBackend mock;
JsonRpcDispatcher d(mock);
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",4},{"method","input.key"},
{"params",{{"keys","ctrl+s"}}}});
CHECK(resp.at("result").at("ok") == true);
REQUIRE(mock.sent_keys.size() == 1);
REQUIRE(mock.sent_keys[0].size() == 1);
CHECK(mock.sent_keys[0][0].key == "s");
REQUIRE(mock.sent_keys[0][0].modifiers.size() == 1);
CHECK(mock.sent_keys[0][0].modifiers[0] == KeyModifier::Ctrl);
}
TEST_CASE("input.key parses array form [\"ctrl\",\"s\"]", "[automation][rpc]") {
MockUiBackend mock;
JsonRpcDispatcher d(mock);
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",5},{"method","input.key"},
{"params",{{"keys", json::array({"ctrl","s"})}}}});
CHECK(resp.at("result").at("ok") == true);
REQUIRE(mock.sent_keys[0][0].modifiers.size() == 1);
CHECK(mock.sent_keys[0][0].key == "s");
}