Skip to content

Commit 1dac8d6

Browse files
committed
feat(ie-shell): implement headless navigation pipeline with JSON protocol #9
Wire ie-net into headless mode for the first end-to-end vertical slice: - --dump-source: fetches URL, prints HTML to stdout - --dump-status: fetches URL, prints HTTP status code - Interactive JSON protocol over stdin/stdout with commands: navigate, get_source, get_tabs, new_tab, close_tab, switch_tab, go_back, go_forward, bookmark_add, bookmark_list, quit - --data-dir flag for bookmark persistence path - --allow-http passed through to InProcessNavigator - Malformed JSON input returns error without crashing - 11 integration tests covering all commands and error cases
1 parent 7b05495 commit 1dac8d6

5 files changed

Lines changed: 552 additions & 18 deletions

File tree

crates/ie-shell/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,4 @@ bytes.workspace = true
3131
http-body-util.workspace = true
3232
hyper.workspace = true
3333
hyper-util.workspace = true
34+
serde_json.workspace = true

crates/ie-shell/src/cli.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ pub struct Cli {
2727
/// Allow plain HTTP navigation (default: HTTPS-only)
2828
#[arg(long)]
2929
pub allow_http: bool,
30+
31+
/// Override data directory (bookmarks, etc.)
32+
#[arg(long)]
33+
pub data_dir: Option<String>,
3034
}
3135

3236
#[derive(Debug, PartialEq)]

crates/ie-shell/src/headless.rs

Lines changed: 320 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,333 @@
1+
use std::path::PathBuf;
2+
13
use anyhow::Result;
4+
use serde::{Deserialize, Serialize};
5+
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
26
use url::Url;
37

8+
use crate::bookmarks::BookmarkStore;
49
use crate::cli::HeadlessAction;
10+
use crate::navigation::{InProcessNavigator, NavigationService};
11+
use crate::tab::{TabId, TabManager, TabState};
512

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<()> {
719
let rt = tokio::runtime::Runtime::new()?;
820
rt.block_on(async {
921
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+
}))
13190
}
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())
17196
}
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}"))
21322
}
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;
22330
}
23-
Ok(())
24-
})
331+
}
332+
Ok(())
25333
}

crates/ie-shell/src/main.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ fn main() -> Result<()> {
3333

3434
match cli.mode()? {
3535
Mode::Gui { url } => run_gui(url, cli.allow_http),
36-
Mode::Headless { url, action } => headless::run_headless(url, action, cli.allow_http),
36+
Mode::Headless { url, action } => {
37+
headless::run_headless(url, action, cli.allow_http, cli.data_dir)
38+
}
3739
}
3840
}
3941

0 commit comments

Comments
 (0)