@pheem49/mint 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/Cargo.lock +55 -3
  2. package/Cargo.toml +2 -1
  3. package/Release_Note.md +285 -140
  4. package/bin/mint +0 -0
  5. package/crates/mint-cli/Cargo.toml +1 -0
  6. package/crates/mint-cli/src/actions.rs +233 -0
  7. package/crates/mint-cli/src/agent.rs +287 -43
  8. package/crates/mint-cli/src/gmail.rs +22 -2
  9. package/crates/mint-cli/src/interactive.rs +2888 -0
  10. package/crates/mint-cli/src/main.rs +336 -2854
  11. package/crates/mint-cli/src/markdown.rs +294 -0
  12. package/crates/mint-cli/src/onboard.rs +44 -1
  13. package/crates/mint-cli/src/updater.rs +48 -6
  14. package/crates/mint-core/Cargo.toml +1 -0
  15. package/crates/mint-core/src/agent_loop.rs +1 -0
  16. package/crates/mint-core/src/api_server.rs +376 -13
  17. package/crates/mint-core/src/browser.rs +629 -101
  18. package/crates/mint-core/src/channels.rs +105 -6
  19. package/crates/mint-core/src/chat.rs +52 -4
  20. package/crates/mint-core/src/code_tools.rs +29 -11
  21. package/crates/mint-core/src/config.rs +118 -0
  22. package/crates/mint-core/src/image_gen.rs +294 -33
  23. package/crates/mint-core/src/lib.rs +5 -2
  24. package/crates/mint-core/src/mcp.rs +119 -6
  25. package/crates/mint-core/src/orchestration.rs +133 -11
  26. package/crates/mint-core/src/pictures.rs +64 -1
  27. package/crates/mint-core/src/skills.rs +82 -6
  28. package/crates/mint-core/src/video_gen.rs +235 -0
  29. package/package.json +8 -2
  30. package/shared-modules/luajit/luajit.json +2 -2
  31. package/skills-lock.json +41 -0
  32. package/src/renderer/index-web.html +1 -1
  33. package/src/renderer/index.html +1 -1
  34. package/src/renderer/shared/constants/slashCommands.ts +27 -0
  35. package/src/renderer/shared/platform.ts +3 -0
  36. package/src/renderer/shared/types.ts +4 -0
  37. package/src/renderer/shared/utils/approval.tsx +86 -7
  38. package/src/renderer/shared/utils/markdown.tsx +217 -88
  39. package/src/renderer/shared/utils/slashCommandProcessor.ts +239 -0
  40. package/src/renderer/src/components/ChatPanel.tsx +461 -64
  41. package/src/renderer/src/components/DashboardSidebar.tsx +11 -2
  42. package/src/renderer/src/components/ImageStudioPanel.tsx +87 -4
  43. package/src/renderer/src/components/MintDashboard.tsx +150 -5
  44. package/src/renderer/src/components/PicturesLibrary.tsx +156 -15
  45. package/src/renderer/src/components/Settings/AutomationTab.tsx +316 -18
  46. package/src/renderer/src/components/Settings/GeneralTab.tsx +762 -634
  47. package/src/renderer/src/components/Settings/PluginsTab.tsx +304 -70
  48. package/src/renderer/src/components/Settings/ThemeTab.tsx +6 -1
  49. package/src/renderer/src/components/SettingsWindow.tsx +38 -2
  50. package/src/renderer/src/components/VeoStudioPanel.tsx +549 -0
  51. package/src/renderer/src/css/image-studio.css +106 -0
  52. package/src/renderer/src/css/settings.css +158 -0
  53. package/src/renderer/src/css/styles.css +352 -1
  54. package/src/renderer/src/css/veo-studio.css +626 -0
  55. package/src/renderer/src/index.css +1 -0
  56. package/src/renderer/src/tauri.ts +144 -17
  57. package/src/renderer/src-web/components/ChatPanel.tsx +360 -88
  58. package/src/renderer/src-web/components/DashboardSidebar.tsx +16 -1
  59. package/src/renderer/src-web/components/ImageStudioPanel.tsx +87 -4
  60. package/src/renderer/src-web/components/MintDashboard.tsx +137 -4
  61. package/src/renderer/src-web/components/PicturesLibrary.tsx +155 -15
  62. package/src/renderer/src-web/components/Settings/AutomationTab.tsx +316 -18
  63. package/src/renderer/src-web/components/Settings/GeneralTab.tsx +762 -629
  64. package/src/renderer/src-web/components/Settings/PluginsTab.tsx +304 -70
  65. package/src/renderer/src-web/components/Settings/ThemeTab.tsx +6 -1
  66. package/src/renderer/src-web/components/SettingsWindow.tsx +35 -2
  67. package/src/renderer/src-web/components/VeoStudioPanel.tsx +549 -0
  68. package/src/renderer/src-web/css/image-studio.css +106 -0
  69. package/src/renderer/src-web/css/settings.css +158 -0
  70. package/src/renderer/src-web/css/styles.css +353 -1
  71. package/src/renderer/src-web/css/veo-studio.css +650 -0
  72. package/src/renderer/src-web/index.css +1 -0
  73. package/src/renderer/src-web/tauri.ts +135 -16
  74. package/src-tauri/src/lib.rs +114 -8
  75. package/src-tauri/src/webhooks.rs +45 -31
  76. package/src-tauri/src/workflows.rs +1 -0
  77. package/src-tauri/tauri.conf.json +1 -1
  78. package/temp_gridgeist/AGENTS.md +8 -0
  79. package/temp_gridgeist/CHANGELOG.md +130 -0
  80. package/temp_gridgeist/LICENSE +21 -0
  81. package/temp_gridgeist/README.md +323 -0
  82. package/temp_gridgeist/README.th.md +325 -0
  83. package/temp_gridgeist/skills/gridgeist/SKILL.md +88 -0
  84. package/temp_gridgeist/skills/gridgeist/agents/openai.yaml +9 -0
  85. package/temp_gridgeist/skills/gridgeist/assets/gridgeist-small.svg +10 -0
  86. package/temp_gridgeist/skills/gridgeist/assets/gridgeist.png +0 -0
  87. package/temp_gridgeist/skills/gridgeist/assets/gridgeist.svg +18 -0
  88. package/temp_gridgeist/skills/gridgeist/references/design-language.md +88 -0
  89. package/temp_gridgeist/skills/gridgeist/references/review-checklist.md +128 -0
  90. package/temp_gridgeist/skills/gridgeist/references/system-contract.md +109 -0
