cueward-adapter-macos 0.5.0

macOS adapter for Cueward with Safari, Notes, Messages, Reminders, Calendar, Screenshot, Clipboard, and OCR integrations.
use std::process::Command;
use std::thread;
use std::time::{Duration, Instant};

use serde::Serialize;

use cueward_core::{ShortcutAction, ShortcutReference, ShortcutSpec};

use crate::applescript;
use crate::MacosError;

mod actions;
mod compiler;
mod db;
mod types;

pub use compiler::{append_action, compile_actions, compiled_action_count, decompile_actions};
pub use db::{
    encode_input_classes, ensure_shortcut_folder_relation_live, ensure_shortcut_relation_live,
    find_shortcut, find_shortcut_live,
    find_latest_shortcut_after_pk_live, latest_shortcut_pk_live, list_shortcuts, list_shortcuts_live,
    load_shortcut_input_policy_live, load_shortcut_payload_live, load_shortcut_surfaces_live,
    rename_shortcut_name_by_workflow_id_live, sync_shortcut_surfaces_live,
    shortcut_has_relation_live, update_shortcut_actions_blob_live,
    update_shortcut_input_classes_live, write_shortcut_payload, write_shortcut_payload_live,
};
pub use types::{ShortcutRecord, ShortcutSelector};

#[cfg(test)]
mod tests;

#[derive(Debug, Serialize)]
pub struct ShortcutCreateResult {
    pub workflow_id: String,
    pub name: String,
}

pub fn create_shortcut(name: &str) -> Result<ShortcutCreateResult, MacosError> {
    let before_pk = latest_shortcut_pk_live()?.unwrap_or_default();
    let output = Command::new("osascript")
        .arg("-e")
        .arg(r#"open location "shortcuts://create-shortcut""#)
        .output()
        .map_err(|err| MacosError::Other(format!("create shortcut launcher failed: {err}")))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(MacosError::Other(format!("create shortcut: {stderr}")));
    }

    let deadline = Instant::now() + Duration::from_secs(5);
    let created = loop {
        if let Some(record) = find_latest_shortcut_after_pk_live(before_pk)? {
            break record;
        }
        if Instant::now() >= deadline {
            return Err(MacosError::Other(
                "create shortcut: timed out waiting for new shortcut to appear in the Shortcuts database".into(),
            ));
        }
        thread::sleep(Duration::from_millis(200));
    };

    rename_shortcut_name_by_workflow_id_live(&created.workflow_id, name)?;

    Ok(ShortcutCreateResult {
        workflow_id: created.workflow_id,
        name: name.to_string(),
    })
}

pub fn run_shortcut(selector: &ShortcutSelector) -> Result<(), MacosError> {
    let record = find_shortcut_live(selector)?;
    let output = Command::new("osascript")
        .arg("-e")
        .arg(r#"tell application "Shortcuts Events""#)
        .arg("-e")
        .arg(format!(
            r#"run shortcut named "{}""#,
            applescript::escape(&record.name)
        ))
        .arg("-e")
        .arg("end tell")
        .output()
        .map_err(|err| MacosError::Other(format!("run shortcut command failed: {err}")))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(MacosError::Other(format!("run shortcut: {stderr}")));
    }
    Ok(())
}

fn spec_uses_shortcut_input_variables(spec: &ShortcutSpec) -> bool {
    fn reference_uses_extension_input(reference: &ShortcutReference) -> bool {
        matches!(reference, ShortcutReference::ExtensionInput)
    }

    fn action_uses_extension_input(action: &ShortcutAction) -> bool {
        match action {
            ShortcutAction::Text { .. } => false,
            ShortcutAction::GetText { from, .. }
            | ShortcutAction::GetUrls { from, .. }
            | ShortcutAction::CopyToClipboard { from }
            | ShortcutAction::Share { from } => reference_uses_extension_input(from),
            ShortcutAction::ReplaceText { from, .. } => reference_uses_extension_input(from),
            ShortcutAction::IfEqualsText {
                input,
                then_actions,
                ..
            } => reference_uses_extension_input(input)
                || then_actions.iter().any(action_uses_extension_input),
            ShortcutAction::RepeatEach { input, body } => {
                reference_uses_extension_input(input) || body.iter().any(action_uses_extension_input)
            }
        }
    }

    spec.actions.iter().any(action_uses_extension_input)
}

