macos-shortcuts 1.1.0

This crate enables access to Apple Shortcuts for Mac
Documentation
//! This crate enables access to [Apple Shortcuts for Mac].
//!
//! The crate currently supports loading [Shortcut]s that have been defined
//! using the [Shortcuts] app.
//!
//! [Apple Shortcuts for Mac]: https://support.apple.com/en-gb/guide/shortcuts-mac/welcome/mac
//! [Shortcuts]: shortcuts://

mod deserializer;
mod dynamic_image_ext;

use std::{
    cmp::Ordering,
    fs::{read, write},
    io::{Error as IoError, ErrorKind},
    process::Command,
};

use std::error::Error;

use image::DynamicImage;
use tempfile::NamedTempFile;

use crate::{deserializer::from_bytes, dynamic_image_ext::DynamicImageExt};

macro_rules! run_apple_script {
    ($script:expr, $( $args:expr ), * ) => {
        Command::new("osascript").args(["-s", "s", "-e", format!($script, $( $args ), *).as_str()]).output()
    };
}
const SHORTCUT_EVENTS: &str = "\"Shortcuts Events\"";

/// An object representing a single shortcut in the Shortcuts app
#[derive(Clone, Debug)]
pub struct Shortcut {
    folder: Option<String>,
    icon: DynamicImageExt,
    id: String,
    index: usize,
    name: String,
}

impl Shortcut {
    
    /// Load the current set of shortcuts defined in the Shortcuts app. This
    /// will load all shortcuts regardless of folder.
    /// 
    /// This method currently uses a small AppleScript snippet and executes
    /// this using [osascript].
    /// 
    /// [AppleScript]: https://developer.apple.com/library/archive/documentation/AppleScript/Conceptual/AppleScriptLangGuide/introduction/ASLR_intro.html
    /// [osascript]: x-man-page://osascript
    /// 
    pub fn load() -> Result<Vec<Shortcut>, Box<dyn Error>> {
        Shortcut::load_internal(None)
    }

    /// Load the current set of shortcuts defined in the Shortcuts app in the
    /// given folder.
    /// 
    /// This method currently uses a small AppleScript snippet and executes
    /// this using [osascript].
    /// 
    /// [AppleScript]: https://developer.apple.com/library/archive/documentation/AppleScript/Conceptual/AppleScriptLangGuide/introduction/ASLR_intro.html
    /// [osascript]: x-man-page://osascript
    /// 
    pub fn load_folder(folder: &str) -> Result<Vec<Shortcut>, Box<dyn Error>> {
        Shortcut::load_internal(Some(folder))
    }

    fn load_internal(folder: Option<&str>) -> Result<Vec<Shortcut>, Box<dyn Error>> {
        let output = run_apple_script!(
            "
            tell application {}
                {{id, name, name of folder, icon}} of every shortcut{}
            end tell
        ",
            SHORTCUT_EVENTS,
            if folder.is_some() { format!(" in folder \"{}\"", folder.unwrap()) } else { String::new() } 
        )?;

        if !output.status.success() {
            Err(IoError::new(ErrorKind::Other, String::from_utf8(output.stderr)?))?;
        }

        let parsed: (
            Vec<String>,
            Vec<String>,
            Vec<Option<String>>,
            Vec<DynamicImageExt>,
        ) = from_bytes(&output.stdout)?;

        let mut shortcuts = Vec::with_capacity(parsed.0.len());
        for ((((id, name), folder), icon), index) in parsed
            .0
            .into_iter()
            .zip(parsed.1.into_iter())
            .zip(parsed.2.into_iter())
            .zip(parsed.3.into_iter())
            .zip((1..).into_iter())
        {
            shortcuts.push(Shortcut {
                folder,
                icon,
                id,
                index,
                name,
            });
        }
        Ok(shortcuts)
    }

    /// Get the folder name that this shortcut is contained within or `None`
    /// if the shortcut isn't in a folder
    /// 
    pub fn folder(&self) -> Option<&str> {
        self.folder.as_deref()
    }

    /// Get the icon defined for this shortcut. The dimensions of the icon are
    /// driven by the AppleScript and the Shortcuts app used to load the data
    /// 
    pub fn icon(&self) -> &DynamicImage {
        &*self.icon
    }

    /// The unique id of the shortcut. This is used to execute the shortcut to
    /// avoid issues with duplicate named shortcuts
    /// 
    pub fn id(&self) -> &str {
        &self.id
    }

    /// The name of the shortcuts executable
    /// 
    pub fn name(&self) -> &str {
        &self.name
    }

    /// Attempt to run the shortcut, using temporary files for input and output
    /// using the [shortcuts] executable
    /// 
    /// [shortcuts]: x-man-page://shortcuts
    /// 
    pub fn run(&self, input: Option<&[u8]>) -> Result<Vec<u8>, Box<dyn Error>> {
        let output_file = NamedTempFile::with_prefix("a")?;
        let input_file = NamedTempFile::with_prefix("a")?;
        let mut args = vec!["run"];
        if input.is_some() {
            write(&input_file, input.unwrap())?;
            args.push("-i");
            args.push(input_file.path().to_str().unwrap());
        }
        args.push("-o");
        args.push(output_file.path().to_str().unwrap());
        args.push(self.id.as_str());
        match Command::new("shortcuts").args(args).status()?.code() {
            Some(0) => Ok(read(&output_file)?),
            _ => Err(Box::<IoError>::new(ErrorKind::Other.into())),
        }
    }
}

impl PartialEq for Shortcut {
    fn eq(&self, other: &Self) -> bool {
        self.id == other.id
    }
}

impl Eq for Shortcut {}

impl PartialOrd for Shortcut {
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
        Some(self.cmp(other))
    }
}

impl Ord for Shortcut {
    fn cmp(&self, other: &Self) -> Ordering {
        self.index.cmp(&other.index)
    }
}

/// Attempt to load the shortcuts
#[cfg(target_os = "macos")]
#[test]
fn load() {
    assert!(Shortcut::load().is_ok());
}

/// Attempt to load the shortcuts in "Streamdeck" folder
#[cfg(target_os = "macos")]
#[test]
fn load_folder() {
    assert!(Shortcut::load_folder("Streamdeck").is_ok());
}

/// Attempt to load the shortcuts in "Streamdeck" folder
#[cfg(target_os = "macos")]
#[test]
fn load_folder_fails_for_invalid_folder_name() {
    assert!(Shortcut::load_folder("Streamdec").is_err());
}

/// Load the shortcuts and attempt to run one called "Get current weather"
#[cfg(target_os = "macos")]
#[test]
fn run() {
    assert!(Shortcut::load().unwrap().iter().find(|s|s.name == "Get current weather").unwrap().run(None).is_ok());
}