@@ -4,6 +4,10 @@ use serde::Serialize;
4
4
  use serde_json::{Value, json};
5
5
  use tokio_tungstenite::{connect_async, tungstenite::Message};
6
6
 
7
+ // ---------------------------------------------------------------------------
8
+ // Logging
9
+ // ---------------------------------------------------------------------------
10
+
7
11
  fn log_action(action: &str, details: &str) {
8
12
  if let Some(config_dir) = dirs::config_dir() {
9
13
  let log_dir = config_dir.join("mint");
@@ -21,6 +25,10 @@ fn log_action(action: &str, details: &str) {
21
25
  }
22
26
  }
23
27
 
28
+ // ---------------------------------------------------------------------------
29
+ // Public types
30
+ // ---------------------------------------------------------------------------
31
+
24
32
  #[derive(Debug, Clone, Serialize)]
25
33
  #[serde(rename_all = "camelCase")]
26
34
  pub struct BrowserTab {
@@ -29,6 +37,10 @@ pub struct BrowserTab {
29
37
  pub url: String,
30
38
  }
31
39
 
40
+ // ---------------------------------------------------------------------------
41
+ // Public API — navigation / reading
42
+ // ---------------------------------------------------------------------------
43
+
32
44
  pub async fn list_tabs(config: &MintConfig) -> Result<Vec<BrowserTab>, String> {
33
45
  if !is_browser_running(config).await {
34
46
  return Err("Browser automation is not running. Please run 'mint auto' first.".to_string());
@@ -110,6 +122,15 @@ pub async fn read_page_text(config: &MintConfig) -> Result<String, String> {
110
122
  }
111
123
  }
112
124
 
125
+ // ---------------------------------------------------------------------------
126
+ // Public API — click / type (selector-based, with native CDP events)
127
+ // ---------------------------------------------------------------------------
128
+
129
+ /// Click a CSS selector element using native CDP mouse events.
130
+ /// Supports:
131
+ /// - Standard CSS selectors: `button.submit`, `#id`, `[attr=val]`
132
+ /// - Text match: `text=Login`, `contains=Submit`
133
+ /// - XPath: `xpath=//button[@type='submit']`
113
134
  pub async fn click(config: &MintConfig, selector: &str) -> Result<String, String> {
114
135
  let selector = selector.trim();
115
136
  if selector.is_empty() || selector.len() > 500 {
@@ -121,36 +142,85 @@ pub async fn click(config: &MintConfig, selector: &str) -> Result<String, String
121
142
  }
122
143
  log_action("CLICK", &format!("Clicking element '{selector}'"));
123
144
  ensure_page_open(config).await?;
124
- let selector_json = serde_json::to_string(selector).map_err(|error| error.to_string())?;
125
- match cdp_call(
145
+
146
+ // Ensure overlay exists before doing any visual interaction
147
+ inject_overlay(config).await;
148
+
149
+ // Build a JS expression that finds the element using selector or text/xpath
150
+ let find_expr = selector_to_js_find(selector);
151
+
152
+ let expression = format!(
153
+ r#"(() => {{
154
+ {find_expr}
155
+ if (!el) return null;
156
+ const r = el.getBoundingClientRect();
157
+ return JSON.stringify({{ x: r.left + r.width / 2, y: r.top + r.height / 2 }});
158
+ }})()"#
159
+ );
160
+
161
+ let coord_result = cdp_call_raw(
126
162
  config,
127
163
  "Runtime.evaluate",
128
- json!({
129
- "expression": format!(
130
- "(() => {{ const element = document.querySelector({selector_json}); if (!element) return 'not-found'; element.click(); return 'clicked'; }})()"
131
- ),
132
- "returnByValue": true
133
- }),
164
+ json!({ "expression": expression, "returnByValue": true }),
134
165
  )
135
- .await {
136
- Ok(response) => {
137
- match response["result"]["result"]["value"].as_str() {
138
- Some("clicked") => {
139
- log_action("CLICK_SUCCESS", &format!("Successfully clicked element '{selector}'"));
140
- Ok("clicked".into())
141
- }
142
- Some("not-found") => {
143
- let err = format!("browser selector not found: {selector}");
144
- log_action("CLICK_ERROR", &err);
145
- Err(err)
146
- }
147
- _ => {
148
- let err = response_error(&response);
149
- log_action("CLICK_ERROR", &err);
150
- Err(err)
166
+ .await;
167
+
168
+ if let Ok(response) = coord_result {
169
+ let val = &response["result"]["result"];
170
+ if let Some(val_str) = val["value"].as_str() {
171
+ if let Ok(parsed) = serde_json::from_str::<Value>(val_str) {
172
+ if let (Some(x), Some(y)) = (parsed["x"].as_f64(), parsed["y"].as_f64()) {
173
+ log_action(
174
+ "CLICK",
175
+ &format!("Coordinates ({x:.0},{y:.0}) for '{selector}'"),
176
+ );
177
+ let result = mouse_click(config, x, y, "left").await?;
178
+ log_action(
179
+ "CLICK_SUCCESS",
180
+ &format!("Clicked '{selector}' at ({x:.0},{y:.0})"),
181
+ );
182
+ return Ok(result);
151
183
  }
152
184
  }
153
185
  }
186
+ }
187
+
188
+ // Fallback: JS .click() directly
189
+ let click_expr = format!(
190
+ r#"(() => {{
191
+ {find_expr}
192
+ if (!el) return 'not-found';
193
+ el.scrollIntoView({{behavior:'instant',block:'center'}});
194
+ el.click();
195
+ return 'clicked';
196
+ }})()"#
197
+ );
198
+ match cdp_call(
199
+ config,
200
+ "Runtime.evaluate",
201
+ json!({ "expression": click_expr, "returnByValue": true }),
202
+ )
203
+ .await
204
+ {
205
+ Ok(response) => match response["result"]["result"]["value"].as_str() {
206
+ Some("clicked") => {
207
+ log_action(
208
+ "CLICK_SUCCESS",
209
+ &format!("JS-fallback clicked '{selector}'"),
210
+ );
211
+ Ok("clicked".into())
212
+ }
213
+ Some("not-found") => {
214
+ let err = format!("element not found for selector: {selector}");
215
+ log_action("CLICK_ERROR", &err);
216
+ Err(err)
217
+ }
218
+ _ => {
219
+ let err = response_error(&response);
220
+ log_action("CLICK_ERROR", &err);
221
+ Err(err)
222
+ }
223
+ },
154
224
  Err(e) => {
155
225
  log_action("CLICK_ERROR", &format!("Websocket error: {e}"));
156
226
  Err(e)
@@ -158,6 +228,8 @@ pub async fn click(config: &MintConfig, selector: &str) -> Result<String, String
158
228
  }
159
229
  }
160
230
 
231
+ /// Type text into a CSS selector element using native CDP keyboard events.
232
+ /// Focuses the element first (via click), then sends Input.insertText.
161
233
  pub async fn type_text(config: &MintConfig, selector: &str, text: &str) -> Result<String, String> {
162
234
  let selector = selector.trim();
163
235
  if selector.is_empty() || selector.len() > 500 {
@@ -167,55 +239,403 @@ pub async fn type_text(config: &MintConfig, selector: &str, text: &str) -> Resul
167
239
  );
168
240
  return Err("browser selector must contain between 1 and 500 characters".into());
169
241
  }
170
- log_action("TYPE", &format!("Typing text into element '{selector}'"));
242
+ log_action("TYPE", &format!("Typing into '{selector}'"));
171
243
  ensure_page_open(config).await?;
172
- let selector_json = serde_json::to_string(selector).map_err(|error| error.to_string())?;
173
- let text_json = serde_json::to_string(text).map_err(|error| error.to_string())?;
174
- match cdp_call(
244
+
245
+ // Focus the element by clicking it
246
+ let _ = click(config, selector).await;
247
+ tokio::time::sleep(std::time::Duration::from_millis(100)).await;
248
+
249
+ // Native keyboard input
250
+ type_text_native(config, text).await
251
+ }
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // Public API — native mouse control
255
+ // ---------------------------------------------------------------------------
256
+
257
+ /// Move the real browser mouse cursor to (x, y) via CDP Input.dispatchMouseEvent.
258
+ /// Also ensures overlay exists and updates cursor position.
259
+ pub async fn mouse_move(config: &MintConfig, x: f64, y: f64) -> Result<String, String> {
260
+ log_action("MOUSE_MOVE", &format!("Moving mouse to ({x:.0},{y:.0})"));
261
+ ensure_page_open(config).await?;
262
+
263
+ // Ensure overlay exists
264
+ inject_overlay(config).await;
265
+
266
+ match cdp_call_raw(
267
+ config,
268
+ "Input.dispatchMouseEvent",
269
+ json!({
270
+ "type": "mouseMoved",
271
+ "x": x,
272
+ "y": y,
273
+ "button": "none",
274
+ "buttons": 0,
275
+ "clickCount": 0,
276
+ "modifiers": 0
277
+ }),
278
+ )
279
+ .await
280
+ {
281
+ Ok(_) => {
282
+ // Update the visual cursor overlay position
283
+ let cursor_script = format!(
284
+ r#"(() => {{
285
+ const c = document.getElementById('mint-cursor-overlay');
286
+ if (c) {{ c.style.left = '{x}px'; c.style.top = '{y}px'; }}
287
+ }})()"#
288
+ );
289
+ let _ = cdp_call_raw(
290
+ config,
291
+ "Runtime.evaluate",
292
+ json!({ "expression": cursor_script, "returnByValue": false }),
293
+ )
294
+ .await;
295
+ log_action("MOUSE_MOVE_SUCCESS", &format!("Moved to ({x:.0},{y:.0})"));
296
+ Ok(format!("mouse moved to ({x:.0},{y:.0})"))
297
+ }
298
+ Err(e) => {
299
+ log_action("MOUSE_MOVE_ERROR", &format!("Failed: {e}"));
300
+ Err(e)
301
+ }
302
+ }
303
+ }
304
+
305
+ /// Click at absolute screen coordinates (x, y) using native CDP mouse events.
306
+ /// Dispatches mouseMoved → mousePressed → mouseReleased with animation feedback.
307
+ pub async fn mouse_click(
308
+ config: &MintConfig,
309
+ x: f64,
310
+ y: f64,
311
+ button: &str,
312
+ ) -> Result<String, String> {
313
+ log_action(
314
+ "MOUSE_CLICK",
315
+ &format!("Clicking ({x:.0},{y:.0}) btn={button}"),
316
+ );
317
+ ensure_page_open(config).await?;
318
+
319
+ let button_str = match button {
320
+ "right" => "right",
321
+ "middle" => "middle",
322
+ _ => "left",
323
+ };
324
+
325
+ // Move cursor to target first
326
+ let _ = mouse_move(config, x, y).await;
327
+ tokio::time::sleep(std::time::Duration::from_millis(60)).await;
328
+
329
+ // Cursor click animation
330
+ let anim = r#"(() => {
331
+ const c = document.getElementById('mint-cursor-overlay');
332
+ if (c) {
333
+ c.style.transition = 'transform 0.08s ease, opacity 0.08s ease';
334
+ c.style.transform = 'scale(0.82)';
335
+ c.style.opacity = '0.65';
336
+ setTimeout(() => { c.style.transform = 'scale(1)'; c.style.opacity = '1'; }, 120);
337
+ }
338
+ })()"#;
339
+ let _ = cdp_call_raw(
175
340
  config,
176
341
  "Runtime.evaluate",
342
+ json!({ "expression": anim, "returnByValue": false }),
343
+ )
344
+ .await;
345
+
346
+ // mousePressed
347
+ cdp_call_raw(
348
+ config,
349
+ "Input.dispatchMouseEvent",
177
350
  json!({
178
- "expression": format!(
179
- "(() => {{ \
180
- const element = document.querySelector({selector_json}); \
181
- if (!element) return 'not-found'; \
182
- element.value = {text_json}; \
183
- element.dispatchEvent(new Event('input', {{ bubbles: true }})); \
184
- element.dispatchEvent(new Event('change', {{ bubbles: true }})); \
185
- return 'typed'; \
186
- }})()"
187
- ),
188
- "returnByValue": true
351
+ "type": "mousePressed",
352
+ "x": x, "y": y,
353
+ "button": button_str,
354
+ "buttons": 1,
355
+ "clickCount": 1,
356
+ "modifiers": 0
357
+ }),
358
+ )
359
+ .await
360
+ .map_err(|e| {
361
+ log_action("MOUSE_CLICK_ERROR", &format!("mousePressed: {e}"));
362
+ e
363
+ })?;
364
+
365
+ tokio::time::sleep(std::time::Duration::from_millis(50)).await;
366
+
367
+ // mouseReleased
368
+ cdp_call_raw(
369
+ config,
370
+ "Input.dispatchMouseEvent",
371
+ json!({
372
+ "type": "mouseReleased",
373
+ "x": x, "y": y,
374
+ "button": button_str,
375
+ "buttons": 0,
376
+ "clickCount": 1,
377
+ "modifiers": 0
378
+ }),
379
+ )
380
+ .await
381
+ .map_err(|e| {
382
+ log_action("MOUSE_CLICK_ERROR", &format!("mouseReleased: {e}"));
383
+ e
384
+ })?;
385
+
386
+ log_action(
387
+ "MOUSE_CLICK_SUCCESS",
388
+ &format!("Clicked ({x:.0},{y:.0}) {button_str}"),
389
+ );
390
+ Ok(format!(
391
+ "clicked at ({x:.0},{y:.0}) with {button_str} button"
392
+ ))
393
+ }
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // Public API — native keyboard control
397
+ // ---------------------------------------------------------------------------
398
+
399
+ /// Type text using CDP Input.insertText (native, supports any Unicode text).
400
+ pub async fn type_text_native(config: &MintConfig, text: &str) -> Result<String, String> {
401
+ log_action(
402
+ "TYPE",
403
+ &format!("Native typing: '{}'", &text[..text.len().min(60)]),
404
+ );
405
+ ensure_page_open(config).await?;
406
+
407
+ match cdp_call_raw(config, "Input.insertText", json!({ "text": text })).await {
408
+ Ok(_) => {
409
+ log_action(
410
+ "TYPE_SUCCESS",
411
+ &format!("Typed {} characters natively", text.len()),
412
+ );
413
+ Ok(format!("typed {} characters", text.len()))
414
+ }
415
+ Err(e) => {
416
+ log_action("TYPE_ERROR", &format!("Native type failed: {e}"));
417
+ Err(e)
418
+ }
419
+ }
420
+ }
421
+
422
+ /// Press a single key using CDP Input.dispatchKeyEvent.
423
+ ///
424
+ /// Supported special keys: Enter, Tab, Escape, Backspace, Delete,
425
+ /// ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Home, End,
426
+ /// PageUp, PageDown, Space, F1–F12.
427
+ /// Also accepts single printable characters.
428
+ pub async fn key_press(config: &MintConfig, key: &str) -> Result<String, String> {
429
+ log_action("KEY_PRESS", &format!("Pressing key '{key}'"));
430
+ ensure_page_open(config).await?;
431
+
432
+ let (key_code, code) = key_to_cdp_params(key);
433
+
434
+ // keyDown
435
+ cdp_call_raw(
436
+ config,
437
+ "Input.dispatchKeyEvent",
438
+ json!({
439
+ "type": "keyDown",
440
+ "key": key,
441
+ "code": code,
442
+ "windowsVirtualKeyCode": key_code,
443
+ "nativeVirtualKeyCode": key_code,
444
+ "modifiers": 0,
445
+ "isSystemKey": false,
446
+ "location": 0
447
+ }),
448
+ )
449
+ .await
450
+ .map_err(|e| {
451
+ log_action("KEY_PRESS_ERROR", &format!("keyDown failed: {e}"));
452
+ e
453
+ })?;
454
+
455
+ // char event for printable single characters
456
+ if key.chars().count() == 1 && !key.chars().next().map(|c| c.is_control()).unwrap_or(true) {
457
+ let _ = cdp_call_raw(
458
+ config,
459
+ "Input.dispatchKeyEvent",
460
+ json!({
461
+ "type": "char",
462
+ "key": key,
463
+ "code": code,
464
+ "windowsVirtualKeyCode": key_code,
465
+ "modifiers": 0
466
+ }),
467
+ )
468
+ .await;
469
+ }
470
+
471
+ tokio::time::sleep(std::time::Duration::from_millis(30)).await;
472
+
473
+ // keyUp
474
+ cdp_call_raw(
475
+ config,
476
+ "Input.dispatchKeyEvent",
477
+ json!({
478
+ "type": "keyUp",
479
+ "key": key,
480
+ "code": code,
481
+ "windowsVirtualKeyCode": key_code,
482
+ "nativeVirtualKeyCode": key_code,
483
+ "modifiers": 0,
484
+ "isSystemKey": false,
485
+ "location": 0
486
+ }),
487
+ )
488
+ .await
489
+ .map_err(|e| {
490
+ log_action("KEY_PRESS_ERROR", &format!("keyUp failed: {e}"));
491
+ e
492
+ })?;
493
+
494
+ log_action("KEY_PRESS_SUCCESS", &format!("Pressed '{key}'"));
495
+ Ok(format!("pressed key '{key}'"))
496
+ }
497
+
498
+ // ---------------------------------------------------------------------------
499
+ // Public API — screenshot
500
+ // ---------------------------------------------------------------------------
501
+
502
+ /// Capture a PNG screenshot of the current browser page.
503
+ /// Returns base64-encoded PNG string.
504
+ pub async fn screenshot(config: &MintConfig) -> Result<String, String> {
505
+ log_action("SCREENSHOT", "Capturing screenshot");
506
+ ensure_page_open(config).await?;
507
+
508
+ match cdp_call_raw(
509
+ config,
510
+ "Page.captureScreenshot",
511
+ json!({
512
+ "format": "png",
513
+ "fromSurface": true,
514
+ "captureBeyondViewport": false
189
515
  }),
190
516
  )
191
517
  .await
192
518
  {
193
- Ok(response) => match response["result"]["result"]["value"].as_str() {
194
- Some("typed") => {
519
+ Ok(response) => {
520
+ if let Some(data) = response["result"]["data"].as_str() {
195
521
  log_action(
196
- "TYPE_SUCCESS",
197
- &format!("Successfully typed into element '{selector}'"),
522
+ "SCREENSHOT_SUCCESS",
523
+ &format!("Captured {} base64 bytes", data.len()),
198
524
  );
199
- Ok("typed".into())
200
- }
201
- Some("not-found") => {
202
- let err = format!("browser selector not found: {selector}");
203
- log_action("TYPE_ERROR", &err);
204
- Err(err)
205
- }
206
- _ => {
525
+ Ok(data.to_owned())
526
+ } else {
207
527
  let err = response_error(&response);
208
- log_action("TYPE_ERROR", &err);
528
+ log_action("SCREENSHOT_ERROR", &format!("Failed: {err}"));
209
529
  Err(err)
210
530
  }
211
- },
531
+ }
212
532
  Err(e) => {
213
- log_action("TYPE_ERROR", &format!("Websocket error: {e}"));
533
+ log_action("SCREENSHOT_ERROR", &format!("Websocket error: {e}"));
214
534
  Err(e)
215
535
  }
216
536
  }
217
537
  }
218
538
 
539
+ // ---------------------------------------------------------------------------
540
+ // Public API — element coordinates
541
+ // ---------------------------------------------------------------------------
542
+
543
+ /// Get the viewport-relative center (x, y) of an element by CSS selector.
544
+ /// Supports text=, contains=, xpath= prefixes in addition to CSS selectors.
545
+ pub async fn get_element_coordinates(
546
+ config: &MintConfig,
547
+ selector: &str,
548
+ ) -> Result<(f64, f64), String> {
549
+ let find_expr = selector_to_js_find(selector);
550
+ let expression = format!(
551
+ r#"(() => {{
552
+ {find_expr}
553
+ if (!el) return null;
554
+ const r = el.getBoundingClientRect();
555
+ return JSON.stringify({{ x: r.left + r.width / 2, y: r.top + r.height / 2 }});
556
+ }})()"#
557
+ );
558
+ match cdp_call_raw(
559
+ config,
560
+ "Runtime.evaluate",
561
+ json!({ "expression": expression, "returnByValue": true }),
562
+ )
563
+ .await
564
+ {
565
+ Ok(response) => {
566
+ let val = &response["result"]["result"];
567
+ if val["type"] == "null" || val["value"].is_null() {
568
+ return Err(format!("element not found: {selector}"));
569
+ }
570
+ if let Some(val_str) = val["value"].as_str() {
571
+ let parsed: Value = serde_json::from_str(val_str).map_err(|e| e.to_string())?;
572
+ let x = parsed["x"].as_f64().ok_or("missing x")?;
573
+ let y = parsed["y"].as_f64().ok_or("missing y")?;
574
+ Ok((x, y))
575
+ } else {
576
+ Err(response_error(&response))
577
+ }
578
+ }
579
+ Err(e) => Err(e),
580
+ }
581
+ }
582
+
583
+ /// Convert a selector (CSS / text= / contains= / xpath=) into a JS let statement:
584
+ /// `let el = <expression>;`
585
+ fn selector_to_js_find(selector: &str) -> String {
586
+ if let Some(text) = selector.strip_prefix("text=") {
587
+ // Exact text match (trimmed)
588
+ let escaped = text.replace('\\', "\\\\").replace('`', "\\`");
589
+ format!(
590
+ "const el = Array.from(document.querySelectorAll('*')).find(\
591
+ e => e.childElementCount === 0 && e.textContent.trim() === `{escaped}`) \
592
+ || Array.from(document.querySelectorAll('*')).find(\
593
+ e => e.textContent.trim() === `{escaped}`);"
594
+ )
595
+ } else if let Some(text) = selector.strip_prefix("contains=") {
596
+ // Partial text match
597
+ let escaped = text.replace('\\', "\\\\").replace('`', "\\`");
598
+ format!(
599
+ "const el = Array.from(document.querySelectorAll('*')).find(\
600
+ e => e.childElementCount === 0 && e.textContent.includes(`{escaped}`)) \
601
+ || Array.from(document.querySelectorAll('*')).find(\
602
+ e => e.textContent.includes(`{escaped}`));"
603
+ )
604
+ } else if let Some(xpath) = selector.strip_prefix("xpath=") {
605
+ let escaped = xpath.replace('\\', "\\\\").replace('"', "\\\"");
606
+ format!(
607
+ "const el = document.evaluate(`{escaped}`, document, null, \
608
+ XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;"
609
+ )
610
+ } else {
611
+ // Standard CSS selector — wrap in try/catch so invalid selectors don't throw
612
+ let escaped = selector.replace('\\', "\\\\").replace('`', "\\`");
613
+ format!(
614
+ "let el; try {{ el = document.querySelector(`{escaped}`); }} \
615
+ catch(e) {{ \
616
+ el = Array.from(document.querySelectorAll('*')).find(\
617
+ e => e.textContent.trim() === `{escaped}`); \
618
+ }}"
619
+ )
620
+ }
621
+ }
622
+
623
+ /// Inject the green aura + animated cursor overlay into the current page.
624
+ /// Best-effort: errors are silently ignored.
625
+ async fn inject_overlay(config: &MintConfig) {
626
+ let script = build_overlay_script();
627
+ let _ = cdp_call_raw(
628
+ config,
629
+ "Runtime.evaluate",
630
+ json!({ "expression": script, "returnByValue": false }),
631
+ )
632
+ .await;
633
+ }
634
+
635
+ // ---------------------------------------------------------------------------
636
+ // Browser lifecycle
637
+ // ---------------------------------------------------------------------------
638
+
219
639
  pub async fn is_browser_running(config: &MintConfig) -> bool {
220
640
  let endpoint = config
221
641
  .extra
@@ -256,7 +676,7 @@ pub async fn spawn_automation_browser(config: &MintConfig) -> Result<(), String>
256
676
  ];
