use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use crate::config::{
read_plist_metadata, ConfigProvider, WorkflowConfig, DEFAULT_ALFRED_VERSION,
DEFAULT_ALFRED_VERSION_BUILD,
};
use crate::error::WorkflowError;
use crate::workflow::{finalize_workflow, setup_workflow};
use crate::Runnable;
use super::action::ActionResult;
use super::graph::Severity;
use super::screen::{Screen, ScreenItem};
use super::{ObjectKind, WorkflowGraph};
#[derive(Debug)]
pub struct Simulator {
workflow_dir: PathBuf,
graph: Arc<WorkflowGraph>,
bundleid: String,
workflow_name: String,
cache_dir: Option<PathBuf>,
data_dir: Option<PathBuf>,
binary: Option<PathBuf>,
source_uid: Option<String>,
}
impl Simulator {
pub fn for_workflow_dir(dir: impl AsRef<Path>) -> Result<Self, SimulatorError> {
let workflow_dir = dir.as_ref().to_path_buf();
let plist_path = workflow_dir.join("info.plist");
if !plist_path.is_file() {
return Err(SimulatorError::NoPlist(workflow_dir));
}
let graph = WorkflowGraph::from_plist_file(&plist_path)
.map_err(|e| SimulatorError::PlistParse(e.to_string()))?;
let (bundleid, workflow_name) = read_plist_metadata(&plist_path)
.ok_or_else(|| SimulatorError::PlistParse("missing bundleid".to_string()))?;
Ok(Self {
workflow_dir,
graph: Arc::new(graph),
bundleid,
workflow_name,
cache_dir: None,
data_dir: None,
binary: None,
source_uid: None,
})
}
pub fn cache_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.cache_dir = Some(path.into());
self
}
pub fn data_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.data_dir = Some(path.into());
self
}
pub fn binary(mut self, path: impl Into<PathBuf>) -> Self {
self.binary = Some(path.into());
self
}
pub fn source_filter(mut self, uid: impl Into<String>) -> Self {
self.source_uid = Some(uid.into());
self
}
pub fn graph(&self) -> &WorkflowGraph {
&self.graph
}
pub fn workflow_dir(&self) -> &Path {
&self.workflow_dir
}
pub fn bundleid(&self) -> &str {
&self.bundleid
}
pub fn run_in_process<R: Runnable>(&self, runnable: R) -> Result<Screen, SimulatorError> {
let provider = self.build_config_provider()?;
let mut buffer = Vec::new();
execute_to_buffer(&provider, runnable, &mut buffer);
let json =
String::from_utf8(buffer).map_err(|e| SimulatorError::OutputParse(e.to_string()))?;
let screen =
Screen::from_json(&json).map_err(|e| SimulatorError::OutputParse(e.to_string()))?;
Ok(self.attach_context(screen))
}
pub fn invoke(&self, args: &[&str]) -> Result<Screen, SimulatorError> {
let binary = self.binary.clone().ok_or(SimulatorError::NoBinary)?;
if !binary.is_file() {
return Err(SimulatorError::BinaryNotFound(binary));
}
self.invoke_binary(&binary, args)
}
pub fn invoke_script_filter(
&self,
filter_uid: &str,
args: &[&str],
) -> Result<Screen, SimulatorError> {
let node = self.graph.objects().get(filter_uid).ok_or_else(|| {
SimulatorError::OutputParse(format!("no object with UID '{filter_uid}'"))
})?;
if let Some(script_file) = node.script_file() {
let resolved = self.workflow_dir.join(script_file);
if resolved.is_file() {
let screen = self.invoke_binary(&resolved, args)?;
return Ok(screen.with_context(Arc::clone(&self.graph), filter_uid.to_string()));
}
}
if let Some(inline_script) = node.script() {
let screen = self.invoke_inline_script(inline_script, args)?;
return Ok(screen.with_context(Arc::clone(&self.graph), filter_uid.to_string()));
}
if let Some(binary) = &self.binary {
if !binary.is_file() {
return Err(SimulatorError::BinaryNotFound(binary.clone()));
}
let screen = self.invoke_binary(binary, args)?;
return Ok(screen.with_context(Arc::clone(&self.graph), filter_uid.to_string()));
}
Err(SimulatorError::NoBinary)
}
pub fn dynamic_audit(&self) -> Result<Vec<DynamicAuditDiagnostic>, SimulatorError> {
let mut diagnostics = Vec::new();
let filters: Vec<(&str, &str)> = self
.graph
.objects()
.values()
.filter(|n| n.kind == ObjectKind::ScriptFilter && n.keyword.is_some())
.map(|n| (n.uid.as_str(), n.keyword.as_deref().unwrap_or("")))
.collect();
for (uid, keyword) in filters {
let screen = match self.invoke_script_filter(uid, &[]) {
Ok(s) => s,
Err(e) => {
diagnostics.push(DynamicAuditDiagnostic {
severity: Severity::Warning,
message: format!("Could not invoke Script Filter '{keyword}' ({uid}): {e}"),
keyword: keyword.to_string(),
item_title: None,
});
continue;
}
};
for (i, item) in screen.items().iter().enumerate() {
if !Self::is_navigation_item(item) {
continue;
}
if screen.action(i) == Some(ActionResult::DeadEnd) {
diagnostics.push(DynamicAuditDiagnostic {
severity: Severity::Error,
message: format!(
"Navigation item '{}' from Script Filter '{keyword}' ({uid}) \
has no route (dead-end: matched branch output is \
unconnected or dangling)",
item.title()
),
keyword: keyword.to_string(),
item_title: Some(item.title().to_string()),
});
}
}
}
Ok(diagnostics)
}
fn is_navigation_item(item: &ScreenItem) -> bool {
if item.autocomplete().is_some() {
return true;
}
if let Some(vars) = item.raw().get("variables").and_then(|v| v.as_object()) {
if !vars.is_empty() {
if let Some(arg) = item.arg() {
if arg.starts_with("http://") || arg.starts_with("https://") {
return false;
}
}
return true;
}
}
false
}
fn invoke_binary(&self, binary: &Path, args: &[&str]) -> Result<Screen, SimulatorError> {
let (cache_dir, data_dir) = self.resolve_dirs()?;
let output = Command::new(binary)
.args(args)
.env("alfred_workflow_bundleid", &self.bundleid)
.env("alfred_workflow_name", &self.workflow_name)
.env(
"alfred_workflow_cache",
cache_dir.to_string_lossy().as_ref(),
)
.env("alfred_workflow_data", data_dir.to_string_lossy().as_ref())
.env("alfred_version", DEFAULT_ALFRED_VERSION)
.env("alfred_version_build", DEFAULT_ALFRED_VERSION_BUILD)
.output()
.map_err(|e| SimulatorError::BinaryExec(e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SimulatorError::BinaryExec(format!(
"exit code {}: {}",
output.status.code().unwrap_or(-1),
stderr.trim()
)));
}
let json = String::from_utf8(output.stdout)
.map_err(|e| SimulatorError::OutputParse(e.to_string()))?;
let screen =
Screen::from_json(&json).map_err(|e| SimulatorError::OutputParse(e.to_string()))?;
Ok(self.attach_context(screen))
}
fn invoke_inline_script(&self, script: &str, args: &[&str]) -> Result<Screen, SimulatorError> {
let (cache_dir, data_dir) = self.resolve_dirs()?;
let query = args.join(" ");
let expanded = script.replace("{query}", &query);
let output = Command::new("/bin/sh")
.arg("-c")
.arg(&expanded)
.arg("--") .arg(&query) .current_dir(&self.workflow_dir)
.env("alfred_workflow_bundleid", &self.bundleid)
.env("alfred_workflow_name", &self.workflow_name)
.env(
"alfred_workflow_cache",
cache_dir.to_string_lossy().as_ref(),
)
.env("alfred_workflow_data", data_dir.to_string_lossy().as_ref())
.env("alfred_version", DEFAULT_ALFRED_VERSION)
.env("alfred_version_build", DEFAULT_ALFRED_VERSION_BUILD)
.output()
.map_err(|e| SimulatorError::BinaryExec(e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SimulatorError::BinaryExec(format!(
"inline script exit code {}: {}",
output.status.code().unwrap_or(-1),
stderr.trim()
)));
}
let json = String::from_utf8(output.stdout)
.map_err(|e| SimulatorError::OutputParse(e.to_string()))?;
Screen::from_json(&json).map_err(|e| SimulatorError::OutputParse(e.to_string()))
}
fn resolve_dirs(&self) -> Result<(PathBuf, PathBuf), SimulatorError> {
let cache_dir = self.cache_dir.clone().unwrap_or_else(|| {
std::env::temp_dir()
.join("alfrusco-simulator")
.join(&self.bundleid)
.join("cache")
});
let data_dir = self.data_dir.clone().unwrap_or_else(|| {
std::env::temp_dir()
.join("alfrusco-simulator")
.join(&self.bundleid)
.join("data")
});
std::fs::create_dir_all(&cache_dir).map_err(|e| SimulatorError::Io(e.to_string()))?;
std::fs::create_dir_all(&data_dir).map_err(|e| SimulatorError::Io(e.to_string()))?;
Ok((cache_dir, data_dir))
}
fn build_config_provider(&self) -> Result<SimulatorConfigProvider, SimulatorError> {
let (cache_dir, data_dir) = self.resolve_dirs()?;
Ok(SimulatorConfigProvider {
bundleid: self.bundleid.clone(),
workflow_name: self.workflow_name.clone(),
cache_dir,
data_dir,
})
}
fn attach_context(&self, screen: Screen) -> Screen {
let source_uid = self.resolve_source_uid();
screen.with_context(Arc::clone(&self.graph), source_uid)
}
fn resolve_source_uid(&self) -> String {
if let Some(uid) = &self.source_uid {
return uid.clone();
}
self.graph
.objects()
.values()
.find(|n| n.kind == ObjectKind::ScriptFilter && n.keyword.is_some())
.map(|n| n.uid.clone())
.unwrap_or_default()
}
}
struct SimulatorConfigProvider {
bundleid: String,
workflow_name: String,
cache_dir: PathBuf,
data_dir: PathBuf,
}
impl ConfigProvider for SimulatorConfigProvider {
fn config(&self) -> crate::Result<WorkflowConfig> {
Ok(WorkflowConfig {
workflow_bundleid: self.bundleid.clone(),
workflow_cache: self.cache_dir.clone(),
workflow_data: self.data_dir.clone(),
version: DEFAULT_ALFRED_VERSION.to_string(),
version_build: DEFAULT_ALFRED_VERSION_BUILD.to_string(),
workflow_name: self.workflow_name.clone(),
workflow_version: None,
preferences: None,
preferences_localhash: None,
theme: None,
theme_background: None,
theme_selection_background: None,
theme_subtext: None,
workflow_description: None,
workflow_uid: None,
workflow_keyword: None,
debug: false,
})
}
}
fn execute_to_buffer<R: Runnable>(
provider: &dyn ConfigProvider,
runnable: R,
buffer: &mut Vec<u8>,
) {
let mut workflow = setup_workflow(provider);
if let Err(e) = runnable.run(&mut workflow) {
workflow.prepend_item(e.error_item());
}
finalize_workflow(workflow, buffer);
}
#[derive(Debug, thiserror::Error)]
pub enum SimulatorError {
#[error("no info.plist found in workflow directory: {0}")]
NoPlist(PathBuf),
#[error("failed to parse info.plist: {0}")]
PlistParse(String),
#[error("no binary path set (use .binary() to specify one)")]
NoBinary,
#[error("binary not found: {0}")]
BinaryNotFound(PathBuf),
#[error("binary execution failed: {0}")]
BinaryExec(String),
#[error("failed to parse workflow output: {0}")]
OutputParse(String),
#[error("I/O error: {0}")]
Io(String),
}
#[derive(Debug, Clone)]
pub struct DynamicAuditDiagnostic {
pub severity: Severity,
pub message: String,
pub keyword: String,
pub item_title: Option<String>,
}