|
| 1 | +use std::path::PathBuf; |
| 2 | + |
1 | 3 | use anyhow::Result; |
| 4 | +use serde::{Deserialize, Serialize}; |
| 5 | +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; |
2 | 6 | use url::Url; |
3 | 7 |
|
| 8 | +use crate::bookmarks::BookmarkStore; |
4 | 9 | use crate::cli::HeadlessAction; |
| 10 | +use crate::navigation::{InProcessNavigator, NavigationService}; |
| 11 | +use crate::tab::{TabId, TabManager, TabState}; |
5 | 12 |
|
6 | | -pub fn run_headless(url: Option<Url>, action: HeadlessAction, _allow_http: bool) -> Result<()> { |
| 13 | +pub fn run_headless( |
| 14 | + url: Option<Url>, |
| 15 | + action: HeadlessAction, |
| 16 | + allow_http: bool, |
| 17 | + data_dir: Option<String>, |
| 18 | +) -> Result<()> { |
7 | 19 | let rt = tokio::runtime::Runtime::new()?; |
8 | 20 | rt.block_on(async { |
9 | 21 | match action { |
10 | | - HeadlessAction::DumpSource => { |
11 | | - tracing::info!(url = ?url, "dump-source mode"); |
12 | | - println!("source dump not yet wired (need ie-net integration)"); |
| 22 | + HeadlessAction::DumpSource => run_dump_source(url.unwrap(), allow_http).await, |
| 23 | + HeadlessAction::DumpStatus => run_dump_status(url.unwrap(), allow_http).await, |
| 24 | + HeadlessAction::Interactive => run_interactive(allow_http, data_dir).await, |
| 25 | + } |
| 26 | + }) |
| 27 | +} |
| 28 | + |
| 29 | +async fn run_dump_source(url: Url, allow_http: bool) -> Result<()> { |
| 30 | + let navigator = InProcessNavigator::new()?.with_https_only(!allow_http); |
| 31 | + match navigator.navigate(&url).await { |
| 32 | + Ok(result) => { |
| 33 | + let text = String::from_utf8_lossy(&result.body); |
| 34 | + print!("{text}"); |
| 35 | + Ok(()) |
| 36 | + } |
| 37 | + Err(e) => { |
| 38 | + eprintln!("{e}"); |
| 39 | + std::process::exit(1); |
| 40 | + } |
| 41 | + } |
| 42 | +} |
| 43 | + |
| 44 | +async fn run_dump_status(url: Url, allow_http: bool) -> Result<()> { |
| 45 | + let navigator = InProcessNavigator::new()?.with_https_only(!allow_http); |
| 46 | + match navigator.navigate(&url).await { |
| 47 | + Ok(result) => { |
| 48 | + println!("{}", result.status); |
| 49 | + Ok(()) |
| 50 | + } |
| 51 | + Err(e) => { |
| 52 | + eprintln!("{e}"); |
| 53 | + std::process::exit(1); |
| 54 | + } |
| 55 | + } |
| 56 | +} |
| 57 | + |
| 58 | +// --- Interactive headless mode --- |
| 59 | + |
| 60 | +#[derive(Deserialize)] |
| 61 | +#[serde(tag = "cmd")] |
| 62 | +enum Command { |
| 63 | + #[serde(rename = "navigate")] |
| 64 | + Navigate { url: String }, |
| 65 | + #[serde(rename = "get_source")] |
| 66 | + GetSource, |
| 67 | + #[serde(rename = "get_tabs")] |
| 68 | + GetTabs, |
| 69 | + #[serde(rename = "new_tab")] |
| 70 | + NewTab, |
| 71 | + #[serde(rename = "close_tab")] |
| 72 | + CloseTab { id: u64 }, |
| 73 | + #[serde(rename = "switch_tab")] |
| 74 | + SwitchTab { id: u64 }, |
| 75 | + #[serde(rename = "go_back")] |
| 76 | + GoBack, |
| 77 | + #[serde(rename = "go_forward")] |
| 78 | + GoForward, |
| 79 | + #[serde(rename = "bookmark_add")] |
| 80 | + BookmarkAdd { url: String, title: String }, |
| 81 | + #[serde(rename = "bookmark_list")] |
| 82 | + BookmarkList, |
| 83 | + #[serde(rename = "quit")] |
| 84 | + Quit, |
| 85 | +} |
| 86 | + |
| 87 | +#[derive(Serialize)] |
| 88 | +struct Response { |
| 89 | + ok: bool, |
| 90 | + #[serde(skip_serializing_if = "Option::is_none")] |
| 91 | + data: Option<serde_json::Value>, |
| 92 | + #[serde(skip_serializing_if = "Option::is_none")] |
| 93 | + error: Option<String>, |
| 94 | +} |
| 95 | + |
| 96 | +impl Response { |
| 97 | + fn ok_empty() -> Self { |
| 98 | + Self { |
| 99 | + ok: true, |
| 100 | + data: None, |
| 101 | + error: None, |
| 102 | + } |
| 103 | + } |
| 104 | + |
| 105 | + fn ok_data(data: serde_json::Value) -> Self { |
| 106 | + Self { |
| 107 | + ok: true, |
| 108 | + data: Some(data), |
| 109 | + error: None, |
| 110 | + } |
| 111 | + } |
| 112 | + |
| 113 | + fn err(msg: String) -> Self { |
| 114 | + Self { |
| 115 | + ok: false, |
| 116 | + data: None, |
| 117 | + error: Some(msg), |
| 118 | + } |
| 119 | + } |
| 120 | +} |
| 121 | + |
| 122 | +struct HeadlessSession { |
| 123 | + tab_manager: TabManager, |
| 124 | + bookmark_store: BookmarkStore, |
| 125 | + navigator: InProcessNavigator, |
| 126 | +} |
| 127 | + |
| 128 | +impl HeadlessSession { |
| 129 | + fn new(allow_http: bool, data_dir: Option<String>) -> Result<Self> { |
| 130 | + let navigator = InProcessNavigator::new()?.with_https_only(!allow_http); |
| 131 | + let bookmark_path = data_dir |
| 132 | + .map(PathBuf::from) |
| 133 | + .unwrap_or_else(|| std::env::temp_dir().join("ie-headless")); |
| 134 | + let bookmark_store = BookmarkStore::new(&bookmark_path)?; |
| 135 | + Ok(Self { |
| 136 | + tab_manager: TabManager::new(), |
| 137 | + bookmark_store, |
| 138 | + navigator, |
| 139 | + }) |
| 140 | + } |
| 141 | + |
| 142 | + async fn handle_command(&mut self, cmd: Command) -> Response { |
| 143 | + match cmd { |
| 144 | + Command::Navigate { url } => self.handle_navigate(url).await, |
| 145 | + Command::GetSource => self.handle_get_source(), |
| 146 | + Command::GetTabs => self.handle_get_tabs(), |
| 147 | + Command::NewTab => self.handle_new_tab(), |
| 148 | + Command::CloseTab { id } => self.handle_close_tab(id), |
| 149 | + Command::SwitchTab { id } => self.handle_switch_tab(id), |
| 150 | + Command::GoBack => self.handle_go_back(), |
| 151 | + Command::GoForward => self.handle_go_forward(), |
| 152 | + Command::BookmarkAdd { url, title } => self.handle_bookmark_add(url, title), |
| 153 | + Command::BookmarkList => self.handle_bookmark_list(), |
| 154 | + Command::Quit => Response::ok_empty(), |
| 155 | + } |
| 156 | + } |
| 157 | + |
| 158 | + async fn handle_navigate(&mut self, input: String) -> Response { |
| 159 | + let url = match Url::parse(&input) { |
| 160 | + Ok(url) => url, |
| 161 | + Err(_) => match Url::parse(&format!("https://{input}")) { |
| 162 | + Ok(url) => url, |
| 163 | + Err(e) => return Response::err(format!("invalid URL: {e}")), |
| 164 | + }, |
| 165 | + }; |
| 166 | + |
| 167 | + if let Some(tab) = self.tab_manager.active_tab_mut() { |
| 168 | + tab.state = TabState::Loading; |
| 169 | + tab.url = Some(url.clone()); |
| 170 | + } |
| 171 | + |
| 172 | + // Sequential: navigate blocks until complete |
| 173 | + match self.navigator.navigate(&url).await { |
| 174 | + Ok(result) => { |
| 175 | + let source = String::from_utf8(result.body).ok(); |
| 176 | + if let Some(tab) = self.tab_manager.active_tab_mut() { |
| 177 | + tab.state = TabState::Loaded; |
| 178 | + tab.title = result |
| 179 | + .final_url |
| 180 | + .host_str() |
| 181 | + .unwrap_or("Untitled") |
| 182 | + .to_string(); |
| 183 | + tab.push_history(result.final_url.clone(), source.clone()); |
| 184 | + tab.source = source; |
| 185 | + } |
| 186 | + Response::ok_data(serde_json::json!({ |
| 187 | + "status": result.status, |
| 188 | + "url": result.final_url.as_str(), |
| 189 | + })) |
13 | 190 | } |
14 | | - HeadlessAction::DumpStatus => { |
15 | | - tracing::info!(url = ?url, "dump-status mode"); |
16 | | - println!("status dump not yet wired (need ie-net integration)"); |
| 191 | + Err(e) => { |
| 192 | + if let Some(tab) = self.tab_manager.active_tab_mut() { |
| 193 | + tab.state = TabState::Error(e.to_string()); |
| 194 | + } |
| 195 | + Response::err(e.to_string()) |
17 | 196 | } |
18 | | - HeadlessAction::Interactive => { |
19 | | - tracing::info!("interactive headless mode"); |
20 | | - eprintln!("interactive headless mode not yet implemented"); |
| 197 | + } |
| 198 | + } |
| 199 | + |
| 200 | + fn handle_get_source(&self) -> Response { |
| 201 | + match self.tab_manager.active_tab() { |
| 202 | + Some(tab) => match &tab.source { |
| 203 | + Some(source) => Response::ok_data(serde_json::Value::String(source.clone())), |
| 204 | + None => Response::err("no source available".to_string()), |
| 205 | + }, |
| 206 | + None => Response::err("no active tab".to_string()), |
| 207 | + } |
| 208 | + } |
| 209 | + |
| 210 | + fn handle_get_tabs(&self) -> Response { |
| 211 | + let tabs: Vec<serde_json::Value> = self |
| 212 | + .tab_manager |
| 213 | + .tabs() |
| 214 | + .iter() |
| 215 | + .map(|t| { |
| 216 | + let state = match &t.state { |
| 217 | + TabState::Blank => "blank", |
| 218 | + TabState::Loading => "loading", |
| 219 | + TabState::Loaded => "loaded", |
| 220 | + TabState::Error(_) => "error", |
| 221 | + }; |
| 222 | + serde_json::json!({ |
| 223 | + "id": t.id.0, |
| 224 | + "url": t.url.as_ref().map(|u| u.as_str()), |
| 225 | + "title": t.title, |
| 226 | + "state": state, |
| 227 | + }) |
| 228 | + }) |
| 229 | + .collect(); |
| 230 | + Response::ok_data(serde_json::Value::Array(tabs)) |
| 231 | + } |
| 232 | + |
| 233 | + fn handle_new_tab(&mut self) -> Response { |
| 234 | + let id = self.tab_manager.new_tab(); |
| 235 | + Response::ok_data(serde_json::json!({"id": id.0})) |
| 236 | + } |
| 237 | + |
| 238 | + fn handle_close_tab(&mut self, id: u64) -> Response { |
| 239 | + if self.tab_manager.close_tab(TabId(id)) { |
| 240 | + Response::ok_empty() |
| 241 | + } else { |
| 242 | + Response::err("tab not found".to_string()) |
| 243 | + } |
| 244 | + } |
| 245 | + |
| 246 | + fn handle_switch_tab(&mut self, id: u64) -> Response { |
| 247 | + if self.tab_manager.switch_to(TabId(id)) { |
| 248 | + Response::ok_empty() |
| 249 | + } else { |
| 250 | + Response::err("tab not found".to_string()) |
| 251 | + } |
| 252 | + } |
| 253 | + |
| 254 | + fn handle_go_back(&mut self) -> Response { |
| 255 | + if self.tab_manager.go_back() { |
| 256 | + let url = self |
| 257 | + .tab_manager |
| 258 | + .active_tab() |
| 259 | + .and_then(|t| t.url.as_ref()) |
| 260 | + .map(|u| u.as_str().to_string()) |
| 261 | + .unwrap_or_default(); |
| 262 | + Response::ok_data(serde_json::json!({"url": url})) |
| 263 | + } else { |
| 264 | + Response::err("no back history".to_string()) |
| 265 | + } |
| 266 | + } |
| 267 | + |
| 268 | + fn handle_go_forward(&mut self) -> Response { |
| 269 | + if self.tab_manager.go_forward() { |
| 270 | + let url = self |
| 271 | + .tab_manager |
| 272 | + .active_tab() |
| 273 | + .and_then(|t| t.url.as_ref()) |
| 274 | + .map(|u| u.as_str().to_string()) |
| 275 | + .unwrap_or_default(); |
| 276 | + Response::ok_data(serde_json::json!({"url": url})) |
| 277 | + } else { |
| 278 | + Response::err("no forward history".to_string()) |
| 279 | + } |
| 280 | + } |
| 281 | + |
| 282 | + fn handle_bookmark_add(&mut self, url: String, title: String) -> Response { |
| 283 | + match self.bookmark_store.add(&url, &title) { |
| 284 | + Ok(()) => Response::ok_empty(), |
| 285 | + Err(e) => Response::err(e.to_string()), |
| 286 | + } |
| 287 | + } |
| 288 | + |
| 289 | + fn handle_bookmark_list(&self) -> Response { |
| 290 | + let bookmarks: Vec<serde_json::Value> = self |
| 291 | + .bookmark_store |
| 292 | + .list() |
| 293 | + .iter() |
| 294 | + .map(|b| { |
| 295 | + serde_json::json!({ |
| 296 | + "url": b.url, |
| 297 | + "title": b.title, |
| 298 | + "created": b.created.to_rfc3339(), |
| 299 | + }) |
| 300 | + }) |
| 301 | + .collect(); |
| 302 | + Response::ok_data(serde_json::Value::Array(bookmarks)) |
| 303 | + } |
| 304 | +} |
| 305 | + |
| 306 | +async fn run_interactive(allow_http: bool, data_dir: Option<String>) -> Result<()> { |
| 307 | + let stdin = BufReader::new(tokio::io::stdin()); |
| 308 | + let mut stdout = tokio::io::stdout(); |
| 309 | + let mut session = HeadlessSession::new(allow_http, data_dir)?; |
| 310 | + |
| 311 | + let mut lines = stdin.lines(); |
| 312 | + while let Ok(Some(line)) = lines.next_line().await { |
| 313 | + let is_quit; |
| 314 | + let response = match serde_json::from_str::<Command>(&line) { |
| 315 | + Ok(cmd) => { |
| 316 | + is_quit = matches!(cmd, Command::Quit); |
| 317 | + session.handle_command(cmd).await |
| 318 | + } |
| 319 | + Err(e) => { |
| 320 | + is_quit = false; |
| 321 | + Response::err(format!("invalid command: {e}")) |
21 | 322 | } |
| 323 | + }; |
| 324 | + let json = serde_json::to_string(&response)?; |
| 325 | + stdout.write_all(json.as_bytes()).await?; |
| 326 | + stdout.write_all(b"\n").await?; |
| 327 | + stdout.flush().await?; |
| 328 | + if is_quit { |
| 329 | + break; |
22 | 330 | } |
23 | | - Ok(()) |
24 | | - }) |
| 331 | + } |
| 332 | + Ok(()) |
25 | 333 | } |
0 commit comments