Skip to main content

macos_shortcuts/
lib.rs

1//! This crate enables access to [Apple Shortcuts for Mac].
2//!
3//! The crate currently supports loading [Shortcut]s that have been defined
4//! using the [Shortcuts] app.
5//!
6//! [Apple Shortcuts for Mac]: https://support.apple.com/en-gb/guide/shortcuts-mac/welcome/mac
7//! [Shortcuts]: shortcuts://
8
9mod deserializer;
10mod dynamic_image_ext;
11
12use std::{
13    cmp::Ordering,
14    fs::{read, write},
15    io::{Error as IoError, ErrorKind},
16    process::Command,
17};
18
19use std::error::Error;
20
21use image::DynamicImage;
22use tempfile::NamedTempFile;
23
24use crate::{deserializer::from_bytes, dynamic_image_ext::DynamicImageExt};
25
26macro_rules! run_apple_script {
27    ($script:expr, $( $args:expr ), * ) => {
28        Command::new("osascript").args(["-s", "s", "-e", format!($script, $( $args ), *).as_str()]).output()
29    };
30}
31const SHORTCUT_EVENTS: &str = "\"Shortcuts Events\"";
32
33/// An object representing a single shortcut in the Shortcuts app
34#[derive(Clone, Debug)]
35pub struct Shortcut {
36    folder: Option<String>,
37    icon: DynamicImageExt,
38    id: String,
39    index: usize,
40    name: String,
41}
42
43impl Shortcut {
44    
45    /// Load the current set of shortcuts defined in the Shortcuts app. This
46    /// will load all shortcuts regardless of folder.
47    /// 
48    /// This method currently uses a small AppleScript snippet and executes
49    /// this using [osascript].
50    /// 
51    /// [AppleScript]: https://developer.apple.com/library/archive/documentation/AppleScript/Conceptual/AppleScriptLangGuide/introduction/ASLR_intro.html
52    /// [osascript]: x-man-page://osascript
53    /// 
54    pub fn load() -> Result<Vec<Shortcut>, Box<dyn Error>> {
55        Shortcut::load_internal(None)
56    }
57
58    /// Load the current set of shortcuts defined in the Shortcuts app in the
59    /// given folder.
60    /// 
61    /// This method currently uses a small AppleScript snippet and executes
62    /// this using [osascript].
63    /// 
64    /// [AppleScript]: https://developer.apple.com/library/archive/documentation/AppleScript/Conceptual/AppleScriptLangGuide/introduction/ASLR_intro.html
65    /// [osascript]: x-man-page://osascript
66    /// 
67    pub fn load_folder(folder: &str) -> Result<Vec<Shortcut>, Box<dyn Error>> {
68        Shortcut::load_internal(Some(folder))
69    }
70
71    fn load_internal(folder: Option<&str>) -> Result<Vec<Shortcut>, Box<dyn Error>> {
72        let output = run_apple_script!(
73            "
74            tell application {}
75                {{id, name, name of folder, icon}} of every shortcut{}
76            end tell
77        ",
78            SHORTCUT_EVENTS,
79            if folder.is_some() { format!(" in folder \"{}\"", folder.unwrap()) } else { String::new() } 
80        )?;
81
82        if !output.status.success() {
83            Err(IoError::new(ErrorKind::Other, String::from_utf8(output.stderr)?))?;
84        }
85
86        let parsed: (
87            Vec<String>,
88            Vec<String>,
89            Vec<Option<String>>,
90            Vec<DynamicImageExt>,
91        ) = from_bytes(&output.stdout)?;
92
93        let mut shortcuts = Vec::with_capacity(parsed.0.len());
94        for ((((id, name), folder), icon), index) in parsed
95            .0
96            .into_iter()
97            .zip(parsed.1.into_iter())
98            .zip(parsed.2.into_iter())
99            .zip(parsed.3.into_iter())
100            .zip((1..).into_iter())
101        {
102            shortcuts.push(Shortcut {
103                folder,
104                icon,
105                id,
106                index,
107                name,
108            });
109        }
110        Ok(shortcuts)
111    }
112
113    /// Get the folder name that this shortcut is contained within or `None`
114    /// if the shortcut isn't in a folder
115    /// 
116    pub fn folder(&self) -> Option<&str> {
117        self.folder.as_deref()
118    }
119
120    /// Get the icon defined for this shortcut. The dimensions of the icon are
121    /// driven by the AppleScript and the Shortcuts app used to load the data
122    /// 
123    pub fn icon(&self) -> &DynamicImage {
124        &*self.icon
125    }
126
127    /// The unique id of the shortcut. This is used to execute the shortcut to
128    /// avoid issues with duplicate named shortcuts
129    /// 
130    pub fn id(&self) -> &str {
131        &self.id
132    }
133
134    /// The name of the shortcuts executable
135    /// 
136    pub fn name(&self) -> &str {
137        &self.name
138    }
139
140    /// Attempt to run the shortcut, using temporary files for input and output
141    /// using the [shortcuts] executable
142    /// 
143    /// [shortcuts]: x-man-page://shortcuts
144    /// 
145    pub fn run(&self, input: Option<&[u8]>) -> Result<Vec<u8>, Box<dyn Error>> {
146        let output_file = NamedTempFile::with_prefix("a")?;
147        let input_file = NamedTempFile::with_prefix("a")?;
148        let mut args = vec!["run"];
149        if input.is_some() {
150            write(&input_file, input.unwrap())?;
151            args.push("-i");
152            args.push(input_file.path().to_str().unwrap());
153        }
154        args.push("-o");
155        args.push(output_file.path().to_str().unwrap());
156        args.push(self.id.as_str());
157        match Command::new("shortcuts").args(args).status()?.code() {
158            Some(0) => Ok(read(&output_file)?),
159            _ => Err(Box::<IoError>::new(ErrorKind::Other.into())),
160        }
161    }
162}
163
164impl PartialEq for Shortcut {
165    fn eq(&self, other: &Self) -> bool {
166        self.id == other.id
167    }
168}
169
170impl Eq for Shortcut {}
171
172impl PartialOrd for Shortcut {
173    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
174        Some(self.cmp(other))
175    }
176}
177
178impl Ord for Shortcut {
179    fn cmp(&self, other: &Self) -> Ordering {
180        self.index.cmp(&other.index)
181    }
182}
183
184/// Attempt to load the shortcuts
185#[cfg(target_os = "macos")]
186#[test]
187fn load() {
188    assert!(Shortcut::load().is_ok());
189}
190
191/// Attempt to load the shortcuts in "Streamdeck" folder
192#[cfg(target_os = "macos")]
193#[test]
194fn load_folder() {
195    assert!(Shortcut::load_folder("Streamdeck").is_ok());
196}
197
198/// Attempt to load the shortcuts in "Streamdeck" folder
199#[cfg(target_os = "macos")]
200#[test]
201fn load_folder_fails_for_invalid_folder_name() {
202    assert!(Shortcut::load_folder("Streamdec").is_err());
203}
204
205/// Load the shortcuts and attempt to run one called "Get current weather"
206#[cfg(target_os = "macos")]
207#[test]
208fn run() {
209    assert!(Shortcut::load().unwrap().iter().find(|s|s.name == "Get current weather").unwrap().run(None).is_ok());
210}