@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,16 @@
|
|
|
1
|
+
use mint_core::MintConfig;
|
|
2
|
+
|
|
3
|
+
use crate::discord_rpc;
|
|
4
|
+
|
|
5
|
+
pub async fn execute_plugin(
|
|
6
|
+
config: &MintConfig,
|
|
7
|
+
name: &str,
|
|
8
|
+
instruction: &str,
|
|
9
|
+
) -> Result<String, String> {
|
|
10
|
+
match name {
|
|
11
|
+
"discord" => discord_rpc::set_activity(config, instruction),
|
|
12
|
+
other => mint_core::execute_native_plugin(config, other, instruction)
|
|
13
|
+
.await
|
|
14
|
+
.map_err(|error| error.to_string()),
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
use std::{
|
|
2
|
+
collections::BTreeMap,
|
|
3
|
+
fs,
|
|
4
|
+
path::PathBuf,
|
|
5
|
+
sync::{
|
|
6
|
+
LazyLock, Mutex,
|
|
7
|
+
atomic::{AtomicBool, Ordering},
|
|
8
|
+
},
|
|
9
|
+
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
|
13
|
+
use mint_core::{MintConfig, config_path, load_config};
|
|
14
|
+
use serde::{Deserialize, Serialize};
|
|
15
|
+
use serde_json::{Value, json};
|
|
16
|
+
use tauri::{AppHandle, Emitter, Manager};
|
|
17
|
+
|
|
18
|
+
use crate::desktop::capture_screen_bytes;
|
|
19
|
+
|
|
20
|
+
const DEFAULT_INTERVAL_SECONDS: u64 = 60;
|
|
21
|
+
const DEFAULT_COOLDOWN_SECONDS: u64 = 120;
|
|
22
|
+
const MAX_CONTEXT_HISTORY: usize = 20;
|
|
23
|
+
const PROMPT: &str = r#"You are Mint's desktop suggestion engine. Analyze the screenshot and return only JSON:
|
|
24
|
+
{"context":"short English description","message":"short Thai message or null","suggestions":[{"label":"1-3 words","action":{"type":"open_url|open_app|search|none","target":"..."}}]}
|
|
25
|
+
Return an empty suggestions array when there is no clear useful opportunity. Provide at most 4 suggestions. Never suggest shell commands."#;
|
|
26
|
+
|
|
27
|
+
static ENABLED: AtomicBool = AtomicBool::new(false);
|
|
28
|
+
static LAST_SUGGESTION: LazyLock<Mutex<Option<(Instant, String)>>> =
|
|
29
|
+
LazyLock::new(|| Mutex::new(None));
|
|
30
|
+
|
|
31
|
+
#[derive(Debug, Default, Serialize, Deserialize)]
|
|
32
|
+
#[serde(rename_all = "camelCase")]
|
|
33
|
+
struct BehaviorMemory {
|
|
34
|
+
#[serde(default)]
|
|
35
|
+
app_frequency: BTreeMap<String, u64>,
|
|
36
|
+
#[serde(default)]
|
|
37
|
+
context_history: Vec<BehaviorEntry>,
|
|
38
|
+
#[serde(default)]
|
|
39
|
+
last_updated: Option<String>,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
43
|
+
struct BehaviorEntry {
|
|
44
|
+
context: String,
|
|
45
|
+
time: String,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
pub fn set_enabled(enabled: bool) {
|
|
49
|
+
ENABLED.store(enabled, Ordering::Relaxed);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
pub fn record_behavior(context: &str) -> Result<(), String> {
|
|
53
|
+
let context = context.trim();
|
|
54
|
+
if context.is_empty() {
|
|
55
|
+
return Ok(());
|
|
56
|
+
}
|
|
57
|
+
let mut memory = load_behavior()?;
|
|
58
|
+
memory.context_history.insert(
|
|
59
|
+
0,
|
|
60
|
+
BehaviorEntry {
|
|
61
|
+
context: context.into(),
|
|
62
|
+
time: timestamp(),
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
memory.context_history.truncate(MAX_CONTEXT_HISTORY);
|
|
66
|
+
for app in [
|
|
67
|
+
"YouTube", "Chrome", "Firefox", "VS Code", "Spotify", "Terminal", "Google", "Discord",
|
|
68
|
+
"Slack", "Gmail", "GitHub", "Figma", "Notion",
|
|
69
|
+
] {
|
|
70
|
+
if context
|
|
71
|
+
.to_ascii_lowercase()
|
|
72
|
+
.contains(&app.to_ascii_lowercase())
|
|
73
|
+
{
|
|
74
|
+
*memory.app_frequency.entry(app.into()).or_default() += 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
memory.last_updated = Some(timestamp());
|
|
78
|
+
save_behavior(&memory)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
pub fn start_loop(app: AppHandle) {
|
|
82
|
+
tauri::async_runtime::spawn(async move {
|
|
83
|
+
loop {
|
|
84
|
+
if !ENABLED.load(Ordering::Relaxed) {
|
|
85
|
+
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
let config = match load_config() {
|
|
89
|
+
Ok(config) => config,
|
|
90
|
+
Err(_) => {
|
|
91
|
+
tokio::time::sleep(Duration::from_secs(DEFAULT_INTERVAL_SECONDS)).await;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
if let Ok(Some(suggestion)) = analyze(&config).await {
|
|
96
|
+
if let Some(context) = suggestion["context"].as_str() {
|
|
97
|
+
let _ = record_behavior(context);
|
|
98
|
+
}
|
|
99
|
+
if let Some(main) = app.get_webview_window("main") {
|
|
100
|
+
let _ = main.emit("proactive-suggestion", suggestion);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
tokio::time::sleep(Duration::from_secs(config_u64(
|
|
104
|
+
&config,
|
|
105
|
+
"proactiveInterval",
|
|
106
|
+
DEFAULT_INTERVAL_SECONDS,
|
|
107
|
+
)))
|
|
108
|
+
.await;
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async fn analyze(config: &MintConfig) -> Result<Option<Value>, String> {
|
|
114
|
+
let key = if config.api_key.trim().is_empty() {
|
|
115
|
+
std::env::var("GEMINI_API_KEY").unwrap_or_default()
|
|
116
|
+
} else {
|
|
117
|
+
config.api_key.clone()
|
|
118
|
+
};
|
|
119
|
+
if key.trim().is_empty() || cooling_down(config) {
|
|
120
|
+
return Ok(None);
|
|
121
|
+
}
|
|
122
|
+
let image = capture_screen_bytes()?;
|
|
123
|
+
let response: Value = mint_core::HTTP_CLIENT
|
|
124
|
+
.clone()
|
|
125
|
+
.post(format!(
|
|
126
|
+
"https://generativelanguage.googleapis.com/v1beta/models/{}:generateContent?key={key}",
|
|
127
|
+
config.gemini_model
|
|
128
|
+
))
|
|
129
|
+
.json(&json!({
|
|
130
|
+
"systemInstruction": { "parts": [{ "text": PROMPT }] },
|
|
131
|
+
"contents": [{ "role": "user", "parts": [
|
|
132
|
+
{ "text": format!("Behavior context: {}", behavior_summary()?) },
|
|
133
|
+
{ "inlineData": { "mimeType": "image/png", "data": STANDARD.encode(image) } }
|
|
134
|
+
]}],
|
|
135
|
+
"generationConfig": { "responseMimeType": "application/json" }
|
|
136
|
+
}))
|
|
137
|
+
.send()
|
|
138
|
+
.await
|
|
139
|
+
.map_err(|error| error.to_string())?
|
|
140
|
+
.error_for_status()
|
|
141
|
+
.map_err(|error| error.to_string())?
|
|
142
|
+
.json()
|
|
143
|
+
.await
|
|
144
|
+
.map_err(|error| error.to_string())?;
|
|
145
|
+
let Some(raw) = response["candidates"][0]["content"]["parts"][0]["text"].as_str() else {
|
|
146
|
+
return Ok(None);
|
|
147
|
+
};
|
|
148
|
+
let mut suggestion: Value = serde_json::from_str(raw).map_err(|error| error.to_string())?;
|
|
149
|
+
let has_message = suggestion["message"].as_str().is_some();
|
|
150
|
+
let Some(items) = suggestion["suggestions"].as_array_mut() else {
|
|
151
|
+
return Ok(None);
|
|
152
|
+
};
|
|
153
|
+
items.retain(|item| {
|
|
154
|
+
item["action"]["type"]
|
|
155
|
+
.as_str()
|
|
156
|
+
.is_some_and(|kind| matches!(kind, "open_url" | "open_app" | "search" | "none"))
|
|
157
|
+
});
|
|
158
|
+
items.truncate(4);
|
|
159
|
+
if !has_message || items.is_empty() {
|
|
160
|
+
return Ok(None);
|
|
161
|
+
}
|
|
162
|
+
let context = suggestion["context"]
|
|
163
|
+
.as_str()
|
|
164
|
+
.unwrap_or_default()
|
|
165
|
+
.to_owned();
|
|
166
|
+
let mut last = LAST_SUGGESTION.lock().map_err(|error| error.to_string())?;
|
|
167
|
+
if last
|
|
168
|
+
.as_ref()
|
|
169
|
+
.is_some_and(|(_, previous)| previous == &context)
|
|
170
|
+
{
|
|
171
|
+
return Ok(None);
|
|
172
|
+
}
|
|
173
|
+
*last = Some((Instant::now(), context));
|
|
174
|
+
Ok(Some(suggestion))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
fn cooling_down(config: &MintConfig) -> bool {
|
|
178
|
+
LAST_SUGGESTION
|
|
179
|
+
.lock()
|
|
180
|
+
.ok()
|
|
181
|
+
.and_then(|value| value.as_ref().map(|(time, _)| time.elapsed()))
|
|
182
|
+
.is_some_and(|elapsed| {
|
|
183
|
+
elapsed
|
|
184
|
+
< Duration::from_secs(config_u64(
|
|
185
|
+
config,
|
|
186
|
+
"proactiveCooldown",
|
|
187
|
+
DEFAULT_COOLDOWN_SECONDS,
|
|
188
|
+
))
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
fn behavior_summary() -> Result<String, String> {
|
|
193
|
+
let memory = load_behavior()?;
|
|
194
|
+
let recent = memory
|
|
195
|
+
.context_history
|
|
196
|
+
.iter()
|
|
197
|
+
.take(3)
|
|
198
|
+
.map(|entry| entry.context.as_str())
|
|
199
|
+
.collect::<Vec<_>>()
|
|
200
|
+
.join(" | ");
|
|
201
|
+
let mut apps = memory.app_frequency.into_iter().collect::<Vec<_>>();
|
|
202
|
+
apps.sort_by(|left, right| right.1.cmp(&left.1));
|
|
203
|
+
let apps = apps
|
|
204
|
+
.into_iter()
|
|
205
|
+
.take(5)
|
|
206
|
+
.map(|(app, count)| format!("{app} ({count}x)"))
|
|
207
|
+
.collect::<Vec<_>>()
|
|
208
|
+
.join(", ");
|
|
209
|
+
Ok(format!(
|
|
210
|
+
"Frequent apps: {apps}. Recent activities: {recent}."
|
|
211
|
+
))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
fn behavior_path() -> Result<PathBuf, String> {
|
|
215
|
+
Ok(config_path()
|
|
216
|
+
.map_err(|error| error.to_string())?
|
|
217
|
+
.with_file_name("behavior_memory.json"))
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
fn load_behavior() -> Result<BehaviorMemory, String> {
|
|
221
|
+
let path = behavior_path()?;
|
|
222
|
+
if !path.exists() {
|
|
223
|
+
return Ok(BehaviorMemory::default());
|
|
224
|
+
}
|
|
225
|
+
serde_json::from_str(&fs::read_to_string(&path).map_err(|error| error.to_string())?)
|
|
226
|
+
.map_err(|error| error.to_string())
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
fn save_behavior(memory: &BehaviorMemory) -> Result<(), String> {
|
|
230
|
+
let path = behavior_path()?;
|
|
231
|
+
let directory = path
|
|
232
|
+
.parent()
|
|
233
|
+
.ok_or_else(|| "behavior memory directory is unavailable".to_string())?;
|
|
234
|
+
fs::create_dir_all(directory).map_err(|error| error.to_string())?;
|
|
235
|
+
let raw = serde_json::to_string_pretty(memory).map_err(|error| error.to_string())?;
|
|
236
|
+
fs::write(path, format!("{raw}\n")).map_err(|error| error.to_string())
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
fn config_u64(config: &MintConfig, key: &str, default: u64) -> u64 {
|
|
240
|
+
config
|
|
241
|
+
.extra
|
|
242
|
+
.get(key)
|
|
243
|
+
.and_then(Value::as_u64)
|
|
244
|
+
.unwrap_or(default)
|
|
245
|
+
.max(5)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
fn timestamp() -> String {
|
|
249
|
+
SystemTime::now()
|
|
250
|
+
.duration_since(UNIX_EPOCH)
|
|
251
|
+
.unwrap_or_default()
|
|
252
|
+
.as_secs()
|
|
253
|
+
.to_string()
|
|
254
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
use std::process::{Command, Stdio};
|
|
2
|
+
|
|
3
|
+
use mint_core::load_config;
|
|
4
|
+
use serde::Serialize;
|
|
5
|
+
use serde_json::{Value, json};
|
|
6
|
+
|
|
7
|
+
#[derive(Debug, Serialize)]
|
|
8
|
+
#[serde(rename_all = "camelCase")]
|
|
9
|
+
pub struct SmartContext {
|
|
10
|
+
pub captured_at: String,
|
|
11
|
+
pub platform: &'static str,
|
|
12
|
+
pub host: String,
|
|
13
|
+
pub active_window: Option<Value>,
|
|
14
|
+
pub current_app: Option<Value>,
|
|
15
|
+
pub browser: Option<Value>,
|
|
16
|
+
pub selected_text: String,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
pub async fn smart_context() -> SmartContext {
|
|
20
|
+
let active_window = linux_active_window();
|
|
21
|
+
let current_app = active_window.as_ref().map(|window| {
|
|
22
|
+
json!({
|
|
23
|
+
"name": window["appName"],
|
|
24
|
+
"processName": window["processName"],
|
|
25
|
+
"pid": window["pid"]
|
|
26
|
+
})
|
|
27
|
+
});
|
|
28
|
+
SmartContext {
|
|
29
|
+
captured_at: unix_timestamp().to_string(),
|
|
30
|
+
platform: std::env::consts::OS,
|
|
31
|
+
host: hostname(),
|
|
32
|
+
browser: browser_context(&active_window).await,
|
|
33
|
+
active_window,
|
|
34
|
+
current_app,
|
|
35
|
+
selected_text: selected_text(),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async fn browser_context(active_window: &Option<Value>) -> Option<Value> {
|
|
40
|
+
let is_chromium = active_window
|
|
41
|
+
.as_ref()
|
|
42
|
+
.and_then(|window| window["appName"].as_str())
|
|
43
|
+
.map(str::to_ascii_lowercase)
|
|
44
|
+
.is_some_and(|name| {
|
|
45
|
+
["chrome", "chromium", "brave", "edge"]
|
|
46
|
+
.iter()
|
|
47
|
+
.any(|browser| name.contains(browser))
|
|
48
|
+
});
|
|
49
|
+
if is_chromium && let Some(context) = chromium_context().await {
|
|
50
|
+
return Some(context);
|
|
51
|
+
}
|
|
52
|
+
browser_extension_context().await
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async fn chromium_context() -> Option<Value> {
|
|
56
|
+
let endpoint = load_config()
|
|
57
|
+
.ok()
|
|
58
|
+
.and_then(|config| {
|
|
59
|
+
config
|
|
60
|
+
.extra
|
|
61
|
+
.get("browserDebugUrl")
|
|
62
|
+
.and_then(Value::as_str)
|
|
63
|
+
.map(str::to_owned)
|
|
64
|
+
})
|
|
65
|
+
.unwrap_or_else(|| "http://127.0.0.1:9222/json/list".into());
|
|
66
|
+
let pages: Value = mint_core::HTTP_CLIENT
|
|
67
|
+
.clone()
|
|
68
|
+
.get(endpoint)
|
|
69
|
+
.send()
|
|
70
|
+
.await
|
|
71
|
+
.ok()?
|
|
72
|
+
.json()
|
|
73
|
+
.await
|
|
74
|
+
.ok()?;
|
|
75
|
+
pages
|
|
76
|
+
.as_array()?
|
|
77
|
+
.iter()
|
|
78
|
+
.find(|page| page["type"] == "page")
|
|
79
|
+
.map(|page| {
|
|
80
|
+
json!({
|
|
81
|
+
"title": page["title"],
|
|
82
|
+
"url": page["url"],
|
|
83
|
+
"source": "chromium-remote-debug"
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async fn browser_extension_context() -> Option<Value> {
|
|
89
|
+
let endpoint = load_config()
|
|
90
|
+
.ok()
|
|
91
|
+
.and_then(|config| {
|
|
92
|
+
config
|
|
93
|
+
.extra
|
|
94
|
+
.get("browserExtensionContextUrl")
|
|
95
|
+
.and_then(Value::as_str)
|
|
96
|
+
.map(str::to_owned)
|
|
97
|
+
})
|
|
98
|
+
.unwrap_or_else(|| "http://127.0.0.1:3212/context".into());
|
|
99
|
+
if !(endpoint.starts_with("http://127.0.0.1:") || endpoint.starts_with("http://localhost:")) {
|
|
100
|
+
return None;
|
|
101
|
+
}
|
|
102
|
+
let context: Value = mint_core::HTTP_CLIENT
|
|
103
|
+
.clone()
|
|
104
|
+
.get(endpoint)
|
|
105
|
+
.send()
|
|
106
|
+
.await
|
|
107
|
+
.ok()?
|
|
108
|
+
.json()
|
|
109
|
+
.await
|
|
110
|
+
.ok()?;
|
|
111
|
+
Some(json!({
|
|
112
|
+
"title": context["title"],
|
|
113
|
+
"url": context["url"],
|
|
114
|
+
"source": "browser-extension"
|
|
115
|
+
}))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
pub fn run_system_automation(target: &str, approved: bool) -> Result<String, String> {
|
|
119
|
+
let (command, value) = target.split_once(':').unwrap_or((target, ""));
|
|
120
|
+
match command {
|
|
121
|
+
"volume" => {
|
|
122
|
+
let value = percent(value)?;
|
|
123
|
+
run_first(&[
|
|
124
|
+
("pactl", vec!["set-sink-volume", "@DEFAULT_SINK@", &value]),
|
|
125
|
+
("amixer", vec!["-D", "pulse", "sset", "Master", &value]),
|
|
126
|
+
])?;
|
|
127
|
+
Ok(format!("Volume set to {value}"))
|
|
128
|
+
}
|
|
129
|
+
"mute" => {
|
|
130
|
+
run_first(&[
|
|
131
|
+
("pactl", vec!["set-sink-mute", "@DEFAULT_SINK@", "toggle"]),
|
|
132
|
+
("amixer", vec!["-D", "pulse", "sset", "Master", "toggle"]),
|
|
133
|
+
])?;
|
|
134
|
+
Ok("Volume toggled".into())
|
|
135
|
+
}
|
|
136
|
+
"brightness" => {
|
|
137
|
+
let value = percent(value)?;
|
|
138
|
+
run_first(&[
|
|
139
|
+
("brightnessctl", vec!["set", &value]),
|
|
140
|
+
("xbacklight", vec!["-set", value.trim_end_matches('%')]),
|
|
141
|
+
])?;
|
|
142
|
+
Ok(format!("Brightness set to {value}"))
|
|
143
|
+
}
|
|
144
|
+
"minimize_all" => {
|
|
145
|
+
run("xdotool", &["key", "Super+d"])?;
|
|
146
|
+
Ok("Minimized all windows".into())
|
|
147
|
+
}
|
|
148
|
+
"sleep" => {
|
|
149
|
+
run("systemctl", &["suspend"])?;
|
|
150
|
+
Ok("Suspend requested".into())
|
|
151
|
+
}
|
|
152
|
+
"restart" | "shutdown" if !approved => {
|
|
153
|
+
Err(format!("'{command}' requires explicit approval"))
|
|
154
|
+
}
|
|
155
|
+
"restart" => {
|
|
156
|
+
run("systemctl", &["reboot"])?;
|
|
157
|
+
Ok("Restart requested".into())
|
|
158
|
+
}
|
|
159
|
+
"shutdown" => {
|
|
160
|
+
run("systemctl", &["poweroff"])?;
|
|
161
|
+
Ok("Shutdown requested".into())
|
|
162
|
+
}
|
|
163
|
+
_ => Err(format!("unsupported system automation command '{command}'")),
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
fn linux_active_window() -> Option<Value> {
|
|
168
|
+
let id = output("xdotool", &["getactivewindow"])?;
|
|
169
|
+
let title = output("xdotool", &["getwindowname", &id]).unwrap_or_default();
|
|
170
|
+
let pid = output("xdotool", &["getwindowpid", &id]).unwrap_or_default();
|
|
171
|
+
let process_name = output("ps", &["-p", &pid, "-o", "comm="]).unwrap_or_default();
|
|
172
|
+
Some(json!({
|
|
173
|
+
"id": id,
|
|
174
|
+
"title": title,
|
|
175
|
+
"appName": process_name,
|
|
176
|
+
"processName": process_name,
|
|
177
|
+
"pid": pid.parse::<u32>().ok(),
|
|
178
|
+
"platform": "linux"
|
|
179
|
+
}))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
fn selected_text() -> String {
|
|
183
|
+
[
|
|
184
|
+
("wl-paste", vec!["--primary", "--no-newline"]),
|
|
185
|
+
("xclip", vec!["-selection", "primary", "-out"]),
|
|
186
|
+
("xsel", vec!["--primary", "--output"]),
|
|
187
|
+
]
|
|
188
|
+
.into_iter()
|
|
189
|
+
.find_map(|(program, args)| output(program, &args))
|
|
190
|
+
.unwrap_or_default()
|
|
191
|
+
.chars()
|
|
192
|
+
.take(2000)
|
|
193
|
+
.collect()
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
fn hostname() -> String {
|
|
197
|
+
output("hostname", &[]).unwrap_or_else(|| "unknown".into())
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
fn unix_timestamp() -> u64 {
|
|
201
|
+
std::time::SystemTime::now()
|
|
202
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
203
|
+
.map(|duration| duration.as_secs())
|
|
204
|
+
.unwrap_or_default()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
fn percent(raw: &str) -> Result<String, String> {
|
|
208
|
+
raw.parse::<u8>()
|
|
209
|
+
.ok()
|
|
210
|
+
.filter(|value| *value <= 100)
|
|
211
|
+
.map(|value| format!("{value}%"))
|
|
212
|
+
.ok_or_else(|| "percentage must be between 0 and 100".into())
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
fn run_first(commands: &[(&str, Vec<&str>)]) -> Result<(), String> {
|
|
216
|
+
let mut errors = Vec::new();
|
|
217
|
+
for (program, args) in commands {
|
|
218
|
+
match run(program, args) {
|
|
219
|
+
Ok(()) => return Ok(()),
|
|
220
|
+
Err(error) => errors.push(error),
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
Err(errors.join(" | "))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
fn run(program: &str, args: &[&str]) -> Result<(), String> {
|
|
227
|
+
Command::new(program)
|
|
228
|
+
.args(args)
|
|
229
|
+
.stdin(Stdio::null())
|
|
230
|
+
.stdout(Stdio::null())
|
|
231
|
+
.stderr(Stdio::null())
|
|
232
|
+
.status()
|
|
233
|
+
.map_err(|error| format!("unable to run '{program}': {error}"))
|
|
234
|
+
.and_then(|status| {
|
|
235
|
+
status
|
|
236
|
+
.success()
|
|
237
|
+
.then_some(())
|
|
238
|
+
.ok_or_else(|| format!("'{program}' exited with {status}"))
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
fn output(program: &str, args: &[&str]) -> Option<String> {
|
|
243
|
+
Command::new(program)
|
|
244
|
+
.args(args)
|
|
245
|
+
.output()
|
|
246
|
+
.ok()
|
|
247
|
+
.filter(|output| output.status.success())
|
|
248
|
+
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned())
|
|
249
|
+
.filter(|output| !output.is_empty())
|
|
250
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
use mint_core::MintConfig;
|
|
2
|
+
use serde::Serialize;
|
|
3
|
+
use tauri::{AppHandle, Runtime};
|
|
4
|
+
use tauri_plugin_updater::UpdaterExt;
|
|
5
|
+
|
|
6
|
+
#[derive(Debug, Serialize)]
|
|
7
|
+
#[serde(rename_all = "camelCase")]
|
|
8
|
+
pub struct UpdateChannelStatus {
|
|
9
|
+
pub current_version: &'static str,
|
|
10
|
+
pub configured: bool,
|
|
11
|
+
pub endpoint: String,
|
|
12
|
+
pub public_key_configured: bool,
|
|
13
|
+
pub automatic_install: bool,
|
|
14
|
+
pub message: &'static str,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
#[derive(Debug, Serialize)]
|
|
18
|
+
#[serde(rename_all = "camelCase")]
|
|
19
|
+
pub struct AvailableUpdate {
|
|
20
|
+
pub current_version: String,
|
|
21
|
+
pub version: String,
|
|
22
|
+
pub notes: Option<String>,
|
|
23
|
+
pub available: bool,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
pub fn status(config: &MintConfig) -> UpdateChannelStatus {
|
|
27
|
+
let endpoint = string_value(config, "updaterEndpoint");
|
|
28
|
+
let public_key_configured = !string_value(config, "updaterPublicKey").trim().is_empty();
|
|
29
|
+
UpdateChannelStatus {
|
|
30
|
+
current_version: env!("CARGO_PKG_VERSION"),
|
|
31
|
+
configured: !endpoint.trim().is_empty() && public_key_configured,
|
|
32
|
+
endpoint,
|
|
33
|
+
public_key_configured,
|
|
34
|
+
automatic_install: false,
|
|
35
|
+
message: "Signed Tauri update channel status only; automatic installation is disabled.",
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
pub async fn check<R: Runtime>(
|
|
40
|
+
app: &AppHandle<R>,
|
|
41
|
+
config: &MintConfig,
|
|
42
|
+
) -> Result<AvailableUpdate, String> {
|
|
43
|
+
let updater = updater(app, config)?;
|
|
44
|
+
let current_version = env!("CARGO_PKG_VERSION").to_owned();
|
|
45
|
+
match updater.check().await.map_err(|error| error.to_string())? {
|
|
46
|
+
Some(update) => Ok(AvailableUpdate {
|
|
47
|
+
current_version,
|
|
48
|
+
version: update.version.clone(),
|
|
49
|
+
notes: update.body.clone(),
|
|
50
|
+
available: true,
|
|
51
|
+
}),
|
|
52
|
+
None => Ok(AvailableUpdate {
|
|
53
|
+
version: current_version.clone(),
|
|
54
|
+
current_version,
|
|
55
|
+
notes: None,
|
|
56
|
+
available: false,
|
|
57
|
+
}),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
pub async fn install<R: Runtime>(
|
|
62
|
+
app: &AppHandle<R>,
|
|
63
|
+
config: &MintConfig,
|
|
64
|
+
approved: bool,
|
|
65
|
+
) -> Result<String, String> {
|
|
66
|
+
require_install_approval(approved)?;
|
|
67
|
+
let Some(update) = updater(app, config)?
|
|
68
|
+
.check()
|
|
69
|
+
.await
|
|
70
|
+
.map_err(|error| error.to_string())?
|
|
71
|
+
else {
|
|
72
|
+
return Ok("Mint is already up to date.".into());
|
|
73
|
+
};
|
|
74
|
+
let version = update.version.clone();
|
|
75
|
+
update
|
|
76
|
+
.download_and_install(|_, _| {}, || {})
|
|
77
|
+
.await
|
|
78
|
+
.map_err(|error| error.to_string())?;
|
|
79
|
+
Ok(format!(
|
|
80
|
+
"Installed Mint {version}. Restart Mint to use the new version."
|
|
81
|
+
))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
fn require_install_approval(approved: bool) -> Result<(), String> {
|
|
85
|
+
approved
|
|
86
|
+
.then_some(())
|
|
87
|
+
.ok_or_else(|| "update installation requires explicit user approval".into())
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
fn updater<R: Runtime>(
|
|
91
|
+
app: &AppHandle<R>,
|
|
92
|
+
config: &MintConfig,
|
|
93
|
+
) -> Result<tauri_plugin_updater::Updater, String> {
|
|
94
|
+
let endpoint = string_value(config, "updaterEndpoint");
|
|
95
|
+
let public_key = string_value(config, "updaterPublicKey");
|
|
96
|
+
if endpoint.trim().is_empty() || public_key.trim().is_empty() {
|
|
97
|
+
return Err(
|
|
98
|
+
"configure updaterEndpoint and updaterPublicKey before checking updates".into(),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
app.updater_builder()
|
|
102
|
+
.pubkey(public_key)
|
|
103
|
+
.endpoints(vec![
|
|
104
|
+
endpoint
|
|
105
|
+
.parse()
|
|
106
|
+
.map_err(|error| format!("invalid updater endpoint: {error}"))?,
|
|
107
|
+
])
|
|
108
|
+
.map_err(|error| error.to_string())?
|
|
109
|
+
.build()
|
|
110
|
+
.map_err(|error| error.to_string())
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
fn string_value(config: &MintConfig, key: &str) -> String {
|
|
114
|
+
config
|
|
115
|
+
.extra
|
|
116
|
+
.get(key)
|
|
117
|
+
.and_then(serde_json::Value::as_str)
|
|
118
|
+
.unwrap_or_default()
|
|
119
|
+
.to_owned()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#[cfg(test)]
|
|
123
|
+
mod tests {
|
|
124
|
+
use super::*;
|
|
125
|
+
|
|
126
|
+
#[test]
|
|
127
|
+
fn requires_endpoint_and_public_key_without_enabling_installation() {
|
|
128
|
+
let mut config = MintConfig::default();
|
|
129
|
+
config.extra.insert(
|
|
130
|
+
"updaterEndpoint".into(),
|
|
131
|
+
"https://updates.example.com/latest.json".into(),
|
|
132
|
+
);
|
|
133
|
+
config
|
|
134
|
+
.extra
|
|
135
|
+
.insert("updaterPublicKey".into(), "RWQexample".into());
|
|
136
|
+
let status = status(&config);
|
|
137
|
+
assert!(status.configured);
|
|
138
|
+
assert!(!status.automatic_install);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#[test]
|
|
142
|
+
fn rejects_install_without_explicit_approval() {
|
|
143
|
+
assert_eq!(
|
|
144
|
+
require_install_approval(false).unwrap_err(),
|
|
145
|
+
"update installation requires explicit user approval"
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|