Skip to main content

browser_commander/browser/
safari.rs

1//! Installed Safari and Technology Preview through classic W3C WebDriver.
2
3use super::{
4    browser_process::BrowserCloser,
5    find_browser_source,
6    launcher::Browser,
7    real_browser::{RealBrowserLaunchResult, RealBrowserOptions},
8    webdriver::{launch_webdriver, ManagedWebDriver, WebDriverBrowser, WebDriverOptions},
9};
10use crate::core::engine::{EngineAdapter, EngineError, EngineType};
11use anyhow::Result;
12use std::{path::PathBuf, sync::Arc, time::Duration};
13
14pub fn is_safari_channel(channel: &str) -> bool {
15    find_browser_source(channel).is_some_and(|source| source.family == "safari")
16}
17
18pub fn unsupported(feature: &str) -> EngineError {
19    EngineError::Unsupported {
20        browser: "safari".into(),
21        feature: feature.into(),
22    }
23}
24
25#[derive(Debug, thiserror::Error)]
26#[error("Safari automation setup required: {cause}. In Safari → Settings → Advanced enable 'Show features for web developers', then Develop → 'Allow Remote Automation'. Run \"{driver}\" --enable once (an admin password may be required). Call open_safari_settings() to open Advanced settings.")]
27pub struct SafariSetupError {
28    #[source]
29    pub cause: anyhow::Error,
30    pub driver: String,
31}
32
33pub(crate) fn launch_error(error: anyhow::Error, browser: WebDriverBrowser) -> anyhow::Error {
34    let message = error.to_string().to_lowercase();
35    if message.contains("allow remote automation")
36        || message.contains("--enable")
37        || message.contains("not authorized")
38        || message.contains("remote automation is disabled")
39    {
40        SafariSetupError {
41            cause: error,
42            driver: driver_path(browser).into(),
43        }
44        .into()
45    } else {
46        error
47    }
48}
49
50/// safaridriver serves one Safari automation session at a time. Launched right
51/// after a previous session closed, CI saw a new driver exit with code 1 before
52/// it was ready, and a ready driver refuse the new-session request (issue
53/// #128). Both are start-up failures of a driver this process owns, so a Safari
54/// launch starts a fresh driver for them, a bounded number of times.
55const LAUNCH_ATTEMPTS: u32 = 3;
56const RETRY_DELAY: Duration = Duration::from_secs(1);
57
58/// Whether a failed launch is a driver start-up failure worth a fresh driver.
59pub(crate) fn is_transient_start_error(error: &anyhow::Error) -> bool {
60    if error.downcast_ref::<SafariSetupError>().is_some() {
61        return false;
62    }
63    let refused = error.chain().any(|cause| {
64        cause.downcast_ref::<std::io::Error>().is_some_and(|io| {
65            matches!(
66                io.kind(),
67                std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::ConnectionReset
68            )
69        })
70    });
71    let message = format!("{error:#}").to_lowercase();
72    refused
73        || message.contains("webdriver exited with")
74        || message.contains("connection refused")
75        || message.contains("connection reset")
76}
77
78async fn launch_webdriver_with_retry(options: WebDriverOptions) -> Result<ManagedWebDriver> {
79    let mut attempt = 1;
80    loop {
81        match launch_webdriver(options.clone()).await {
82            Ok(driver) => return Ok(driver),
83            Err(error) if attempt < LAUNCH_ATTEMPTS && is_transient_start_error(&error) => {
84                if crate::core::logger::is_verbose_enabled() {
85                    eprintln!(
86                        "[webdriver] Safari launch attempt {attempt} failed, retrying in {}ms: {error:#}",
87                        RETRY_DELAY.as_millis()
88                    );
89                }
90                attempt += 1;
91                tokio::time::sleep(RETRY_DELAY).await;
92            }
93            Err(error) => return Err(error),
94        }
95    }
96}
97
98fn driver_path(browser: WebDriverBrowser) -> &'static str {
99    if browser == WebDriverBrowser::SafariTechnologyPreview {
100        "/Applications/Safari Technology Preview.app/Contents/MacOS/safaridriver"
101    } else {
102        "/usr/bin/safaridriver"
103    }
104}
105
106/// Open Advanced settings on explicit request. Authorization stays manual.
107pub async fn open_safari_settings(technology_preview: bool) -> Result<()> {
108    if !cfg!(target_os = "macos") {
109        return Err(unsupported("Safari settings outside macOS").into());
110    }
111    let app = if technology_preview {
112        "Safari Technology Preview"
113    } else {
114        "Safari"
115    };
116    crate::utilities::run_command("osascript", &[
117        "-e".into(), format!("tell application \"{app}\" to activate"),
118        "-e".into(), format!("tell application \"{app}\" to open location \"x-safari-preferences:com.apple.Safari.preferences.Advanced\"")
119    ], Default::default()).await?;
120    Ok(())
121}
122
123#[async_trait::async_trait]
124impl BrowserCloser for ManagedWebDriver {
125    async fn close(&self) -> Result<()> {
126        ManagedWebDriver::close(self).await
127    }
128}
129
130/// Launch Safari without creating a disk profile. Cookies are opt-in.
131pub async fn launch_safari_real(options: RealBrowserOptions) -> Result<RealBrowserLaunchResult> {
132    launch_safari_real_with_webdriver(options, WebDriverOptions::default()).await
133}
134
135pub(crate) async fn launch_safari_real_with_webdriver(
136    options: RealBrowserOptions,
137    webdriver_options: WebDriverOptions,
138) -> Result<RealBrowserLaunchResult> {
139    for (feature, requested) in [
140        ("launch restrictions", !options.restrictions.is_empty()),
141        (
142            "profile migration (use imported seed cookies)",
143            options.migrate_from.is_some(),
144        ),
145        ("CDP port", options.remote_debugging_port.is_some()),
146    ] {
147        if requested {
148            return Err(unsupported(feature).into());
149        }
150    }
151    let source = find_browser_source(&options.channel)
152        .ok_or_else(|| anyhow::anyhow!("Unknown Safari channel"))?;
153    let browser = if source.id == "safari-technology-preview" {
154        WebDriverBrowser::SafariTechnologyPreview
155    } else {
156        WebDriverBrowser::Safari
157    };
158    let executable = options
159        .executable_path
160        .clone()
161        .or_else(|| webdriver_options.driver_executable.clone())
162        .unwrap_or_else(|| PathBuf::from(driver_path(browser)));
163    let native = Arc::new(
164        launch_webdriver_with_retry(WebDriverOptions {
165            browser,
166            driver_executable: options
167                .executable_path
168                .or(webdriver_options.driver_executable.clone()),
169            user_data_dir: options.user_data_dir,
170            headless: options.headless,
171            args: [
172                options.args,
173                options.extra_args,
174                webdriver_options.args.clone(),
175            ]
176            .concat(),
177            preferences: options.preferences,
178            local_state: options.local_state,
179            default_browser_check: options.default_browser_check,
180            first_run: options.first_run,
181            env: options.env,
182            downloads: options.downloads,
183            launch_timeout: options.startup_timeout,
184            bidi: false,
185            ..webdriver_options
186        })
187        .await?,
188    );
189    let seed = async {
190        let mut state = options
191            .storage_state
192            .map(|input| input.load())
193            .transpose()?
194            .unwrap_or(super::storage_state::StorageState {
195                cookies: Vec::new(),
196                origins: Vec::new(),
197            });
198        state.cookies.extend(options.seed_cookies);
199        if !state.cookies.is_empty() || !state.origins.is_empty() {
200            native.restore_state(state).await?;
201        }
202        Ok::<(), anyhow::Error>(())
203    }
204    .await;
205    if let Err(error) = seed {
206        native.close().await?;
207        return Err(error);
208    }
209    let process = native.process_handle();
210    let page: Arc<dyn EngineAdapter> = native.clone();
211    Ok(RealBrowserLaunchResult {
212        browser: Browser {
213            engine: EngineType::Fantoccini,
214            user_data_dir: PathBuf::new(),
215            headless: false,
216        },
217        page,
218        cdp_endpoint: String::new(),
219        remote_debugging_port: 0,
220        executable_path: executable,
221        user_data_dir: PathBuf::new(),
222        temporary_profile: false,
223        args: Vec::new(),
224        browser_process: process,
225        downloads: None,
226        migration: None,
227        closer: native.clone(),
228        webdriver: Some(native),
229    })
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn driver_start_failures_are_transient() {
238        for error in [
239            anyhow::anyhow!("WebDriver exited with 1: "),
240            anyhow::Error::new(std::io::Error::from(std::io::ErrorKind::ConnectionRefused))
241                .context("webdriver did not respond"),
242            anyhow::anyhow!("tcp connect error: Connection refused (os error 61)"),
243        ] {
244            assert!(is_transient_start_error(&error), "{error:#}");
245        }
246    }
247
248    #[test]
249    fn setup_and_other_failures_are_not_retried() {
250        let setup = launch_error(
251            anyhow::anyhow!("You must enable the 'Allow Remote Automation' option"),
252            WebDriverBrowser::Safari,
253        );
254        assert!(!is_transient_start_error(&setup));
255        assert!(!is_transient_start_error(&anyhow::anyhow!(
256            "WebDriver launch timed out"
257        )));
258    }
259}