@pheem49/mint 1.5.5 → 1.6.1

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 (222) hide show
  1. package/.codex +0 -0
  2. package/.github/FUNDING.yml +2 -0
  3. package/.github/workflows/ci.yml +45 -0
  4. package/.github/workflows/release.yml +79 -0
  5. package/Cargo.lock +5792 -0
  6. package/Cargo.toml +32 -0
  7. package/README.md +387 -353
  8. package/assets/icon.png +0 -0
  9. package/bin/mint +0 -0
  10. package/crates/mint-cli/Cargo.toml +23 -0
  11. package/crates/mint-cli/src/agent.rs +851 -0
  12. package/crates/mint-cli/src/gmail.rs +216 -0
  13. package/crates/mint-cli/src/image.rs +142 -0
  14. package/crates/mint-cli/src/main.rs +2837 -0
  15. package/crates/mint-cli/src/mcp.rs +63 -0
  16. package/crates/mint-cli/src/onboard.rs +1149 -0
  17. package/crates/mint-cli/src/setup.rs +390 -0
  18. package/crates/mint-cli/src/skills.rs +8 -0
  19. package/crates/mint-cli/src/updater.rs +279 -0
  20. package/crates/mint-core/Cargo.toml +22 -0
  21. package/crates/mint-core/src/agent_loop.rs +94 -0
  22. package/crates/mint-core/src/api_server.rs +991 -0
  23. package/crates/mint-core/src/channels.rs +248 -0
  24. package/crates/mint-core/src/chat.rs +895 -0
  25. package/crates/mint-core/src/code_tools.rs +729 -0
  26. package/crates/mint-core/src/config.rs +368 -0
  27. package/crates/mint-core/src/files.rs +159 -0
  28. package/crates/mint-core/src/knowledge.rs +541 -0
  29. package/crates/mint-core/src/lib.rs +84 -0
  30. package/crates/mint-core/src/mcp.rs +273 -0
  31. package/crates/mint-core/src/memory.rs +673 -0
  32. package/crates/mint-core/src/orchestration.rs +2157 -0
  33. package/crates/mint-core/src/pictures.rs +314 -0
  34. package/crates/mint-core/src/plugins.rs +727 -0
  35. package/crates/mint-core/src/safety.rs +416 -0
  36. package/crates/mint-core/src/semantic.rs +254 -0
  37. package/crates/mint-core/src/shell.rs +317 -0
  38. package/crates/mint-core/src/skills.rs +71 -0
  39. package/crates/mint-core/src/symbols.rs +157 -0
  40. package/crates/mint-core/src/tasks.rs +308 -0
  41. package/crates/mint-core/src/tts.rs +92 -0
  42. package/crates/mint-core/src/weather.rs +93 -0
  43. package/crates/mint-core/src/web_search.rs +200 -0
  44. package/crates/mint-core/src/workflows.rs +81 -0
  45. package/crates/mint-core/tests/mcp_stdio.rs +45 -0
  46. package/crates/mint-core/tests/memory_persistence.rs +172 -0
  47. package/crates/mint-core/tests/pictures_storage.rs +14 -0
  48. package/crates/mint-core/tests/task_lifecycle.rs +87 -0
  49. package/package.json +35 -99
  50. package/src/bin/index.js +16 -0
  51. package/src/renderer/index-web.html +17 -0
  52. package/src/renderer/index.html +17 -0
  53. package/src/renderer/public/Live2DCubismCore.js +9 -0
  54. package/src/renderer/public/assets/icon.png +0 -0
  55. package/src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.model3.json +36 -0
  56. package/src/renderer/src/App.tsx +33 -0
  57. package/src/renderer/src/calculator.ts +47 -0
  58. package/src/renderer/src/components/ChatPanel.tsx +1598 -0
  59. package/src/renderer/src/components/DashboardSidebar.tsx +358 -0
  60. package/src/renderer/src/components/Live2DStage.tsx +374 -0
  61. package/src/renderer/src/components/MintDashboard.tsx +950 -0
  62. package/src/renderer/src/components/ModelPanel.tsx +154 -0
  63. package/src/renderer/src/components/PicturesLibrary.tsx +46 -0
  64. package/src/renderer/src/components/ProactiveGlow.tsx +19 -0
  65. package/src/renderer/src/components/ScreenPicker.tsx +579 -0
  66. package/src/renderer/src/components/SettingsWindow.tsx +1467 -0
  67. package/src/renderer/src/components/SpotlightWindow.tsx +280 -0
  68. package/src/renderer/src/components/WidgetWindow.tsx +36 -0
  69. package/src/renderer/src/components/WorkspacePanel.tsx +268 -0
  70. package/src/{UI → renderer/src/css}/settings.css +69 -16
  71. package/src/renderer/src/css/spotlight.css +113 -0
  72. package/src/renderer/src/css/styles.css +3722 -0
  73. package/src/renderer/src/css/widget.css +185 -0
  74. package/src/renderer/src/env.d.ts +116 -0
  75. package/src/renderer/src/index.css +379 -0
  76. package/src/renderer/src/main.tsx +13 -0
  77. package/src/renderer/src/tauri.ts +996 -0
  78. package/src/renderer/src-web/App.tsx +25 -0
  79. package/src/renderer/src-web/calculator.ts +47 -0
  80. package/src/renderer/src-web/components/ChatPanel.tsx +1662 -0
  81. package/src/renderer/src-web/components/DashboardSidebar.tsx +242 -0
  82. package/src/renderer/src-web/components/MintDashboard.tsx +763 -0
  83. package/src/renderer/src-web/components/PicturesLibrary.tsx +73 -0
  84. package/src/renderer/src-web/components/SettingsWindow.tsx +1500 -0
  85. package/src/renderer/src-web/css/settings.css +1100 -0
  86. package/src/{UI → renderer/src-web/css}/spotlight.css +4 -4
  87. package/src/{UI → renderer/src-web/css}/styles.css +1055 -159
  88. package/src/{UI → renderer/src-web/css}/widget.css +2 -2
  89. package/src/renderer/src-web/env.d.ts +107 -0
  90. package/src/renderer/src-web/index.css +379 -0
  91. package/src/renderer/src-web/main.tsx +13 -0
  92. package/src/renderer/src-web/tauri.ts +983 -0
  93. package/tsconfig.json +30 -0
  94. package/vite.config.ts +33 -0
  95. package/vite.config.web.ts +51 -0
  96. package/GUIDE_TH.md +0 -125
  97. package/assets/Agent_Mint.png +0 -0
  98. package/assets/CLI_Screen.png +0 -0
  99. package/assets/Settings.png +0 -0
  100. package/benchmark_ai.js +0 -71
  101. package/install.ps1 +0 -64
  102. package/install.sh +0 -54
  103. package/main.js +0 -139
  104. package/mint-cli-logic.js +0 -3
  105. package/mint-cli.js +0 -410
  106. package/models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.model3.json +0 -47
  107. package/models/Shiroko_Model/Shiroko//342/232/241/351/253/230/344/272/256/342/232/241/344/275/277/347/224/250/346/225/231/347/250/213/344/270/216/346/263/250/346/204/217/344/272/213/351/241/271.txt +0 -23
  108. package/preload-picker.js +0 -11
  109. package/preload-settings.js +0 -11
  110. package/preload.js +0 -41
  111. package/scripts/install_linux_desktop_entry.js +0 -48
  112. package/src/AI_Brain/Gemini_API.js +0 -813
  113. package/src/AI_Brain/agent_orchestrator.js +0 -73
  114. package/src/AI_Brain/autonomous_brain.js +0 -179
  115. package/src/AI_Brain/behavior_memory.js +0 -135
  116. package/src/AI_Brain/headless_agent.js +0 -143
  117. package/src/AI_Brain/knowledge_base.js +0 -349
  118. package/src/AI_Brain/memory_store.js +0 -662
  119. package/src/AI_Brain/proactive_engine.js +0 -172
  120. package/src/AI_Brain/provider_adapter.js +0 -365
  121. package/src/Automation_Layer/browser_automation.js +0 -149
  122. package/src/Automation_Layer/file_operations.js +0 -286
  123. package/src/Automation_Layer/open_app.js +0 -85
  124. package/src/Automation_Layer/open_website.js +0 -38
  125. package/src/CLI/approval_handler.js +0 -47
  126. package/src/CLI/chat_router.js +0 -247
  127. package/src/CLI/chat_ui.js +0 -1159
  128. package/src/CLI/cli_colors.js +0 -115
  129. package/src/CLI/cli_formatters.js +0 -94
  130. package/src/CLI/code_agent.js +0 -1667
  131. package/src/CLI/code_session_memory.js +0 -62
  132. package/src/CLI/gmail_auth.js +0 -210
  133. package/src/CLI/image_input.js +0 -90
  134. package/src/CLI/intent_detectors.js +0 -181
  135. package/src/CLI/interactive_chat.js +0 -658
  136. package/src/CLI/list_features.js +0 -64
  137. package/src/CLI/onboarding.js +0 -416
  138. package/src/CLI/repo_summarizer.js +0 -282
  139. package/src/CLI/semantic_code_search.js +0 -312
  140. package/src/CLI/skill_manager.js +0 -41
  141. package/src/CLI/slash_command_handler.js +0 -418
  142. package/src/CLI/symbol_indexer.js +0 -231
  143. package/src/CLI/updater.js +0 -230
  144. package/src/CLI/workspace_manager.js +0 -90
  145. package/src/Channels/brave_search_bridge.js +0 -35
  146. package/src/Channels/discord_bridge.js +0 -66
  147. package/src/Channels/google_search_bridge.js +0 -38
  148. package/src/Channels/line_bridge.js +0 -60
  149. package/src/Channels/slack_bridge.js +0 -48
  150. package/src/Channels/telegram_bridge.js +0 -41
  151. package/src/Channels/whatsapp_bridge.js +0 -57
  152. package/src/Command_Parser/parser.js +0 -45
  153. package/src/Plugins/dev_tools.js +0 -41
  154. package/src/Plugins/discord.js +0 -20
  155. package/src/Plugins/docker.js +0 -47
  156. package/src/Plugins/gmail.js +0 -251
  157. package/src/Plugins/google_calendar.js +0 -252
  158. package/src/Plugins/mcp_manager.js +0 -95
  159. package/src/Plugins/notion.js +0 -256
  160. package/src/Plugins/obsidian.js +0 -54
  161. package/src/Plugins/plugin_manager.js +0 -81
  162. package/src/Plugins/spotify.js +0 -173
  163. package/src/Plugins/system_metrics.js +0 -31
  164. package/src/Plugins/system_monitor.js +0 -72
  165. package/src/System/action_executor.js +0 -178
  166. package/src/System/bridge_manager.js +0 -76
  167. package/src/System/chat_history_manager.js +0 -83
  168. package/src/System/config_manager.js +0 -194
  169. package/src/System/custom_workflows.js +0 -163
  170. package/src/System/daemon_manager.js +0 -67
  171. package/src/System/google_tts_urls.js +0 -51
  172. package/src/System/granular_automation.js +0 -157
  173. package/src/System/ipc_handlers.js +0 -332
  174. package/src/System/notifications.js +0 -23
  175. package/src/System/optional_require.js +0 -23
  176. package/src/System/picture_store.js +0 -109
  177. package/src/System/proactive_loop.js +0 -153
  178. package/src/System/safety_manager.js +0 -273
  179. package/src/System/sandbox_runner.js +0 -182
  180. package/src/System/screen_capture.js +0 -175
  181. package/src/System/smart_context.js +0 -227
  182. package/src/System/system_automation.js +0 -162
  183. package/src/System/system_events.js +0 -79
  184. package/src/System/system_info.js +0 -125
  185. package/src/System/task_manager.js +0 -222
  186. package/src/System/tool_registry.js +0 -293
  187. package/src/System/window_manager.js +0 -220
  188. package/src/UI/floating.css +0 -80
  189. package/src/UI/floating.html +0 -17
  190. package/src/UI/floating.js +0 -67
  191. package/src/UI/live2d_manager.js +0 -600
  192. package/src/UI/preload-floating.js +0 -7
  193. package/src/UI/preload-spotlight.js +0 -11
  194. package/src/UI/preload-widget.js +0 -5
  195. package/src/UI/proactive-glow.html +0 -42
  196. package/src/UI/renderer.js +0 -2127
  197. package/src/UI/screenPicker.html +0 -214
  198. package/src/UI/screenPicker.js +0 -262
  199. package/src/UI/settings.html +0 -577
  200. package/src/UI/settings.js +0 -770
  201. package/src/UI/spotlight.html +0 -23
  202. package/src/UI/spotlight.js +0 -185
  203. package/src/UI/widget.html +0 -29
  204. package/src/UI/widget.js +0 -10
  205. /package/{models → src/renderer/public/models}/Shiroko_Model/Shiroko/Shiroko_Core/72d86db84cfa9730b894c241fd24c0db.png +0 -0
  206. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//345/233/264/350/243/231.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/apron.exp3.json} +0 -0
  207. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//347/214/253/345/222/252/346/273/244/351/225/234.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/catfilter.exp3.json} +0 -0
  208. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//347/202/271/344/270/200/344/270/213.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/click.exp3.json} +0 -0
  209. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//345/221/206/347/214/253.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/dazed.exp3.json} +0 -0
  210. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//345/221/206/347/214/253/347/234/274/347/217/240/346/221/207/346/231/203.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/dazedeyes.exp3.json} +0 -0
  211. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//347/234/274/351/225/234.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/glasses.exp3.json} +0 -0
  212. /package/{models → src/renderer/public/models}/Shiroko_Model/Shiroko/Shiroko_Core/items_pinned_to_model.json +0 -0
  213. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//346/213/277/347/254/224.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/pen.exp3.json} +0 -0
  214. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//346/213/215/347/205/247.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/photo.exp3.json} +0 -0
  215. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_00.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_00.png} +0 -0
  216. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_01.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_01.png} +0 -0
  217. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_02.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_02.png} +0 -0
  218. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_03.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_03.png} +0 -0
  219. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.cdi3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.cdi3.json} +0 -0
  220. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.moc3" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.moc3} +0 -0
  221. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.physics3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.physics3.json} +0 -0
  222. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.vtube.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.vtube.json} +0 -0