257
677
 
258
678
  let mut spawned = false;
259
- let executables = if cfg!(target_os = "windows") {
679
+ let executables: Vec<&str> = if cfg!(target_os = "windows") {
260
680
  vec!["chrome.exe", "chromium.exe", "msedge.exe"]
261
681
  } else if cfg!(target_os = "macos") {
262
682
  vec![
@@ -290,7 +710,8 @@ pub async fn spawn_automation_browser(config: &MintConfig) -> Result<(), String>
290
710
 
291
711
  if !spawned {
292
712
  return Err(format!(
293
- "Could not find or spawn browser '{browser_name}' with remote debugging. Please verify it is installed."
713
+ "Could not find or spawn browser '{browser_name}' with remote debugging. \
714
+ Please verify it is installed."
294
715
  ));
295
716
  }
296
717
 
@@ -328,6 +749,68 @@ pub async fn ensure_page_open(config: &MintConfig) -> Result<(), String> {
328
749
  Ok(())
329
750
  }
330
751
 
752
+ // ---------------------------------------------------------------------------
753
+ // Overlay injection script
754
+ // ---------------------------------------------------------------------------
755
+
756
+ fn build_overlay_script() -> &'static str {
757
+ r##"
758
+ (() => {
759
+ // ── Green aura border ────────────────────────────────────────────────
760
+ if (!document.getElementById('mint-browser-aura')) {
761
+ const style = document.createElement('style');
762
+ style.id = 'mint-browser-aura-style';
763
+ style.textContent = `
764
+ @keyframes mint-pulse {
765
+ 0% { box-shadow: inset 0 0 15px rgba(16,185,129,0.4); border-color: rgba(16,185,129,0.6); }
766
+ 100% { box-shadow: inset 0 0 30px rgba(16,185,129,0.8); border-color: rgba(16,185,129,1); }
767
+ }
768
+ `;
769
+ document.head.appendChild(style);
770
+
771
+ const aura = document.createElement('div');
772
+ aura.id = 'mint-browser-aura';
773
+ aura.style.cssText = `
774
+ position:fixed; top:0; left:0; width:100vw; height:100vh;
775
+ border:5px solid rgba(16,185,129,0.6);
776
+ pointer-events:none; z-index:2147483646;
777
+ box-sizing:border-box;
778
+ animation:mint-pulse 1.5s infinite alternate;
779
+ `;
780
+ document.body.appendChild(aura);
781
+ }
782
+
783
+ // ── Animated mouse cursor overlay ─────────────────────────────────────
784
+ if (!document.getElementById('mint-cursor-overlay')) {
785
+ const cursor = document.createElement('div');
786
+ cursor.id = 'mint-cursor-overlay';
787
+ cursor.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">
788
+ <defs>
789
+ <filter id="mint-cs" x="-30%" y="-30%" width="160%" height="160%">
790
+ <feDropShadow dx="1" dy="2" stdDeviation="2.5" flood-color="rgba(0,0,0,0.5)"/>
791
+ </filter>
792
+ </defs>
793
+ <path d="M5 2 L5 22 L9.5 17 L13.5 24.5 L16 23 L12 15.5 L19 15.5 Z"
794
+ fill="white" stroke="#10b981" stroke-width="1.6"
795
+ stroke-linejoin="round" filter="url(#mint-cs)"/>
796
+ </svg>`;
797
+ cursor.style.cssText = `
798
+ position:fixed; left:0; top:0; pointer-events:none;
799
+ z-index:2147483647;
800
+ transition:left 0.06s ease-out, top 0.06s ease-out, transform 0.08s ease, opacity 0.08s ease;
801
+ transform-origin:5px 2px;
802
+ `;
803
+ document.body.appendChild(cursor);
804
+ }
805
+ })()
806
+ "##
807
+ }
808
+
809
+ // ---------------------------------------------------------------------------
810
+ // CDP helpers
811
+ // ---------------------------------------------------------------------------
812
+
813
+ /// Full CDP call — injects the aura+cursor overlay before the actual call.
331
814
  async fn cdp_call(config: &MintConfig, method: &str, params: Value) -> Result<Value, String> {
332
815
  let page = fetch_pages(config)
333
816
  .await?
@@ -339,55 +822,22 @@ async fn cdp_call(config: &MintConfig, method: &str, params: Value) -> Result<Va
339
822
  .ok_or("Chrome DevTools page does not expose a websocket URL")?;
340
823
  let (mut socket, _) = connect_async(socket_url)
341
824
  .await
342
- .map_err(|error| format!("unable to connect to Chrome DevTools websocket: {error}"))?;
343
- let is_aura_script = method == "Runtime.evaluate"
825
+ .map_err(|e| format!("unable to connect to Chrome DevTools websocket: {e}"))?;
826
+
827
+ let is_overlay = method == "Runtime.evaluate"
344
828
  && params["expression"]
345
829
  .as_str()
346
830
  .map(|s| s.contains("mint-browser-aura"))
347
831
  .unwrap_or(false);
348
832
 
349
- if !is_aura_script {
350
- let aura_script = r#"
351
- (() => {
352
- if (document.getElementById('mint-browser-aura')) return;
353
- const aura = document.createElement('div');
354
- aura.id = 'mint-browser-aura';
355
-
356
- const style = document.createElement('style');
357
- style.id = 'mint-browser-aura-style';
358
- style.textContent = `
359
- @keyframes mint-pulse {
360
- 0% { box-shadow: inset 0 0 15px rgba(16, 185, 129, 0.4); border-color: rgba(16, 185, 129, 0.6); }
361
- 100% { box-shadow: inset 0 0 30px rgba(16, 185, 129, 0.8); border-color: rgba(16, 185, 129, 1); }
362
- }
363
- `;
364
- document.head.appendChild(style);
365
-
366
- aura.style.cssText = `
367
- position: fixed;
368
- top: 0;
369
- left: 0;
370
- width: 100vw;
371
- height: 100vh;
372
- border: 5px solid rgba(16, 185, 129, 0.6);
373
- pointer-events: none;
374
- z-index: 2147483647;
375
- box-sizing: border-box;
376
- animation: mint-pulse 1.5s infinite alternate;
377
- `;
378
- document.body.appendChild(aura);
379
- })()
380
- "#;
381
-
833
+ if !is_overlay {
834
+ let overlay = build_overlay_script();
382
835
  let _ = socket
383
836
  .send(Message::Text(
384
837
  json!({
385
838
  "id": 999,
386
839
  "method": "Runtime.evaluate",
387
- "params": {
388
- "expression": aura_script,
389
- "returnByValue": true
390
- }
840
+ "params": { "expression": overlay, "returnByValue": false }
391
841
  })
392
842
  .to_string()
393
843
  .into(),
@@ -402,13 +852,50 @@ async fn cdp_call(config: &MintConfig, method: &str, params: Value) -> Result<Va
402
852
  .into(),
403
853
  ))
404
854
  .await
405
- .map_err(|error| error.to_string())?;
855
+ .map_err(|e| e.to_string())?;
856
+
406
857
  while let Some(message) = socket.next().await {
407
- let message = message.map_err(|error| error.to_string())?;
858
+ let message = message.map_err(|e| e.to_string())?;
408
859
  let Message::Text(raw) = message else {
409
860
  continue;
410
861
  };
411
- let value: Value = serde_json::from_str(&raw).map_err(|error| error.to_string())?;
862
+ let value: Value = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
863
+ if value["id"] == 1 {
864
+ return Ok(value);
865
+ }
866
+ }
867
+ Err("Chrome DevTools websocket closed before returning a response".into())
868
+ }
869
+
870
+ /// Lightweight CDP call — no overlay injection. Used for Input.* and Page.captureScreenshot.
871
+ async fn cdp_call_raw(config: &MintConfig, method: &str, params: Value) -> Result<Value, String> {
872
+ let page = fetch_pages(config)
873
+ .await?
874
+ .into_iter()
875
+ .find(|page| page["type"] == "page")
876
+ .ok_or("Chrome DevTools did not report an open browser page")?;
877
+ let socket_url = page["webSocketDebuggerUrl"]
878
+ .as_str()
879
+ .ok_or("Chrome DevTools page does not expose a websocket URL")?;
880
+ let (mut socket, _) = connect_async(socket_url)
881
+ .await
882
+ .map_err(|e| format!("unable to connect to Chrome DevTools websocket: {e}"))?;
883
+
884
+ socket
885
+ .send(Message::Text(
886
+ json!({ "id": 1, "method": method, "params": params })
887
+ .to_string()
888
+ .into(),
889
+ ))
890
+ .await
891
+ .map_err(|e| e.to_string())?;
892
+
893
+ while let Some(message) = socket.next().await {
894
+ let message = message.map_err(|e| e.to_string())?;
895
+ let Message::Text(raw) = message else {
896
+ continue;
897
+ };
898
+ let value: Value = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
412
899
  if value["id"] == 1 {
413
900
  return Ok(value);
414
901
  }
@@ -436,12 +923,13 @@ async fn fetch_pages_endpoint(endpoint: &str) -> Result<Vec<Value>, String> {
436
923
  .get(endpoint)
437
924
  .send()
438
925
  .await
439
- .map_err(|error| format!("unable to reach Chrome DevTools at {endpoint}: {error}"))?
926
+ .map_err(|e| format!("unable to reach Chrome DevTools at {endpoint}: {e}"))?
440
927
  .error_for_status()
441
- .map_err(|error| error.to_string())?
928
+ .map_err(|e| e.to_string())?
442
929
  .json()
443
930
  .await
444
- .map_err(|error| error.to_string())?;
931
+ .map_err(|e| e.to_string())?;
932
+
445
933
  value
446
934
  .as_array()
447
935
  .cloned()
@@ -454,3 +942,43 @@ fn response_error(response: &Value) -> String {
454
942
  .unwrap_or("Chrome DevTools returned an unexpected response")
455
943
  .to_owned()
456
944
  }
945
+
946
+ // ---------------------------------------------------------------------------
947
+ // Key-to-CDP mapping
948
+ // ---------------------------------------------------------------------------
949
+
950
+ /// Returns (windowsVirtualKeyCode, code string) for a key name.
951
+ fn key_to_cdp_params(key: &str) -> (i32, &'static str) {
952
+ match key {
953
+ "Enter" | "\n" | "\r" => (13, "Enter"),
954
+ "Tab" | "\t" => (9, "Tab"),
955
+ "Escape" | "Esc" => (27, "Escape"),
956
+ "Backspace" => (8, "Backspace"),
957
+ "Delete" | "Del" => (46, "Delete"),
958
+ "ArrowUp" | "Up" => (38, "ArrowUp"),
959
+ "ArrowDown" | "Down" => (40, "ArrowDown"),
960
+ "ArrowLeft" | "Left" => (37, "ArrowLeft"),
961
+ "ArrowRight" | "Right" => (39, "ArrowRight"),
962
+ "Home" => (36, "Home"),
963
+ "End" => (35, "End"),
964
+ "PageUp" => (33, "PageUp"),
965
+ "PageDown" => (34, "PageDown"),
966
+ "Space" | " " => (32, "Space"),
967
+ "F1" => (112, "F1"),
968
+ "F2" => (113, "F2"),
969
+ "F3" => (114, "F3"),
970
+ "F4" => (115, "F4"),
971
+ "F5" => (116, "F5"),
972
+ "F6" => (117, "F6"),
973
+ "F7" => (118, "F7"),
974
+ "F8" => (119, "F8"),
975
+ "F9" => (120, "F9"),
976
+ "F10" => (121, "F10"),
977
+ "F11" => (122, "F11"),
978
+ "F12" => (123, "F12"),
979
+ _ => {
980
+ let code = key.chars().next().map(|c| c as i32).unwrap_or(0);
981
+ (code, "Unidentified")
982
+ }
983
+ }
984
+ }