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"));
}
}