@@ -0,0 +1,216 @@
1
+ use std::{
2
+ collections::BTreeMap,
3
+ io::{Read, Write},
4
+ net::TcpListener,
5
+ process::{Command, Stdio},
6
+ thread,
7
+ time::Duration,
8
+ time::{SystemTime, UNIX_EPOCH},
9
+ };
10
+
11
+ use anyhow::{Context, Result, anyhow, bail};
12
+ use mint_core::{MintConfig, load_config, save_config};
13
+ use serde_json::Value;
14
+ use sha2::{Digest, Sha256};
15
+
16
+ const AUTH_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth";
17
+ const TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
18
+ const SCOPES: &str =
19
+ "https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.compose";
20
+
21
+ pub async fn auth(no_open: bool, port: u16) -> Result<()> {
22
+ let mut config = load_config()?;
23
+ let client_id = config_string(&config, "gmailClientId")?;
24
+ let client_secret = config_string(&config, "gmailClientSecret")?;
25
+ let listener = TcpListener::bind(("127.0.0.1", port))?;
26
+ let actual_port = listener.local_addr()?.port();
27
+ let redirect = format!("http://127.0.0.1:{actual_port}/oauth2callback");
28
+ let state = state_token();
29
+ let url = format!(
30
+ "{AUTH_URL}?client_id={}&redirect_uri={}&response_type=code&scope={}&access_type=offline&prompt=consent&state={}",
31
+ encode(&client_id),
32
+ encode(&redirect),
33
+ encode(SCOPES),
34
+ encode(&state)
35
+ );
36
+ println!("Open this Google OAuth consent link for Gmail:\n{url}\n");
37
+ if !no_open {
38
+ open_browser(&url)?;
39
+ }
40
+ println!("Waiting for Gmail OAuth callback on {redirect} ...");
41
+ let code = wait_for_code(listener, &state)?;
42
+ let token: Value = mint_core::HTTP_CLIENT
43
+ .clone()
44
+ .post(TOKEN_URL)
45
+ .form(&[
46
+ ("client_id", client_id.as_str()),
47
+ ("client_secret", client_secret.as_str()),
48
+ ("code", code.as_str()),
49
+ ("redirect_uri", redirect.as_str()),
50
+ ("grant_type", "authorization_code"),
51
+ ])
52
+ .send()
53
+ .await?
54
+ .error_for_status()?
55
+ .json()
56
+ .await?;
57
+ let refresh = token["refresh_token"]
58
+ .as_str()
59
+ .ok_or_else(|| anyhow!("Google did not return a refresh token; run Gmail auth again"))?;
60
+ config
61
+ .extra
62
+ .insert("gmailRefreshToken".into(), Value::String(refresh.into()));
63
+ config
64
+ .extra
65
+ .insert("pluginGmailEnabled".into(), Value::Bool(true));
66
+ save_config(&config)?;
67
+ println!("Gmail OAuth refresh token saved.");
68
+ Ok(())
69
+ }
70
+
71
+ fn wait_for_code(listener: TcpListener, expected_state: &str) -> Result<String> {
72
+ listener.set_nonblocking(true)?;
73
+ let deadline = std::time::Instant::now() + Duration::from_secs(180);
74
+ let mut stream = loop {
75
+ match listener.accept() {
76
+ Ok((stream, _)) => break stream,
77
+ Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
78
+ if std::time::Instant::now() >= deadline {
79
+ bail!("timed out waiting for Gmail authorization callback");
80
+ }
81
+ thread::sleep(Duration::from_millis(100));
82
+ }
83
+ Err(error) => return Err(error.into()),
84
+ }
85
+ };
86
+ let mut bytes = [0_u8; 8192];
87
+ let read = stream.read(&mut bytes)?;
88
+ let request = String::from_utf8_lossy(&bytes[..read]);
89
+ let target = request
90
+ .lines()
91
+ .next()
92
+ .and_then(|line| line.split_whitespace().nth(1))
93
+ .ok_or_else(|| anyhow!("invalid OAuth callback request"))?;
94
+ let (_, query) = target
95
+ .split_once('?')
96
+ .ok_or_else(|| anyhow!("OAuth callback did not contain query parameters"))?;
97
+ let values = query
98
+ .split('&')
99
+ .filter_map(|part| part.split_once('='))
100
+ .map(|(key, value)| (key.to_owned(), decode(value)))
101
+ .collect::<BTreeMap<_, _>>();
102
+ let response = if values
103
+ .get("state")
104
+ .is_some_and(|state| state == expected_state)
105
+ && values.contains_key("code")
106
+ {
107
+ "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n\r\n<h1>Gmail connected</h1><p>You can close this window and return to Mint.</p>"
108
+ } else {
109
+ "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\nInvalid Gmail authorization response."
110
+ };
111
+ stream.write_all(response.as_bytes())?;
112
+ if let Some(error) = values.get("error") {
113
+ bail!("Gmail authorization failed: {error}");
114
+ }
115
+ if values
116
+ .get("state")
117
+ .is_none_or(|state| state != expected_state)
118
+ {
119
+ bail!("invalid Gmail OAuth state");
120
+ }
121
+ values
122
+ .get("code")
123
+ .cloned()
124
+ .ok_or_else(|| anyhow!("Gmail OAuth callback did not include a code"))
125
+ }
126
+
127
+ fn config_string(config: &MintConfig, key: &str) -> Result<String> {
128
+ config
129
+ .extra
130
+ .get(key)
131
+ .and_then(Value::as_str)
132
+ .map(str::to_owned)
133
+ .filter(|value| !value.trim().is_empty())
134
+ .ok_or_else(|| anyhow!("missing config value '{key}'"))
135
+ }
136
+
137
+ fn state_token() -> String {
138
+ let seed = format!(
139
+ "{}:{}",
140
+ std::process::id(),
141
+ SystemTime::now()
142
+ .duration_since(UNIX_EPOCH)
143
+ .unwrap_or_default()
144
+ .as_nanos()
145
+ );
146
+ format!("{:x}", Sha256::digest(seed.as_bytes()))
147
+ }
148
+
149
+ fn open_browser(url: &str) -> Result<()> {
150
+ let mut command = if cfg!(target_os = "windows") {
151
+ let mut command = Command::new("cmd");
152
+ command.args(["/c", "start", "", url]);
153
+ command
154
+ } else if cfg!(target_os = "macos") {
155
+ let mut command = Command::new("open");
156
+ command.arg(url);
157
+ command
158
+ } else {
159
+ let mut command = Command::new("xdg-open");
160
+ command.arg(url);
161
+ command
162
+ };
163
+ command
164
+ .stdin(Stdio::null())
165
+ .stdout(Stdio::null())
166
+ .stderr(Stdio::null())
167
+ .spawn()
168
+ .context("unable to open browser")?;
169
+ Ok(())
170
+ }
171
+
172
+ fn encode(value: &str) -> String {
173
+ value
174
+ .bytes()
175
+ .map(|byte| match byte {
176
+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
177
+ (byte as char).to_string()
178
+ }
179
+ _ => format!("%{byte:02X}"),
180
+ })
181
+ .collect()
182
+ }
183
+
184
+ fn decode(value: &str) -> String {
185
+ let bytes = value.as_bytes();
186
+ let mut output = Vec::new();
187
+ let mut index = 0;
188
+ while index < bytes.len() {
189
+ if bytes[index] == b'%'
190
+ && index + 2 < bytes.len()
191
+ && let Ok(byte) = u8::from_str_radix(&value[index + 1..index + 3], 16)
192
+ {
193
+ output.push(byte);
194
+ index += 3;
195
+ continue;
196
+ }
197
+ output.push(if bytes[index] == b'+' {
198
+ b' '
199
+ } else {
200
+ bytes[index]
201
+ });
202
+ index += 1;
203
+ }
204
+ String::from_utf8_lossy(&output).into_owned()
205
+ }
206
+
207
+ #[cfg(test)]
208
+ mod tests {
209
+ use super::*;
210
+
211
+ #[test]
212
+ fn encodes_and_decodes_oauth_values() {
213
+ let value = "http://127.0.0.1/callback?a=b c";
214
+ assert_eq!(decode(&encode(value)), value);
215
+ }
216
+ }
@@ -0,0 +1,142 @@
1
+ use std::path::Path;
2
+
3
+ use anyhow::{Context, Result, bail};
4
+ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
5
+
6
+ /// Load an image file from disk and return a `data:<mime>;base64,<data>` URI.
7
+ /// Supports PNG, JPEG, GIF, and WEBP by extension and magic bytes.
8
+ pub fn load_image_as_data_uri(path: &Path) -> Result<String> {
9
+ let bytes = std::fs::read(path)
10
+ .with_context(|| format!("failed to read image file: {}", path.display()))?;
11
+
12
+ if bytes.is_empty() {
13
+ bail!("image file is empty: {}", path.display());
14
+ }
15
+
16
+ let mime = detect_mime(&bytes, path);
17
+ let encoded = BASE64.encode(&bytes);
18
+ Ok(format!("data:{mime};base64,{encoded}"))
19
+ }
20
+
21
+ /// Try to read an image from the system clipboard.
22
+ /// Returns `None` if the clipboard contains no image or the required tool is unavailable.
23
+ /// On Linux uses `xclip` or `wl-paste`. On macOS uses `osascript`.
24
+ pub fn read_clipboard_image() -> Result<Option<String>> {
25
+ #[cfg(target_os = "linux")]
26
+ {
27
+ if let Ok(output) = std::process::Command::new("xclip")
28
+ .args(["-selection", "clipboard", "-t", "image/png", "-o"])
29
+ .output()
30
+ {
31
+ if output.status.success() && !output.stdout.is_empty() {
32
+ let encoded = BASE64.encode(&output.stdout);
33
+ return Ok(Some(format!("data:image/png;base64,{encoded}")));
34
+ }
35
+ }
36
+
37
+ if let Ok(output) = std::process::Command::new("wl-paste")
38
+ .args(["--type", "image/png"])
39
+ .output()
40
+ {
41
+ if output.status.success() && !output.stdout.is_empty() {
42
+ let encoded = BASE64.encode(&output.stdout);
43
+ return Ok(Some(format!("data:image/png;base64,{encoded}")));
44
+ }
45
+ }
46
+
47
+ Ok(None)
48
+ }
49
+
50
+ #[cfg(target_os = "macos")]
51
+ {
52
+ let script = r#"
53
+ set png_data to (the clipboard as «class PNGf»)
54
+ return png_data
55
+ "#;
56
+ let output = std::process::Command::new("osascript")
57
+ .args(["-e", script])
58
+ .output();
59
+ if let Ok(out) = output {
60
+ if out.status.success() && !out.stdout.is_empty() {
61
+ let encoded = BASE64.encode(&out.stdout);
62
+ return Ok(Some(format!("data:image/png;base64,{encoded}")));
63
+ }
64
+ }
65
+ Ok(None)
66
+ }
67
+
68
+ #[cfg(not(any(target_os = "linux", target_os = "macos")))]
69
+ {
70
+ Ok(None)
71
+ }
72
+ }
73
+
74
+ pub fn save_sent_image_after_send(data_uri: Option<&str>, message: &str) {
75
+ if let Some(data_uri) = data_uri {
76
+ for img in data_uri.split_whitespace() {
77
+ match mint_core::save_sent_image(img, message) {
78
+ Ok(entry) => println!("\x1b[90mSaved image: {}\x1b[0m", entry.path.display()),
79
+ Err(error) => {
80
+ eprintln!("\x1b[33mWarning: failed to save sent image: {error}\x1b[0m")
81
+ }
82
+ }
83
+ }
84
+ }
85
+ }
86
+
87
+ fn detect_mime(bytes: &[u8], path: &Path) -> &'static str {
88
+ if bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
89
+ return "image/png";
90
+ }
91
+ if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
92
+ return "image/jpeg";
93
+ }
94
+ if bytes.starts_with(b"GIF8") {
95
+ return "image/gif";
96
+ }
97
+ if bytes.starts_with(b"RIFF") && bytes.len() > 12 && &bytes[8..12] == b"WEBP" {
98
+ return "image/webp";
99
+ }
100
+
101
+ match path
102
+ .extension()
103
+ .and_then(|e| e.to_str())
104
+ .map(|e| e.to_ascii_lowercase())
105
+ .as_deref()
106
+ {
107
+ Some("jpg") | Some("jpeg") => "image/jpeg",
108
+ Some("gif") => "image/gif",
109
+ Some("webp") => "image/webp",
110
+ Some("bmp") => "image/bmp",
111
+ _ => "image/png",
112
+ }
113
+ }
114
+
115
+ #[cfg(test)]
116
+ mod tests {
117
+ use super::*;
118
+
119
+ #[test]
120
+ fn detects_png_by_magic_bytes() {
121
+ let png_magic = [0x89u8, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
122
+ assert_eq!(
123
+ detect_mime(&png_magic, Path::new("test.unknown")),
124
+ "image/png"
125
+ );
126
+ }
127
+
128
+ #[test]
129
+ fn detects_jpeg_by_magic_bytes() {
130
+ let jpeg_magic = [0xFF, 0xD8, 0xFF, 0xE0];
131
+ assert_eq!(
132
+ detect_mime(&jpeg_magic, Path::new("photo.jpg")),
133
+ "image/jpeg"
134
+ );
135
+ }
136
+
137
+ #[test]
138
+ fn falls_back_to_extension() {
139
+ let no_magic = [0x00u8, 0x01, 0x02, 0x03];
140
+ assert_eq!(detect_mime(&no_magic, Path::new("anim.gif")), "image/gif");
141
+ }
142
+ }