@pheem49/mint 1.6.1 → 1.6.3
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.
- package/.github/workflows/ci.yml +14 -0
- package/Cargo.lock +3 -3
- package/Cargo.toml +1 -1
- package/bin/mint +0 -0
- package/package.json +11 -3
- package/src/renderer/index-web.html +1 -1
- package/src/renderer/index.html +1 -1
- package/src/renderer/src/components/DashboardSidebar.tsx +2 -2
- package/src/renderer/src/components/MintDashboard.tsx +1 -1
- package/src/renderer/src-web/components/ChatPanel.tsx +1 -1
- package/src/renderer/src-web/components/DashboardSidebar.tsx +2 -2
- package/src/renderer/src-web/components/MintDashboard.tsx +1 -1
- package/src-tauri/Cargo.toml +29 -0
- package/src-tauri/build.rs +3 -0
- package/src-tauri/capabilities/main.json +7 -0
- package/src-tauri/gen/schemas/acl-manifests.json +1 -0
- package/src-tauri/gen/schemas/capabilities.json +1 -0
- package/src-tauri/gen/schemas/desktop-schema.json +2412 -0
- package/src-tauri/gen/schemas/linux-schema.json +2412 -0
- package/src-tauri/src/browser.rs +141 -0
- package/src-tauri/src/desktop.rs +392 -0
- package/src-tauri/src/discord_rpc.rs +99 -0
- package/src-tauri/src/events.rs +70 -0
- package/src-tauri/src/headless.rs +222 -0
- package/src-tauri/src/integrations.rs +126 -0
- package/src-tauri/src/lib.rs +1033 -0
- package/src-tauri/src/main.rs +3 -0
- package/src-tauri/src/plugins.rs +16 -0
- package/src-tauri/src/proactive.rs +254 -0
- package/src-tauri/src/system.rs +250 -0
- package/src-tauri/src/updater.rs +148 -0
- package/src-tauri/src/webhooks.rs +255 -0
- package/src-tauri/src/workflows.rs +70 -0
- package/src-tauri/tauri.conf.json +48 -0
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
use futures_util::{SinkExt, StreamExt};
|
|
2
|
+
use mint_core::MintConfig;
|
|
3
|
+
use serde::Serialize;
|
|
4
|
+
use serde_json::{Value, json};
|
|
5
|
+
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
|
6
|
+
|
|
7
|
+
#[derive(Debug, Clone, Serialize)]
|
|
8
|
+
#[serde(rename_all = "camelCase")]
|
|
9
|
+
pub struct BrowserTab {
|
|
10
|
+
pub id: String,
|
|
11
|
+
pub title: String,
|
|
12
|
+
pub url: String,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
pub async fn list_tabs(config: &MintConfig) -> Result<Vec<BrowserTab>, String> {
|
|
16
|
+
Ok(fetch_pages(config)
|
|
17
|
+
.await?
|
|
18
|
+
.into_iter()
|
|
19
|
+
.filter_map(|page| {
|
|
20
|
+
Some(BrowserTab {
|
|
21
|
+
id: page["id"].as_str()?.to_owned(),
|
|
22
|
+
title: page["title"].as_str().unwrap_or_default().to_owned(),
|
|
23
|
+
url: page["url"].as_str().unwrap_or_default().to_owned(),
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
.collect())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
pub async fn navigate(config: &MintConfig, url: &str) -> Result<String, String> {
|
|
30
|
+
if !(url.starts_with("https://") || url.starts_with("http://")) {
|
|
31
|
+
return Err("browser navigation only supports http and https URLs".into());
|
|
32
|
+
}
|
|
33
|
+
let response = cdp_call(config, "Page.navigate", json!({ "url": url })).await?;
|
|
34
|
+
response["result"]["frameId"]
|
|
35
|
+
.as_str()
|
|
36
|
+
.map(|_| format!("navigating to {url}"))
|
|
37
|
+
.ok_or_else(|| response_error(&response))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
pub async fn read_page_text(config: &MintConfig) -> Result<String, String> {
|
|
41
|
+
let response = cdp_call(
|
|
42
|
+
config,
|
|
43
|
+
"Runtime.evaluate",
|
|
44
|
+
json!({
|
|
45
|
+
"expression": "document.body ? document.body.innerText.substring(0, 12000) : ''",
|
|
46
|
+
"returnByValue": true
|
|
47
|
+
}),
|
|
48
|
+
)
|
|
49
|
+
.await?;
|
|
50
|
+
response["result"]["result"]["value"]
|
|
51
|
+
.as_str()
|
|
52
|
+
.map(str::to_owned)
|
|
53
|
+
.ok_or_else(|| response_error(&response))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
pub async fn click(config: &MintConfig, selector: &str) -> Result<String, String> {
|
|
57
|
+
let selector = selector.trim();
|
|
58
|
+
if selector.is_empty() || selector.len() > 500 {
|
|
59
|
+
return Err("browser selector must contain between 1 and 500 characters".into());
|
|
60
|
+
}
|
|
61
|
+
let selector = serde_json::to_string(selector).map_err(|error| error.to_string())?;
|
|
62
|
+
let response = cdp_call(
|
|
63
|
+
config,
|
|
64
|
+
"Runtime.evaluate",
|
|
65
|
+
json!({
|
|
66
|
+
"expression": format!(
|
|
67
|
+
"(() => {{ const element = document.querySelector({selector}); if (!element) return 'not-found'; element.click(); return 'clicked'; }})()"
|
|
68
|
+
),
|
|
69
|
+
"returnByValue": true
|
|
70
|
+
}),
|
|
71
|
+
)
|
|
72
|
+
.await?;
|
|
73
|
+
match response["result"]["result"]["value"].as_str() {
|
|
74
|
+
Some("clicked") => Ok("clicked".into()),
|
|
75
|
+
Some("not-found") => Err(format!("browser selector not found: {selector}")),
|
|
76
|
+
_ => Err(response_error(&response)),
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async fn cdp_call(config: &MintConfig, method: &str, params: Value) -> Result<Value, String> {
|
|
81
|
+
let page = fetch_pages(config)
|
|
82
|
+
.await?
|
|
83
|
+
.into_iter()
|
|
84
|
+
.find(|page| page["type"] == "page")
|
|
85
|
+
.ok_or("Chrome DevTools did not report an open browser page")?;
|
|
86
|
+
let socket_url = page["webSocketDebuggerUrl"]
|
|
87
|
+
.as_str()
|
|
88
|
+
.ok_or("Chrome DevTools page does not expose a websocket URL")?;
|
|
89
|
+
let (mut socket, _) = connect_async(socket_url)
|
|
90
|
+
.await
|
|
91
|
+
.map_err(|error| format!("unable to connect to Chrome DevTools websocket: {error}"))?;
|
|
92
|
+
socket
|
|
93
|
+
.send(Message::Text(
|
|
94
|
+
json!({ "id": 1, "method": method, "params": params })
|
|
95
|
+
.to_string()
|
|
96
|
+
.into(),
|
|
97
|
+
))
|
|
98
|
+
.await
|
|
99
|
+
.map_err(|error| error.to_string())?;
|
|
100
|
+
while let Some(message) = socket.next().await {
|
|
101
|
+
let message = message.map_err(|error| error.to_string())?;
|
|
102
|
+
let Message::Text(raw) = message else {
|
|
103
|
+
continue;
|
|
104
|
+
};
|
|
105
|
+
let value: Value = serde_json::from_str(&raw).map_err(|error| error.to_string())?;
|
|
106
|
+
if value["id"] == 1 {
|
|
107
|
+
return Ok(value);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
Err("Chrome DevTools websocket closed before returning a response".into())
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async fn fetch_pages(config: &MintConfig) -> Result<Vec<Value>, String> {
|
|
114
|
+
let endpoint = config
|
|
115
|
+
.extra
|
|
116
|
+
.get("browserDebugUrl")
|
|
117
|
+
.and_then(Value::as_str)
|
|
118
|
+
.unwrap_or("http://127.0.0.1:9222/json/list");
|
|
119
|
+
let value: Value = mint_core::HTTP_CLIENT
|
|
120
|
+
.clone()
|
|
121
|
+
.get(endpoint)
|
|
122
|
+
.send()
|
|
123
|
+
.await
|
|
124
|
+
.map_err(|error| format!("unable to reach Chrome DevTools at {endpoint}: {error}"))?
|
|
125
|
+
.error_for_status()
|
|
126
|
+
.map_err(|error| error.to_string())?
|
|
127
|
+
.json()
|
|
128
|
+
.await
|
|
129
|
+
.map_err(|error| error.to_string())?;
|
|
130
|
+
value
|
|
131
|
+
.as_array()
|
|
132
|
+
.cloned()
|
|
133
|
+
.ok_or_else(|| "Chrome DevTools response was not a page list".into())
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
fn response_error(response: &Value) -> String {
|
|
137
|
+
response["error"]["message"]
|
|
138
|
+
.as_str()
|
|
139
|
+
.unwrap_or("Chrome DevTools returned an unexpected response")
|
|
140
|
+
.to_owned()
|
|
141
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
use std::{
|
|
2
|
+
fs,
|
|
3
|
+
path::PathBuf,
|
|
4
|
+
process::{Command, Stdio},
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
|
8
|
+
use image::ImageFormat;
|
|
9
|
+
use serde::{Deserialize, Serialize};
|
|
10
|
+
use serde_json::{Value, json};
|
|
11
|
+
use tauri::{
|
|
12
|
+
AppHandle, Emitter, Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindowBuilder,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
use crate::system::run_system_automation;
|
|
16
|
+
use mint_core::{KnowledgeStore, create_folder, find_paths};
|
|
17
|
+
|
|
18
|
+
#[derive(Debug, Deserialize)]
|
|
19
|
+
#[serde(rename_all = "camelCase")]
|
|
20
|
+
pub struct DesktopAction {
|
|
21
|
+
#[serde(rename = "type")]
|
|
22
|
+
pub kind: String,
|
|
23
|
+
#[serde(default)]
|
|
24
|
+
pub target: String,
|
|
25
|
+
#[serde(default)]
|
|
26
|
+
pub server: String,
|
|
27
|
+
#[serde(default)]
|
|
28
|
+
pub args: Value,
|
|
29
|
+
#[serde(default)]
|
|
30
|
+
pub approved: bool,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#[derive(Debug, Serialize)]
|
|
34
|
+
#[serde(rename_all = "camelCase")]
|
|
35
|
+
pub struct ActionResult {
|
|
36
|
+
pub success: bool,
|
|
37
|
+
pub message: String,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#[derive(Debug, Deserialize)]
|
|
41
|
+
pub struct CaptureRect {
|
|
42
|
+
pub x: u32,
|
|
43
|
+
pub y: u32,
|
|
44
|
+
pub width: u32,
|
|
45
|
+
pub height: u32,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
pub fn open_desktop_window(app: &AppHandle, kind: &str) -> Result<(), String> {
|
|
49
|
+
let (label, route, width, height, always_on_top, skip_taskbar) = match kind {
|
|
50
|
+
"settings" => ("settings", "settings", 1020.0, 720.0, false, false),
|
|
51
|
+
"spotlight" => ("spotlight", "spotlight", 600.0, 80.0, true, true),
|
|
52
|
+
"widget" => ("widget", "widget", 150.0, 150.0, true, true),
|
|
53
|
+
"screen-picker" => ("screen-picker", "screen-picker", 1280.0, 800.0, true, true),
|
|
54
|
+
other => return Err(format!("unsupported desktop window '{other}'")),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
if let Some(window) = app.get_webview_window(label) {
|
|
58
|
+
window.show().map_err(|error| error.to_string())?;
|
|
59
|
+
window.set_focus().map_err(|error| error.to_string())?;
|
|
60
|
+
return Ok(());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let url = WebviewUrl::App(format!("index.html#/{route}").into());
|
|
64
|
+
let mut builder = WebviewWindowBuilder::new(app, label, url)
|
|
65
|
+
.title(format!("Mint {kind}"))
|
|
66
|
+
.inner_size(width, height)
|
|
67
|
+
.decorations(kind == "settings")
|
|
68
|
+
.transparent(true)
|
|
69
|
+
.always_on_top(always_on_top)
|
|
70
|
+
.skip_taskbar(skip_taskbar);
|
|
71
|
+
if kind == "screen-picker" {
|
|
72
|
+
builder = builder.fullscreen(true);
|
|
73
|
+
}
|
|
74
|
+
builder.build().map_err(|error| error.to_string())?;
|
|
75
|
+
Ok(())
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
pub fn hide_window(app: &AppHandle, label: &str) -> Result<(), String> {
|
|
79
|
+
app.get_webview_window(label)
|
|
80
|
+
.ok_or_else(|| format!("window '{label}' is not open"))?
|
|
81
|
+
.hide()
|
|
82
|
+
.map_err(|error| error.to_string())
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
pub fn close_window(app: &AppHandle, label: &str) -> Result<(), String> {
|
|
86
|
+
app.get_webview_window(label)
|
|
87
|
+
.ok_or_else(|| format!("window '{label}' is not open"))?
|
|
88
|
+
.close()
|
|
89
|
+
.map_err(|error| error.to_string())
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
pub fn resize_window(app: &AppHandle, label: &str, width: u32, height: u32) -> Result<(), String> {
|
|
93
|
+
app.get_webview_window(label)
|
|
94
|
+
.ok_or_else(|| format!("window '{label}' is not open"))?
|
|
95
|
+
.set_size(PhysicalSize::new(width, height))
|
|
96
|
+
.map_err(|error| error.to_string())
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
pub fn position_widget(app: &AppHandle) {
|
|
100
|
+
let Some(widget) = app.get_webview_window("widget") else {
|
|
101
|
+
return;
|
|
102
|
+
};
|
|
103
|
+
let Ok(Some(monitor)) = widget.primary_monitor() else {
|
|
104
|
+
return;
|
|
105
|
+
};
|
|
106
|
+
let size = monitor.size();
|
|
107
|
+
let x = size.width.saturating_sub(190) as i32;
|
|
108
|
+
let _ = widget.set_position(PhysicalPosition::new(x, 40));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
pub fn execute_action(
|
|
112
|
+
config: &mint_core::MintConfig,
|
|
113
|
+
action: DesktopAction,
|
|
114
|
+
) -> Result<ActionResult, String> {
|
|
115
|
+
match action.kind.as_str() {
|
|
116
|
+
"none" => Ok(success("no action requested")),
|
|
117
|
+
"system_info" => Ok(success(&format!(
|
|
118
|
+
"os={} arch={} family={}",
|
|
119
|
+
std::env::consts::OS,
|
|
120
|
+
std::env::consts::ARCH,
|
|
121
|
+
std::env::consts::FAMILY
|
|
122
|
+
))),
|
|
123
|
+
"open_url" => {
|
|
124
|
+
if !(action.target.starts_with("https://")
|
|
125
|
+
|| action.target.starts_with("http://")
|
|
126
|
+
|| action.target.starts_with("file://"))
|
|
127
|
+
{
|
|
128
|
+
return Err("only http, https, and file URLs may be opened".into());
|
|
129
|
+
}
|
|
130
|
+
spawn_detached("xdg-open", &[&action.target])?;
|
|
131
|
+
Ok(success("opened URL"))
|
|
132
|
+
}
|
|
133
|
+
"search" => {
|
|
134
|
+
let query = action.target.trim();
|
|
135
|
+
if query.is_empty() {
|
|
136
|
+
return Err("search query is required".into());
|
|
137
|
+
}
|
|
138
|
+
let url = format!("https://www.google.com/search?q={}", encode_query(query));
|
|
139
|
+
spawn_detached("xdg-open", &[&url])?;
|
|
140
|
+
Ok(success("opened web search"))
|
|
141
|
+
}
|
|
142
|
+
"open_app" => {
|
|
143
|
+
let app = action.target.trim();
|
|
144
|
+
if app.is_empty()
|
|
145
|
+
|| !app
|
|
146
|
+
.chars()
|
|
147
|
+
.all(|char| char.is_ascii_alphanumeric() || matches!(char, '-' | '_' | '.'))
|
|
148
|
+
{
|
|
149
|
+
return Err("application name contains unsupported characters".into());
|
|
150
|
+
}
|
|
151
|
+
spawn_detached(app, &[])?;
|
|
152
|
+
Ok(success("opened application"))
|
|
153
|
+
}
|
|
154
|
+
"clipboard_write" => Err("clipboard actions are handled by the renderer".into()),
|
|
155
|
+
"mcp_tool" => mint_core::call_mcp_tool(config, &action.server, &action.target, action.args)
|
|
156
|
+
.map(|result| success(&result.to_string()))
|
|
157
|
+
.map_err(|error| error.to_string()),
|
|
158
|
+
"system_automation" => {
|
|
159
|
+
run_system_automation(&action.target, action.approved).map(|message| success(&message))
|
|
160
|
+
}
|
|
161
|
+
"create_folder" => create_folder(std::path::Path::new(&action.target), config)
|
|
162
|
+
.map(|path| success(&format!("created {}", path.display())))
|
|
163
|
+
.map_err(|error| error.to_string()),
|
|
164
|
+
"find_path" => {
|
|
165
|
+
let roots = action.args["roots"]
|
|
166
|
+
.as_array()
|
|
167
|
+
.map(|roots| {
|
|
168
|
+
roots
|
|
169
|
+
.iter()
|
|
170
|
+
.filter_map(Value::as_str)
|
|
171
|
+
.map(PathBuf::from)
|
|
172
|
+
.collect::<Vec<_>>()
|
|
173
|
+
})
|
|
174
|
+
.filter(|roots| !roots.is_empty())
|
|
175
|
+
.unwrap_or_else(|| {
|
|
176
|
+
let mut roots =
|
|
177
|
+
vec![std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))];
|
|
178
|
+
if let Some(home) = dirs::home_dir() {
|
|
179
|
+
roots.push(home);
|
|
180
|
+
}
|
|
181
|
+
roots
|
|
182
|
+
});
|
|
183
|
+
let limit = action.args["limit"].as_u64().unwrap_or(20).min(100) as usize;
|
|
184
|
+
serde_json::to_string(&find_paths(&action.target, &roots, limit, config))
|
|
185
|
+
.map(|message| success(&message))
|
|
186
|
+
.map_err(|error| error.to_string())
|
|
187
|
+
}
|
|
188
|
+
"learn_file" => KnowledgeStore::open_default()
|
|
189
|
+
.map_err(|error| error.to_string())?
|
|
190
|
+
.index_file(std::path::Path::new(&action.target), config)
|
|
191
|
+
.map(|chunks| success(&format!("indexed {chunks} knowledge chunks")))
|
|
192
|
+
.map_err(|error| error.to_string()),
|
|
193
|
+
other => Err(format!(
|
|
194
|
+
"desktop action '{other}' has not migrated to the allowlisted Rust executor"
|
|
195
|
+
)),
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
pub fn capture_screen() -> Result<String, String> {
|
|
200
|
+
capture_screen_bytes().map(|bytes| format!("data:image/png;base64,{}", STANDARD.encode(bytes)))
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
pub fn read_clipboard_image() -> Result<String, String> {
|
|
204
|
+
let output = Command::new("wl-paste").args(&["-t", "image/png"]).output();
|
|
205
|
+
|
|
206
|
+
if let Ok(out) = output {
|
|
207
|
+
if out.status.success() && !out.stdout.is_empty() {
|
|
208
|
+
return Ok(format!(
|
|
209
|
+
"data:image/png;base64,{}",
|
|
210
|
+
STANDARD.encode(&out.stdout)
|
|
211
|
+
));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let output = Command::new("xclip")
|
|
216
|
+
.args(&["-selection", "clipboard", "-t", "image/png", "-o"])
|
|
217
|
+
.output();
|
|
218
|
+
|
|
219
|
+
if let Ok(out) = output {
|
|
220
|
+
if out.status.success() && !out.stdout.is_empty() {
|
|
221
|
+
return Ok(format!(
|
|
222
|
+
"data:image/png;base64,{}",
|
|
223
|
+
STANDARD.encode(&out.stdout)
|
|
224
|
+
));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
Err("No image found in clipboard".into())
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
pub async fn translate_screen_region(
|
|
232
|
+
config: &mint_core::MintConfig,
|
|
233
|
+
rect: CaptureRect,
|
|
234
|
+
) -> Result<String, String> {
|
|
235
|
+
let bytes = capture_screen_bytes()?;
|
|
236
|
+
let image = image::load_from_memory(&bytes).map_err(|error| error.to_string())?;
|
|
237
|
+
if rect.width == 0 || rect.height == 0 || rect.x >= image.width() || rect.y >= image.height() {
|
|
238
|
+
return Err("screen capture rectangle is invalid".into());
|
|
239
|
+
}
|
|
240
|
+
let width = rect.width.min(image.width() - rect.x);
|
|
241
|
+
let height = rect.height.min(image.height() - rect.y);
|
|
242
|
+
let cropped = image.crop_imm(rect.x, rect.y, width, height);
|
|
243
|
+
let mut jpeg = std::io::Cursor::new(Vec::new());
|
|
244
|
+
cropped
|
|
245
|
+
.write_to(&mut jpeg, ImageFormat::Jpeg)
|
|
246
|
+
.map_err(|error| error.to_string())?;
|
|
247
|
+
let api_key = if config.api_key.trim().is_empty() {
|
|
248
|
+
std::env::var("GEMINI_API_KEY").unwrap_or_default()
|
|
249
|
+
} else {
|
|
250
|
+
config.api_key.clone()
|
|
251
|
+
};
|
|
252
|
+
if api_key.trim().is_empty() {
|
|
253
|
+
return Err("Gemini API key is required for live translation".into());
|
|
254
|
+
}
|
|
255
|
+
let value: Value = mint_core::HTTP_CLIENT.clone()
|
|
256
|
+
.post(format!(
|
|
257
|
+
"https://generativelanguage.googleapis.com/v1beta/models/{}:generateContent?key={api_key}",
|
|
258
|
+
config.gemini_model
|
|
259
|
+
))
|
|
260
|
+
.json(&json!({
|
|
261
|
+
"contents": [{
|
|
262
|
+
"role": "user",
|
|
263
|
+
"parts": [
|
|
264
|
+
{ "text": "Translate visible text in this image into Thai. Return only the translated text. If there is no readable text, return an empty string." },
|
|
265
|
+
{ "inlineData": { "mimeType": "image/jpeg", "data": STANDARD.encode(jpeg.into_inner()) } }
|
|
266
|
+
]
|
|
267
|
+
}]
|
|
268
|
+
}))
|
|
269
|
+
.send()
|
|
270
|
+
.await
|
|
271
|
+
.map_err(|error| error.to_string())?
|
|
272
|
+
.error_for_status()
|
|
273
|
+
.map_err(|error| error.to_string())?
|
|
274
|
+
.json()
|
|
275
|
+
.await
|
|
276
|
+
.map_err(|error| error.to_string())?;
|
|
277
|
+
value["candidates"][0]["content"]["parts"][0]["text"]
|
|
278
|
+
.as_str()
|
|
279
|
+
.map(str::to_owned)
|
|
280
|
+
.ok_or_else(|| "Gemini translation response did not include text".into())
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
pub(crate) fn capture_screen_bytes() -> Result<Vec<u8>, String> {
|
|
284
|
+
let path = std::env::temp_dir().join(format!("mint-screen-{}.png", std::process::id()));
|
|
285
|
+
let commands = [
|
|
286
|
+
("grim", vec![path_string(&path)]),
|
|
287
|
+
("gnome-screenshot", vec!["-f".into(), path_string(&path)]),
|
|
288
|
+
(
|
|
289
|
+
"spectacle",
|
|
290
|
+
vec!["-b".into(), "-n".into(), "-o".into(), path_string(&path)],
|
|
291
|
+
),
|
|
292
|
+
("scrot", vec![path_string(&path)]),
|
|
293
|
+
(
|
|
294
|
+
"import",
|
|
295
|
+
vec!["-window".into(), "root".into(), path_string(&path)],
|
|
296
|
+
),
|
|
297
|
+
];
|
|
298
|
+
let mut attempted = Vec::new();
|
|
299
|
+
for (program, args) in commands {
|
|
300
|
+
attempted.push(program);
|
|
301
|
+
let result = Command::new(program)
|
|
302
|
+
.args(&args)
|
|
303
|
+
.stdout(Stdio::null())
|
|
304
|
+
.stderr(Stdio::null())
|
|
305
|
+
.status();
|
|
306
|
+
if result.is_ok_and(|status| status.success()) && path.exists() {
|
|
307
|
+
let bytes = fs::read(&path).map_err(|error| error.to_string())?;
|
|
308
|
+
let _ = fs::remove_file(&path);
|
|
309
|
+
return Ok(bytes);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
Err(format!(
|
|
313
|
+
"screen capture requires one of these commands: {}",
|
|
314
|
+
attempted.join(", ")
|
|
315
|
+
))
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
pub fn integration_status(config: &mint_core::MintConfig) -> Value {
|
|
319
|
+
let mcp_servers = config
|
|
320
|
+
.extra
|
|
321
|
+
.get("mcpServers")
|
|
322
|
+
.and_then(Value::as_object)
|
|
323
|
+
.map(|servers| servers.keys().cloned().collect::<Vec<_>>())
|
|
324
|
+
.unwrap_or_default();
|
|
325
|
+
json!({
|
|
326
|
+
"automation": {
|
|
327
|
+
"supportedActions": ["open_url", "open_app", "search", "system_info", "system_automation", "find_path", "create_folder", "learn_file"],
|
|
328
|
+
"approvalRequired": true
|
|
329
|
+
},
|
|
330
|
+
"mcp": {
|
|
331
|
+
"configuredServers": mcp_servers,
|
|
332
|
+
"execution": "native-stdio"
|
|
333
|
+
},
|
|
334
|
+
"plugins": {
|
|
335
|
+
"migrated": ["desktop-actions", "dev_tools", "docker", "obsidian", "spotify", "system_metrics"]
|
|
336
|
+
}
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
pub fn emit_to_main(app: &AppHandle, event: &str, payload: impl Serialize + Clone) {
|
|
341
|
+
if let Some(main) = app.get_webview_window("main") {
|
|
342
|
+
let _ = main.emit(event, payload);
|
|
343
|
+
let _ = main.show();
|
|
344
|
+
let _ = main.set_focus();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
fn success(message: &str) -> ActionResult {
|
|
349
|
+
ActionResult {
|
|
350
|
+
success: true,
|
|
351
|
+
message: message.into(),
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
fn spawn_detached(program: &str, args: &[&str]) -> Result<(), String> {
|
|
356
|
+
Command::new(program)
|
|
357
|
+
.args(args)
|
|
358
|
+
.stdin(Stdio::null())
|
|
359
|
+
.stdout(Stdio::null())
|
|
360
|
+
.stderr(Stdio::null())
|
|
361
|
+
.spawn()
|
|
362
|
+
.map(|_| ())
|
|
363
|
+
.map_err(|error| format!("unable to start '{program}': {error}"))
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
fn path_string(path: &PathBuf) -> String {
|
|
367
|
+
path.to_string_lossy().into_owned()
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
fn encode_query(query: &str) -> String {
|
|
371
|
+
query
|
|
372
|
+
.bytes()
|
|
373
|
+
.map(|byte| match byte {
|
|
374
|
+
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
|
375
|
+
(byte as char).to_string()
|
|
376
|
+
}
|
|
377
|
+
b' ' => "+".into(),
|
|
378
|
+
_ => format!("%{byte:02X}"),
|
|
379
|
+
})
|
|
380
|
+
.collect()
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
#[cfg(test)]
|
|
384
|
+
mod tests {
|
|
385
|
+
use super::*;
|
|
386
|
+
|
|
387
|
+
#[test]
|
|
388
|
+
fn encodes_search_queries_for_urls() {
|
|
389
|
+
assert_eq!(encode_query("mint cli"), "mint+cli");
|
|
390
|
+
assert_eq!(encode_query("a/b"), "a%2Fb");
|
|
391
|
+
}
|
|
392
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
use std::io::{Read, Write};
|
|
2
|
+
|
|
3
|
+
use mint_core::MintConfig;
|
|
4
|
+
use serde_json::{Value, json};
|
|
5
|
+
|
|
6
|
+
#[cfg(unix)]
|
|
7
|
+
use std::os::unix::net::UnixStream;
|
|
8
|
+
|
|
9
|
+
pub fn set_activity(config: &MintConfig, instruction: &str) -> Result<String, String> {
|
|
10
|
+
let application_id = config
|
|
11
|
+
.extra
|
|
12
|
+
.get("discordApplicationId")
|
|
13
|
+
.and_then(Value::as_str)
|
|
14
|
+
.filter(|value| !value.trim().is_empty())
|
|
15
|
+
.ok_or("missing config value 'discordApplicationId'")?;
|
|
16
|
+
let input: Value = serde_json::from_str(instruction).unwrap_or_else(|_| {
|
|
17
|
+
json!({ "details": instruction.trim().is_empty().then_some("Using Mint Assistant").unwrap_or(instruction) })
|
|
18
|
+
});
|
|
19
|
+
let activity = json!({
|
|
20
|
+
"details": input["details"].as_str().unwrap_or("Using Mint Assistant"),
|
|
21
|
+
"state": input["state"].as_str().unwrap_or("Native Tauri desktop"),
|
|
22
|
+
"instance": false
|
|
23
|
+
});
|
|
24
|
+
send(application_id, activity)?;
|
|
25
|
+
Ok("Discord Rich Presence activity updated.".into())
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#[cfg(unix)]
|
|
29
|
+
fn send(application_id: &str, activity: Value) -> Result<(), String> {
|
|
30
|
+
let mut socket = connect()?;
|
|
31
|
+
write_frame(
|
|
32
|
+
&mut socket,
|
|
33
|
+
0,
|
|
34
|
+
&json!({ "v": 1, "client_id": application_id }),
|
|
35
|
+
)?;
|
|
36
|
+
let _ = read_frame(&mut socket)?;
|
|
37
|
+
write_frame(
|
|
38
|
+
&mut socket,
|
|
39
|
+
1,
|
|
40
|
+
&json!({
|
|
41
|
+
"cmd": "SET_ACTIVITY",
|
|
42
|
+
"args": { "pid": std::process::id(), "activity": activity },
|
|
43
|
+
"nonce": format!("mint-{}", std::process::id())
|
|
44
|
+
}),
|
|
45
|
+
)?;
|
|
46
|
+
let _ = read_frame(&mut socket)?;
|
|
47
|
+
Ok(())
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#[cfg(not(unix))]
|
|
51
|
+
fn send(_application_id: &str, _activity: Value) -> Result<(), String> {
|
|
52
|
+
Err("Discord Rich Presence is currently implemented for Unix desktop IPC only".into())
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#[cfg(unix)]
|
|
56
|
+
fn connect() -> Result<UnixStream, String> {
|
|
57
|
+
let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into());
|
|
58
|
+
(0..10)
|
|
59
|
+
.find_map(|index| UnixStream::connect(format!("{runtime}/discord-ipc-{index}")).ok())
|
|
60
|
+
.ok_or_else(|| "Discord IPC socket was not found; start Discord desktop first".into())
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
fn write_frame(stream: &mut impl Write, opcode: u32, payload: &Value) -> Result<(), String> {
|
|
64
|
+
let payload = serde_json::to_vec(payload).map_err(|error| error.to_string())?;
|
|
65
|
+
stream
|
|
66
|
+
.write_all(&opcode.to_le_bytes())
|
|
67
|
+
.and_then(|_| stream.write_all(&(payload.len() as u32).to_le_bytes()))
|
|
68
|
+
.and_then(|_| stream.write_all(&payload))
|
|
69
|
+
.map_err(|error| format!("unable to write Discord IPC frame: {error}"))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
fn read_frame(stream: &mut impl Read) -> Result<Value, String> {
|
|
73
|
+
let mut header = [0_u8; 8];
|
|
74
|
+
stream
|
|
75
|
+
.read_exact(&mut header)
|
|
76
|
+
.map_err(|error| format!("unable to read Discord IPC header: {error}"))?;
|
|
77
|
+
let length = u32::from_le_bytes(header[4..8].try_into().unwrap()) as usize;
|
|
78
|
+
let mut payload = vec![0; length];
|
|
79
|
+
stream
|
|
80
|
+
.read_exact(&mut payload)
|
|
81
|
+
.map_err(|error| format!("unable to read Discord IPC payload: {error}"))?;
|
|
82
|
+
serde_json::from_slice(&payload).map_err(|error| error.to_string())
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#[cfg(test)]
|
|
86
|
+
mod tests {
|
|
87
|
+
use super::*;
|
|
88
|
+
|
|
89
|
+
#[test]
|
|
90
|
+
fn writes_discord_rpc_frame() {
|
|
91
|
+
let mut bytes = Vec::new();
|
|
92
|
+
write_frame(&mut bytes, 1, &json!({ "ok": true })).unwrap();
|
|
93
|
+
assert_eq!(u32::from_le_bytes(bytes[0..4].try_into().unwrap()), 1);
|
|
94
|
+
assert_eq!(
|
|
95
|
+
u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize,
|
|
96
|
+
bytes.len() - 8
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
use std::{
|
|
2
|
+
fs,
|
|
3
|
+
net::{SocketAddr, TcpStream},
|
|
4
|
+
path::Path,
|
|
5
|
+
thread,
|
|
6
|
+
time::Duration,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
use serde_json::json;
|
|
10
|
+
use tauri::{AppHandle, Emitter, Manager};
|
|
11
|
+
|
|
12
|
+
pub fn start_system_events(app: AppHandle) {
|
|
13
|
+
thread::spawn(move || {
|
|
14
|
+
let mut last_battery = None;
|
|
15
|
+
let mut last_online = None;
|
|
16
|
+
loop {
|
|
17
|
+
let battery = battery_level(Path::new("/sys/class/power_supply"));
|
|
18
|
+
if let Some(level) = battery
|
|
19
|
+
.filter(|level| *level <= 20 && last_battery.is_none_or(|previous| previous > 20))
|
|
20
|
+
{
|
|
21
|
+
emit_notification(
|
|
22
|
+
&app,
|
|
23
|
+
json!({ "message": format!("Battery is low ({level}%). Please plug in your charger."), "type": "warning" }),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
last_battery = battery;
|
|
27
|
+
|
|
28
|
+
let online = network_online();
|
|
29
|
+
if last_online.is_some_and(|previous| previous != online) {
|
|
30
|
+
emit_notification(
|
|
31
|
+
&app,
|
|
32
|
+
json!({
|
|
33
|
+
"message": if online { "Internet connection restored." } else { "Internet connection lost." },
|
|
34
|
+
"type": "info"
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
last_online = Some(online);
|
|
39
|
+
thread::sleep(Duration::from_secs(60));
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
fn emit_notification(app: &AppHandle, payload: serde_json::Value) {
|
|
45
|
+
if let Some(main) = app.get_webview_window("main") {
|
|
46
|
+
let _ = main.emit("proactive-notification", payload);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
fn battery_level(root: &Path) -> Option<u8> {
|
|
51
|
+
fs::read_dir(root).ok()?.flatten().find_map(|entry| {
|
|
52
|
+
let name = entry.file_name().to_string_lossy().to_ascii_uppercase();
|
|
53
|
+
if !name.starts_with("BAT") {
|
|
54
|
+
return None;
|
|
55
|
+
}
|
|
56
|
+
fs::read_to_string(entry.path().join("capacity"))
|
|
57
|
+
.ok()?
|
|
58
|
+
.trim()
|
|
59
|
+
.parse()
|
|
60
|
+
.ok()
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
fn network_online() -> bool {
|
|
65
|
+
"1.1.1.1:53"
|
|
66
|
+
.parse::<SocketAddr>()
|
|
67
|
+
.ok()
|
|
68
|
+
.and_then(|address| TcpStream::connect_timeout(&address, Duration::from_secs(2)).ok())
|
|
69
|
+
.is_some()
|
|
70
|
+
}
|