pub fn apply_shortcut_spec(spec: &ShortcutSpec) -> Result<ShortcutRecord, MacosError> {
    let record = match find_shortcut_live(&ShortcutSelector::Name(spec.name.clone())) {
        Ok(record) => record,
        Err(MacosError::NotFound(_)) => {
            create_shortcut(&spec.name)?;
            find_shortcut_live(&ShortcutSelector::Name(spec.name.clone()))?
        }
        Err(err) => return Err(err),
    };
    let payload = compile_actions(spec)?;
    let action_count = compiled_action_count(&payload)?;
    let input_classes = encode_input_classes(&spec.input)?;
    let has_shortcut_input_variables = spec_uses_shortcut_input_variables(spec);

    write_shortcut_payload_live(
        record.pk,
        &payload,
        action_count,
        Some(&input_classes),
        has_shortcut_input_variables,
    )?;
    sync_shortcut_surfaces_live(record.pk, &spec.surfaces)?;
    find_shortcut_live(&ShortcutSelector::Id(record.workflow_id))
}

pub fn rename_shortcut(selector: &ShortcutSelector, new_name: &str) -> Result<ShortcutRecord, MacosError> {
    let record = find_shortcut_live(selector)?;
    rename_shortcut_name_by_workflow_id_live(&record.workflow_id, new_name)?;
    find_shortcut_live(&ShortcutSelector::Id(record.workflow_id))
}

pub fn attach_surface(
    selector: &ShortcutSelector,
    surface: &cueward_core::ShortcutSurface,
) -> Result<ShortcutRecord, MacosError> {
    let record = find_shortcut_live(selector)?;
    match surface {
        cueward_core::ShortcutSurface::LibraryRoot => ensure_shortcut_relation_live(record.pk, 6)?,
        cueward_core::ShortcutSurface::ShareSheet => ensure_shortcut_relation_live(record.pk, 2)?,
        cueward_core::ShortcutSurface::Folder(folder_name) => {
            ensure_shortcut_folder_relation_live(record.pk, folder_name)?;
        }
    }
    find_shortcut_live(&ShortcutSelector::Id(record.workflow_id))
}

pub fn set_input_type(
    selector: &ShortcutSelector,
    policy: &cueward_core::ShortcutInputPolicy,
) -> Result<ShortcutRecord, MacosError> {
    let record = find_shortcut_live(selector)?;
    let input_classes = encode_input_classes(policy)?;
    update_shortcut_input_classes_live(record.pk, &input_classes)?;
    find_shortcut_live(&ShortcutSelector::Id(record.workflow_id))
}

pub fn move_shortcut(
    selector: &ShortcutSelector,
    folder_name: &str,
) -> Result<ShortcutRecord, MacosError> {
    let record = find_shortcut_live(selector)?;
    let mut surfaces = Vec::new();
    if shortcut_has_relation_live(record.pk, 2)? {
        surfaces.push(cueward_core::ShortcutSurface::ShareSheet);
    }
    surfaces.push(cueward_core::ShortcutSurface::Folder(folder_name.to_string()));
    sync_shortcut_surfaces_live(record.pk, &surfaces)?;
    find_shortcut_live(&ShortcutSelector::Id(record.workflow_id))
}

pub fn append_shortcut_action(
    selector: &ShortcutSelector,
    action: &ShortcutAction,
) -> Result<ShortcutRecord, MacosError> {
    let record = find_shortcut_live(selector)?;
    let existing_payload = load_shortcut_payload_live(record.pk)?;
    let appended = append_action(&existing_payload, action)?;
    let action_count = compiled_action_count(&appended)?;
    update_shortcut_actions_blob_live(record.pk, &appended, action_count)?;
    find_shortcut_live(&ShortcutSelector::Id(record.workflow_id))
}

pub fn export_shortcut_spec(selector: &ShortcutSelector) -> Result<ShortcutSpec, MacosError> {
    let record = find_shortcut_live(selector)?;
    let payload = load_shortcut_payload_live(record.pk)?;
    let actions = decompile_actions(&payload)?;
    let surfaces = load_shortcut_surfaces_live(record.pk)?;
    let input = load_shortcut_input_policy_live(record.pk)?;

    Ok(ShortcutSpec {
        name: record.name,
        surfaces,
        input,
        actions,
    })
}