1use crate::exposure::{self, GENERIC_READ_METHODS};
8use crate::spec::*;
9use rightkit_daemon::{Client, ServiceError, Suite};
10use serde_json::{json, Value};
11use std::sync::Arc;
12use std::time::Duration;
13
14pub const CONTROL_METHODS: &[&str] = &[
16 "health",
17 "click",
18 "move",
19 "drag",
20 "wheel",
21 "key",
22 "type",
23 "eval",
24 "dom",
25 "ax",
26 "screenshot",
27 "command",
28];
29
30pub const CLIENT_APP: &str = "right-ctl";
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum CommandPolicy {
35 Any,
37 Only(Vec<String>),
39}
40
41#[derive(Debug, Clone)]
45pub struct AppProfile {
46 pub app: String,
47 pub service: String,
49 pub methods: Vec<String>,
50 pub commands: CommandPolicy,
51 pub sensitive: bool,
53}
54
55impl AppProfile {
56 pub fn standard(app: &str) -> Self {
58 Self {
59 app: app.into(),
60 service: format!("control-{app}"),
61 methods: CONTROL_METHODS.iter().map(|s| s.to_string()).collect(),
62 commands: CommandPolicy::Any,
63 sensitive: false,
64 }
65 }
66
67 pub fn sensitive(app: &str, commands: &[&str]) -> Self {
71 let commands = commands
72 .iter()
73 .filter(|c| exposure::forbidden(c).is_none())
74 .map(|c| c.to_string())
75 .collect();
76 Self {
77 app: app.into(),
78 service: format!("control-{app}"),
79 methods: vec!["health".into(), "command".into()],
80 commands: CommandPolicy::Only(commands),
81 sensitive: true,
82 }
83 }
84
85 pub fn for_app(app: &str) -> Self {
87 match app {
88 "heardright" => Self::sensitive(
89 "heardright",
90 &["health", "version", "list_models", "model_status"],
91 ),
92 _ => Self::standard(app),
93 }
94 }
95
96 pub fn with_methods(mut self, methods: &[&str]) -> Self {
97 self.methods = methods.iter().map(|s| s.to_string()).collect();
98 self
99 }
100
101 pub fn with_commands(mut self, policy: CommandPolicy) -> Self {
102 self.commands = policy;
103 self
104 }
105
106 pub fn allow_patterns(&self) -> Vec<String> {
108 let mut out = vec![];
109 for m in &self.methods {
110 if m == "command" {
111 match &self.commands {
112 CommandPolicy::Any => out.push(format!("{}.command:*", self.app)),
113 CommandPolicy::Only(names) => {
114 out.extend(names.iter().map(|n| format!("{}.command:{n}", self.app)))
115 }
116 }
117 } else {
118 out.push(format!("{}.{m}", self.app));
119 }
120 }
121 out
122 }
123
124 fn method_enabled(&self, m: &str) -> bool {
125 self.methods.iter().any(|x| x == m)
126 && !(self.sensitive && GENERIC_READ_METHODS.contains(&m))
127 }
128}
129
130fn num(desc: &str) -> Value {
131 json!({"type": "number", "description": desc})
132}
133
134fn method_schema(method: &str) -> (&'static str, Effect, Value) {
136 match method {
137 "health" => ("Is the app's control server up? Returns its status.", Effect::Observe, json!({"type": "object", "properties": {}})),
138 "click" => (
139 "Real click in the webview (viewport CSS pixels, origin top-left).",
140 Effect::Act,
141 json!({"type": "object", "properties": {"x": num("x"), "y": num("y"), "button": {"type": "string", "enum": ["left", "right"]}, "count": {"type": "integer"}}, "required": ["x", "y"]}),
142 ),
143 "move" => ("Move the pointer to x,y (hover).", Effect::Act, json!({"type": "object", "properties": {"x": num("x"), "y": num("y")}, "required": ["x", "y"]})),
144 "drag" => (
145 "Drag from (x1,y1) to (x2,y2).",
146 Effect::Act,
147 json!({"type": "object", "properties": {"x1": num("x1"), "y1": num("y1"), "x2": num("x2"), "y2": num("y2"), "steps": {"type": "integer"}}, "required": ["x1", "y1", "x2", "y2"]}),
148 ),
149 "wheel" => ("Scroll wheel at x,y by dx,dy.", Effect::Act, json!({"type": "object", "properties": {"x": num("x"), "y": num("y"), "dx": num("dx"), "dy": num("dy")}, "required": ["x", "y", "dx", "dy"]})),
150 "key" => ("Press a key chord such as `cmd+k` or `enter`.", Effect::Act, json!({"type": "object", "properties": {"key": {"type": "string"}}, "required": ["key"]})),
151 "type" => ("Type text into the focused element with real key events.", Effect::Act, json!({"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]})),
152 "eval" => ("Evaluate JavaScript in the webview and return the result.", Effect::Act, json!({"type": "object", "properties": {"js": {"type": "string"}}, "required": ["js"]})),
153 "dom" => ("Query DOM nodes by CSS selector.", Effect::Observe, json!({"type": "object", "properties": {"selector": {"type": "string"}}, "required": ["selector"]})),
154 "ax" => ("Accessibility tree of the app window.", Effect::Observe, json!({"type": "object", "properties": {}})),
155 "screenshot" => (
156 "Screenshot of the webview (password and secret elements are masked). Returns {path,bytes,masked}.",
157 Effect::Capture,
158 json!({"type": "object", "properties": {"mask": {"type": "array"}}}),
159 ),
160 "command" => (
161 "Run one of the app's own typed commands by name; `args` is the command's argument string.",
162 Effect::Act,
163 json!({"type": "object", "properties": {"name": {"type": "string"}, "args": {"type": "string"}}, "required": ["name"]}),
164 ),
165 _ => ("", Effect::Act, json!({"type": "object"})),
166 }
167}
168
169struct ControlTool {
170 spec: ToolSpec,
171 suite: Suite,
172 profile: AppProfile,
173 method: String,
174}
175
176fn map_err(e: ServiceError) -> ToolError {
177 ToolError {
178 code: e.code,
179 message: e.message,
180 retryable: e.retryable,
181 }
182}
183
184impl Tool for ControlTool {
185 fn spec(&self) -> &ToolSpec {
186 &self.spec
187 }
188
189 fn subject(&self, args: &Value) -> Option<String> {
190 (self.method == "command").then(|| args["name"].as_str().unwrap_or("").to_string())
191 }
192
193 fn call(&self, _ctx: &CallCtx, args: Value) -> Result<Value, ToolError> {
194 let params = if self.method == "command" {
195 let name = args["name"].as_str().unwrap_or_default();
196 let allowed = match &self.profile.commands {
197 CommandPolicy::Any => true,
198 CommandPolicy::Only(l) => l.iter().any(|c| c == name),
199 };
200 if !allowed || exposure::forbidden(name).is_some() {
201 return Err(ToolError::new(
202 ERR_NOT_ALLOWED,
203 format!("command `{name}` is not exposed for {}", self.profile.app),
204 ));
205 }
206 let a = match &args["args"] {
207 Value::Null => String::new(),
208 Value::String(s) => s.clone(),
209 other => other.to_string(),
210 };
211 json!({"name": name, "args": a})
212 } else {
213 args
214 };
215 let client = Client::connect_with(
216 &self.suite,
217 &self.profile.service,
218 CLIENT_APP,
219 Some(Duration::from_secs(30)),
220 )
221 .map_err(map_err)?;
222 client.call(&self.method, params).map_err(map_err)
223 }
224}
225
226pub fn control_tools(suite: &Suite, profile: &AppProfile) -> Vec<Arc<dyn Tool>> {
228 CONTROL_METHODS
229 .iter()
230 .filter(|m| profile.method_enabled(m))
231 .map(|m| {
232 let (desc, effect, schema) = method_schema(m);
233 let description = format!("{desc} (app: {})", profile.app);
234 Arc::new(ControlTool {
235 spec: ToolSpec::new(format!("{}.{m}", profile.app), description, effect, schema),
236 suite: suite.clone(),
237 profile: profile.clone(),
238 method: m.to_string(),
239 }) as Arc<dyn Tool>
240 })
241 .collect()
242}