safari 1.0.0

Terminal UI for capturing and restoring Safari sessions on macOS
Documentation
use std::process::Command;
use std::str;

use anyhow::{anyhow, Context, Result};
use serde::Deserialize;

use crate::backend::models::{SafariSession, SessionWindow};
use crate::backend::utils;

const CAPTURE_SCRIPT: &str = r#"
function run() {
  const safari = Application("Safari");
  const windows = safari.windows().map((window, index) => {
    let rawTabs = [];

    try {
      rawTabs = window.tabs() || [];
    } catch (_error) {
      rawTabs = [];
    }

    const tabs = rawTabs
      .filter((tab) => tab)
      .map((tab) => {
        let title = "";
        let url = "";

        try {
          title = String(tab.name() || "").trim();
        } catch (_error) {}

        try {
          url = String(tab.url() || "").trim();
        } catch (_error) {}

        return { title, url };
      })
      .filter((tab) => tab.url.length > 0);

    return {
      title: `Window ${index + 1}`,
      tabs,
    };
  }).filter((window) => window.tabs.length > 0);

  return JSON.stringify({ windows });
}
"#;

#[derive(Debug, Deserialize)]
struct CapturePayload {
    windows: Vec<SessionWindow>,
}

pub fn get_current_session() -> Result<SafariSession> {
    let stdout = run_osascript(Some("JavaScript"), CAPTURE_SCRIPT)?;
    let payload: CapturePayload =
        serde_json::from_str(stdout.trim()).context("Failed to parse Safari capture payload")?;

    Ok(SafariSession::new(
        utils::current_timestamp(),
        payload.windows,
    ))
}

pub fn restore_session(session: &SafariSession) -> Result<()> {
    if session.is_empty() {
        return Err(anyhow!(
            "Selected session does not contain any Safari windows"
        ));
    }

    run_osascript(None, &build_restore_script(session))?;
    Ok(())
}

pub fn open_url(url: &str) -> Result<()> {
    let status = Command::new("open")
        .arg("-a")
        .arg("Safari")
        .arg(url)
        .status()
        .context("Failed to open URL in Safari")?;

    if status.success() {
        Ok(())
    } else {
        Err(anyhow!(
            "Safari returned a non-zero status while opening the URL"
        ))
    }
}

fn run_osascript(language: Option<&str>, script: &str) -> Result<String> {
    let mut command = Command::new("osascript");
    if let Some(language) = language {
        command.arg("-l").arg(language);
    }

    let output = command
        .arg("-e")
        .arg(script)
        .output()
        .context("Failed to execute osascript")?;

    if !output.status.success() {
        let stderr = str::from_utf8(&output.stderr).unwrap_or("Unknown AppleScript error");
        return Err(anyhow!(stderr.trim().to_string()));
    }

    str::from_utf8(&output.stdout)
        .map(str::trim)
        .map(str::to_owned)
        .context("Invalid UTF-8 output from osascript")
}

fn build_restore_script(session: &SafariSession) -> String {
    let mut script = String::from("tell application \"Safari\"\nactivate\n");

    for window in &session.windows {
        let urls: Vec<&str> = window
            .tabs
            .iter()
            .map(|tab| tab.url.trim())
            .filter(|url| !url.is_empty())
            .collect();

        if urls.is_empty() {
            continue;
        }

        script.push_str(&format!(
            "make new document with properties {{URL:\"{}\"}}\n",
            utils::applescript_escape(urls[0])
        ));
        script.push_str("set safariWindow to front window\n");

        for url in urls.iter().skip(1) {
            script.push_str(&format!(
                "tell safariWindow\nset newTab to make new tab at end of tabs\nset URL of newTab to \"{}\"\nend tell\n",
                utils::applescript_escape(url)
            ));
        }
    }

    script.push_str("end tell\n");
    script
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::backend::models::{SafariSession, SessionTab, SessionWindow};

    #[test]
    fn builds_restore_script_with_grouped_windows() {
        let session = SafariSession::new(
            "2026-04-15T12:30:00.000+08:00".to_string(),
            vec![
                SessionWindow {
                    title: "Window 1".to_string(),
                    tabs: vec![
                        SessionTab {
                            title: "One".to_string(),
                            url: "https://one.example".to_string(),
                        },
                        SessionTab {
                            title: "Two".to_string(),
                            url: "https://two.example".to_string(),
                        },
                    ],
                },
                SessionWindow {
                    title: "Window 2".to_string(),
                    tabs: vec![SessionTab {
                        title: "Three".to_string(),
                        url: "https://three.example".to_string(),
                    }],
                },
            ],
        );

        let script = build_restore_script(&session);
        assert!(script.contains("make new document"));
        assert!(script.contains("set safariWindow to front window"));
        assert!(script.contains("set newTab to make new tab at end of tabs"));
        assert!(script.contains("set URL of newTab"));
        assert!(script.contains("https://three.example"));
    }
}