Skip to main content

imessage_apple/
process.rs

1/// macOS process execution: osascript, sips, afconvert, mdls, and shell commands.
2use std::process::Stdio;
3use std::time::Duration;
4
5use anyhow::{Result, bail};
6use tokio::process::Command;
7use tracing::warn;
8
9/// Timeout for osascript execution (30 seconds).
10const OSASCRIPT_TIMEOUT: Duration = Duration::from_secs(30);
11
12/// Execute a shell command, returning stdout (or stderr if stdout is empty).
13pub 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
38/// Execute a multi-line AppleScript string via `osascript`.
39///
40/// Execute an AppleScript:
41/// 1. Split on newlines
42/// 2. Trim + escape double-quotes in each line
43/// 3. Filter empty lines
44/// 4. Pass each line as a separate `-e "line"` argument
45pub 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    // Take stdout/stderr handles before waiting, so we can still kill on timeout.
73    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            // Timeout — kill the hung osascript process
80            let _ = child.kill().await;
81            bail!("osascript timed out after {}s", OSASCRIPT_TIMEOUT.as_secs());
82        }
83    };
84
85    // Read captured output
86    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        // Strip the "execution error: " prefix and ". (..." suffix
101        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
112/// Execute an AppleScript with error handling. Logs warnings on failure.
113pub 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
123/// Extract a clean error message from osascript stderr.
124fn extract_osa_error(stderr: &str) -> String {
125    if let Some(rest) = stderr.strip_prefix("execution error: ") {
126        // Strip trailing ". (error code)" suffix
127        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
135// ---------------------------------------------------------------------------
136// CLI tool wrappers
137// ---------------------------------------------------------------------------
138
139/// Convert HEIC/HEIF/TIFF image to JPEG using sips.
140pub 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
155/// Resize an image using sips (either by width or height).
156pub 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
184/// Convert CAF audio to M4A/AAC using afconvert.
185pub 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
200/// Convert MP3 to CAF for iMessage audio messages.
201pub 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
216/// Get the iCloud account identifier from MobileMeAccounts.plist.
217pub 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
228/// Get the system region/locale for phone number formatting.
229pub async fn get_region() -> Result<String> {
230    let output = exec_shell_command("defaults read -g AppleLanguages").await?;
231    // Parse the first language entry, extract region code
232    // Output looks like: (\n    "en-US",\n    "ja"\n)
233    for line in output.lines() {
234        let trimmed = line.trim().trim_matches('"').trim_matches(',');
235        if trimmed.contains('-') {
236            // e.g., "en-US" -> "US"
237            if let Some(region) = trimmed.split('-').next_back() {
238                return Ok(region.to_string());
239            }
240        }
241    }
242    Ok("US".to_string())
243}
244
245/// Check if SIP (System Integrity Protection) is disabled.
246pub async fn is_sip_disabled() -> Result<bool> {
247    let output = exec_shell_command("csrutil status").await?;
248    Ok(output.contains("disabled"))
249}
250
251// ---------------------------------------------------------------------------
252// Helpers
253// ---------------------------------------------------------------------------
254
255#[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}