1mod 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#[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 pub fn load() -> Result<Vec<Shortcut>, Box<dyn Error>> {
55 Shortcut::load_internal(None)
56 }
57
58 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 pub fn folder(&self) -> Option<&str> {
117 self.folder.as_deref()
118 }
119
120 pub fn icon(&self) -> &DynamicImage {
124 &*self.icon
125 }
126
127 pub fn id(&self) -> &str {
131 &self.id
132 }
133
134 pub fn name(&self) -> &str {
137 &self.name
138 }
139
140 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#[cfg(target_os = "macos")]
186#[test]
187fn load() {
188 assert!(Shortcut::load().is_ok());
189}
190
191#[cfg(target_os = "macos")]
193#[test]
194fn load_folder() {
195 assert!(Shortcut::load_folder("Streamdeck").is_ok());
196}
197
198#[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#[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}