Skip to main content

imessage_apple/
scripts.rs

1/// AppleScript template generators.
2///
3/// All functions produce AppleScript source code as a String,
4/// ready to pass to `process::execute_applescript()`.
5use imessage_core::macos::MacOsVersion;
6use imessage_core::utils::{escape_osa_exp, is_not_empty};
7
8// ---------------------------------------------------------------------------
9// Internal helpers
10// ---------------------------------------------------------------------------
11
12/// Build the `set targetService to ...` fragment.
13/// On Sequoia+ always uses `account`.
14fn build_service_script(input_service: &str) -> String {
15    format!("set targetService to 1st account whose service type = {input_service}")
16}
17
18/// Build `send "message" to <target>`.
19fn build_message_script(message: &str, target: &str) -> String {
20    if is_not_empty(message) {
21        format!("send \"{}\" to {target}", escape_osa_exp(message))
22    } else {
23        String::new()
24    }
25}
26
27/// Build attachment-send fragment.
28fn build_attachment_script(attachment: &str, variable: &str, target: &str) -> String {
29    if is_not_empty(attachment) {
30        format!(
31            "set {variable} to \"{}\" as POSIX file\n            send theAttachment to {target}\n            delay 1",
32            escape_osa_exp(attachment)
33        )
34    } else {
35        String::new()
36    }
37}
38
39/// Extract the address portion of a chat GUID (last segment after `;`).
40pub fn get_address_from_input(value: &str) -> &str {
41    value.rsplit(';').next().unwrap_or(value)
42}
43
44/// Extract the service portion of a chat GUID (first segment before `;`).
45/// Maps `"any"` to `"iMessage"` for Tahoe compatibility.
46pub fn get_service_from_input(value: &str) -> &str {
47    if !value.contains(';') {
48        return "iMessage";
49    }
50    let service = value.split(';').next().unwrap_or("iMessage");
51    if service == "any" {
52        "iMessage"
53    } else {
54        service
55    }
56}
57
58// ---------------------------------------------------------------------------
59// Application control
60// ---------------------------------------------------------------------------
61
62fn start_app(app_name: &str) -> String {
63    format!(
64        "set appName to \"{app_name}\"\n\
65        if application appName is running then\n\
66            return 0\n\
67        else\n\
68            tell application appName to reopen\n\
69        end if"
70    )
71}
72
73pub fn start_messages() -> String {
74    start_app("Messages")
75}
76
77// ---------------------------------------------------------------------------
78// Messages send / chat scripts
79// ---------------------------------------------------------------------------
80
81/// Send a message and/or attachment to a chat by GUID.
82///
83/// On Tahoe, replaces iMessage/SMS prefix with "any" in the GUID.
84pub fn send_message(
85    chat_guid: &str,
86    message: &str,
87    attachment: &str,
88    v: MacOsVersion,
89    format_address: &(dyn Fn(&str) -> String + Send + Sync),
90) -> Option<String> {
91    if chat_guid.is_empty() || (message.is_empty() && attachment.is_empty()) {
92        return None;
93    }
94
95    let attachment_scpt = build_attachment_script(attachment, "theAttachment", "targetChat");
96    let message_scpt = build_message_script(message, "targetChat");
97
98    if !chat_guid.contains(';') {
99        return None; // Invalid GUID
100    }
101
102    let mut guid = chat_guid.to_string();
103
104    // Format the address in DM GUIDs
105    if guid.contains(";-;") {
106        let parts: Vec<&str> = guid.splitn(2, ";-;").collect();
107        if parts.len() == 2 {
108            let service = parts[0];
109            let addr = parts[1];
110            let formatted = format_address(addr);
111            guid = format!("{service};-;{formatted}");
112        }
113    }
114
115    // Tahoe: use "any" service prefix
116    if v.is_min_tahoe() {
117        if guid.starts_with("iMessage;") {
118            guid = format!("any;{}", &guid["iMessage;".len()..]);
119        } else if guid.starts_with("SMS;") {
120            guid = format!("any;{}", &guid["SMS;".len()..]);
121        }
122    }
123
124    Some(format!(
125        "tell application \"Messages\"\n\
126            set targetChat to a reference to chat id \"{guid}\"\n\
127        \n\
128            {attachment_scpt}\n\
129            {message_scpt}\n\
130        end tell"
131    ))
132}
133
134/// Fallback send for DMs only: uses participant + service approach.
135pub fn send_message_fallback(
136    chat_guid: &str,
137    message: &str,
138    attachment: &str,
139) -> Result<Option<String>, String> {
140    if chat_guid.is_empty() || (message.is_empty() && attachment.is_empty()) {
141        return Ok(None);
142    }
143
144    let attachment_scpt = build_attachment_script(attachment, "theAttachment", "targetBuddy");
145    let message_scpt = build_message_script(message, "targetBuddy");
146
147    let address = get_address_from_input(chat_guid);
148    let service = get_service_from_input(chat_guid);
149
150    if address.starts_with("chat") {
151        return Err(
152            "Can't use the send message (fallback) script to text a group chat!".to_string(),
153        );
154    }
155
156    let service_script = build_service_script(service);
157
158    Ok(Some(format!(
159        "tell application \"Messages\"\n\
160            {service_script}\n\
161            set targetBuddy to participant \"{address}\" of targetService\n\
162        \n\
163            {attachment_scpt}\n\
164            {message_scpt}\n\
165        end tell"
166    )))
167}
168
169/// Restart Messages app with a configurable delay.
170pub fn restart_messages(delay_seconds: u32) -> String {
171    format!(
172        "tell application \"Messages\"\n\
173            quit\n\
174            delay {delay_seconds}\n\
175            reopen\n\
176        end tell"
177    )
178}
179
180/// Create a new chat with given participants and optional initial message.
181pub fn start_chat(participants: &[String], service: &str, message: Option<&str>) -> String {
182    let service_script = build_service_script(service);
183    let buddies = participants
184        .iter()
185        .map(|b| format!("buddy \"{b}\" of targetService"))
186        .collect::<Vec<_>>()
187        .join(", ");
188
189    let message_scpt = match message {
190        Some(msg) if is_not_empty(msg) => build_message_script(msg, "thisChat"),
191        _ => String::new(),
192    };
193
194    format!(
195        "tell application \"Messages\"\n\
196            {service_script}\n\
197        \n\
198            (* Start the new chat with all the recipients *)\n\
199            set thisChat to make new chat with properties {{participants: {{{buddies}}}}}\n\
200            log thisChat\n\
201            {message_scpt}\n\
202        end tell\n\
203        \n\
204        try\n\
205            tell application \"System Events\" to tell process \"Messages\" to set visible to false\n\
206        end try"
207    )
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    fn tahoe() -> MacOsVersion {
215        MacOsVersion::new(26, 3, 0)
216    }
217    fn sequoia() -> MacOsVersion {
218        MacOsVersion::new(15, 0, 0)
219    }
220
221    fn identity(s: &str) -> String {
222        s.to_string()
223    }
224
225    #[test]
226    fn get_address_extracts_last_segment() {
227        assert_eq!(
228            get_address_from_input("iMessage;-;+15551234567"),
229            "+15551234567"
230        );
231        assert_eq!(get_address_from_input("SMS;-;+15551234567"), "+15551234567");
232        assert_eq!(get_address_from_input("no-semicolons"), "no-semicolons");
233    }
234
235    #[test]
236    fn get_service_extracts_first_segment() {
237        assert_eq!(
238            get_service_from_input("iMessage;-;+15551234567"),
239            "iMessage"
240        );
241        assert_eq!(get_service_from_input("SMS;-;+15551234567"), "SMS");
242        assert_eq!(get_service_from_input("any;-;+15551234567"), "iMessage"); // Tahoe maps to iMessage
243        assert_eq!(get_service_from_input("no-semicolons"), "iMessage"); // default
244    }
245
246    #[test]
247    fn send_message_returns_none_for_empty() {
248        assert!(send_message("", "hello", "", tahoe(), &identity).is_none());
249        assert!(send_message("iMessage;-;+15551234567", "", "", tahoe(), &identity).is_none());
250    }
251
252    #[test]
253    fn send_message_tahoe_uses_any_prefix() {
254        let script =
255            send_message("iMessage;-;+15551234567", "hello", "", tahoe(), &identity).unwrap();
256        assert!(script.contains("any;-;+15551234567"));
257        assert!(!script.contains("iMessage;-;"));
258    }
259
260    #[test]
261    fn send_message_sequoia_keeps_imessage_prefix() {
262        let script =
263            send_message("iMessage;-;+15551234567", "hello", "", sequoia(), &identity).unwrap();
264        assert!(script.contains("iMessage;-;+15551234567"));
265    }
266
267    #[test]
268    fn build_service_uses_account() {
269        let s = build_service_script("iMessage");
270        assert!(s.contains("1st account whose service type"));
271    }
272
273    #[test]
274    fn restart_messages_includes_delay() {
275        let s = restart_messages(5);
276        assert!(s.contains("delay 5"));
277    }
278
279    #[test]
280    fn start_chat_uses_make_new_chat() {
281        let s = start_chat(&["buddy1".to_string()], "iMessage", None);
282        assert!(s.contains("make new chat"));
283        assert!(!s.contains("text chat"));
284    }
285
286    #[test]
287    fn fallback_rejects_group_chats() {
288        let result = send_message_fallback("iMessage;+;chat123456", "hello", "");
289        assert!(result.is_err());
290    }
291
292    #[test]
293    fn fallback_uses_participant() {
294        let script = send_message_fallback("iMessage;-;+15551234567", "hello", "")
295            .unwrap()
296            .unwrap();
297        assert!(script.contains("participant"));
298    }
299}