browser_commander/browser/
safari.rs1use 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
50const LAUNCH_ATTEMPTS: u32 = 3;
56const RETRY_DELAY: Duration = Duration::from_secs(1);
57
58pub(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
106pub 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
130pub 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}