imessage_apple/
process.rs1use std::process::Stdio;
3use std::time::Duration;
4
5use anyhow::{Result, bail};
6use tokio::process::Command;
7use tracing::warn;
8
9const OSASCRIPT_TIMEOUT: Duration = Duration::from_secs(30);
11
12pub async fn exec_shell_command(cmd: &str) -> Result<String> {
14 let output = Command::new("sh")
15 .args(["-c", cmd])
16 .stdout(Stdio::piped())
17 .stderr(Stdio::piped())
18 .output()
19 .await?;
20
21 if !output.status.success() {
22 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
23 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
24 let msg = if stderr.is_empty() { stdout } else { stderr };
25 bail!("{msg}");
26 }
27
28 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
29 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
30
31 if stdout.is_empty() {
32 Ok(stderr)
33 } else {
34 Ok(stdout)
35 }
36}
37
38pub async fn execute_applescript(script: &str) -> Result<String> {
46 if script.trim().is_empty() {
47 return Ok(String::new());
48 }
49
50 let lines: Vec<String> = script
51 .lines()
52 .map(|line| line.trim().to_string())
53 .filter(|line| !line.is_empty())
54 .collect();
55
56 if lines.is_empty() {
57 return Ok(String::new());
58 }
59
60 let mut cmd = Command::new("osascript");
61 for line in &lines {
62 cmd.arg("-e");
63 cmd.arg(line);
64 }
65
66 let mut child = cmd
67 .stdout(Stdio::piped())
68 .stderr(Stdio::piped())
69 .kill_on_drop(true)
70 .spawn()?;
71
72 let mut stdout_handle = child.stdout.take();
74 let mut stderr_handle = child.stderr.take();
75
76 let status = match tokio::time::timeout(OSASCRIPT_TIMEOUT, child.wait()).await {
77 Ok(result) => result?,
78 Err(_) => {
79 let _ = child.kill().await;
81 bail!("osascript timed out after {}s", OSASCRIPT_TIMEOUT.as_secs());
82 }
83 };
84
85 use tokio::io::AsyncReadExt;
87 let mut stdout_buf = Vec::new();
88 let mut stderr_buf = Vec::new();
89 if let Some(ref mut h) = stdout_handle {
90 let _ = h.read_to_end(&mut stdout_buf).await;
91 }
92 if let Some(ref mut h) = stderr_handle {
93 let _ = h.read_to_end(&mut stderr_buf).await;
94 }
95
96 let stdout = String::from_utf8_lossy(&stdout_buf).trim().to_string();
97 let stderr = String::from_utf8_lossy(&stderr_buf).trim().to_string();
98
99 if !status.success() {
100 let msg = extract_osa_error(&stderr);
102 bail!("{msg}");
103 }
104
105 if stdout.is_empty() {
106 Ok(stderr)
107 } else {
108 Ok(stdout)
109 }
110}
111
112pub async fn safe_execute_applescript(script: &str) -> Result<String> {
114 match execute_applescript(script).await {
115 Ok(output) => Ok(output),
116 Err(e) => {
117 warn!("AppleScript error: {e}");
118 Err(e)
119 }
120 }
121}
122
123fn extract_osa_error(stderr: &str) -> String {
125 if let Some(rest) = stderr.strip_prefix("execution error: ") {
126 if let Some(idx) = rest.rfind(". (") {
128 return rest[..idx].to_string();
129 }
130 return rest.to_string();
131 }
132 stderr.to_string()
133}
134
135pub async fn convert_to_jpg(input_path: &str, output_path: &str) -> Result<()> {
141 let real_path = imessage_core::utils::expand_tilde(input_path);
142 let output = exec_shell_command(&format!(
143 "/usr/bin/sips --setProperty \"format\" \"jpeg\" \"{}\" --out \"{}\"",
144 real_path.display(),
145 output_path
146 ))
147 .await?;
148
149 if output.contains("Error:") {
150 bail!("Failed to convert image to JPEG: {output}");
151 }
152 Ok(())
153}
154
155pub async fn resize_image(
157 input_path: &str,
158 output_path: &str,
159 width: Option<u32>,
160 height: Option<u32>,
161) -> Result<()> {
162 let real_path = imessage_core::utils::expand_tilde(input_path);
163 let resize_flag = if let Some(w) = width {
164 format!("--resampleWidth {w}")
165 } else if let Some(h) = height {
166 format!("--resampleHeight {h}")
167 } else {
168 bail!("Must specify width or height for resize");
169 };
170
171 let output = exec_shell_command(&format!(
172 "/usr/bin/sips --setProperty format jpeg {resize_flag} \"{}\" --out \"{}\"",
173 real_path.display(),
174 output_path
175 ))
176 .await?;
177
178 if output.contains("Error:") {
179 bail!("Failed to resize image: {output}");
180 }
181 Ok(())
182}
183
184pub async fn convert_caf_to_m4a(input_path: &str, output_path: &str) -> Result<()> {
186 let real_path = imessage_core::utils::expand_tilde(input_path);
187 let output = exec_shell_command(&format!(
188 "/usr/bin/afconvert -f m4af -d aac \"{}\" \"{}\"",
189 real_path.display(),
190 output_path
191 ))
192 .await?;
193
194 if output.contains("Error:") {
195 bail!("Failed to convert audio to M4A: {output}");
196 }
197 Ok(())
198}
199
200pub async fn convert_mp3_to_caf(input_path: &str, output_path: &str) -> Result<()> {
202 let real_path = imessage_core::utils::expand_tilde(input_path);
203 let output = exec_shell_command(&format!(
204 "/usr/bin/afconvert -f caff -d LEI16@44100 -c 1 \"{}\" \"{}\"",
205 real_path.display(),
206 output_path
207 ))
208 .await?;
209
210 if output.contains("Error:") {
211 bail!("Failed to convert audio to CAF: {output}");
212 }
213 Ok(())
214}
215
216pub async fn get_icloud_account() -> Result<String> {
218 let home = std::env::var("HOME").unwrap_or_default();
219 let plist_path = format!("{}/Library/Preferences/MobileMeAccounts.plist", home);
220 let output = exec_shell_command(&format!(
221 "/usr/libexec/PlistBuddy -c \"Print :Accounts:0:AccountID\" \"{}\"",
222 plist_path
223 ))
224 .await?;
225 Ok(output.trim().to_string())
226}
227
228pub async fn get_region() -> Result<String> {
230 let output = exec_shell_command("defaults read -g AppleLanguages").await?;
231 for line in output.lines() {
234 let trimmed = line.trim().trim_matches('"').trim_matches(',');
235 if trimmed.contains('-') {
236 if let Some(region) = trimmed.split('-').next_back() {
238 return Ok(region.to_string());
239 }
240 }
241 }
242 Ok("US".to_string())
243}
244
245pub async fn is_sip_disabled() -> Result<bool> {
247 let output = exec_shell_command("csrutil status").await?;
248 Ok(output.contains("disabled"))
249}
250
251#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn extract_osa_error_strips_prefix() {
261 let e = extract_osa_error("execution error: Group chat does not exist. (-2700)");
262 assert_eq!(e, "Group chat does not exist");
263 }
264
265 #[test]
266 fn extract_osa_error_passthrough() {
267 let e = extract_osa_error("some other error");
268 assert_eq!(e, "some other error");
269 }
270}