@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.
Files changed (35) hide show
  1. package/.github/workflows/ci.yml +14 -0
  2. package/Cargo.lock +3 -3
  3. package/Cargo.toml +1 -1
  4. package/bin/mint +0 -0
  5. package/package.json +11 -3
  6. package/src/renderer/index-web.html +1 -1
  7. package/src/renderer/index.html +1 -1
  8. package/src/renderer/src/components/DashboardSidebar.tsx +2 -2
  9. package/src/renderer/src/components/MintDashboard.tsx +1 -1
  10. package/src/renderer/src-web/components/ChatPanel.tsx +1 -1
  11. package/src/renderer/src-web/components/DashboardSidebar.tsx +2 -2
  12. package/src/renderer/src-web/components/MintDashboard.tsx +1 -1
  13. package/src-tauri/Cargo.toml +29 -0
  14. package/src-tauri/build.rs +3 -0
  15. package/src-tauri/capabilities/main.json +7 -0
  16. package/src-tauri/gen/schemas/acl-manifests.json +1 -0
  17. package/src-tauri/gen/schemas/capabilities.json +1 -0
  18. package/src-tauri/gen/schemas/desktop-schema.json +2412 -0
  19. package/src-tauri/gen/schemas/linux-schema.json +2412 -0
  20. package/src-tauri/src/browser.rs +141 -0
  21. package/src-tauri/src/desktop.rs +392 -0
  22. package/src-tauri/src/discord_rpc.rs +99 -0
  23. package/src-tauri/src/events.rs +70 -0
  24. package/src-tauri/src/headless.rs +222 -0
  25. package/src-tauri/src/integrations.rs +126 -0
  26. package/src-tauri/src/lib.rs +1033 -0
  27. package/src-tauri/src/main.rs +3 -0
  28. package/src-tauri/src/plugins.rs +16 -0
  29. package/src-tauri/src/proactive.rs +254 -0
  30. package/src-tauri/src/system.rs +250 -0
  31. package/src-tauri/src/updater.rs +148 -0
  32. package/src-tauri/src/webhooks.rs +255 -0
  33. package/src-tauri/src/workflows.rs +70 -0
  34. package/src-tauri/tauri.conf.json +48 -0
  35. package/tsconfig.json +1 -1
@@ -0,0 +1,3 @@
1
+ fn main() {
2
+ mint_desktop_lib::run()
3
+ }
@@ -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
+ }