1use anyhow::{anyhow, Result};
4use crossterm::event::{
5 self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind,
6};
7use crossterm::execute;
8use crossterm::terminal::{
9 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
10};
11use ratatui::backend::CrosstermBackend;
12use ratatui::layout::{Constraint, Direction, Layout};
13use ratatui::style::{Color, Modifier, Style};
14use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
15use ratatui::Terminal;
16use std::io::stdout;
17
18use crate::client::RommClient;
19use crate::config::{
20 load_config, load_layered_env, normalize_romm_origin, persist_user_config, AuthConfig, Config,
21};
22
23#[derive(Clone, Copy, PartialEq, Eq)]
24enum AuthKind {
25 None,
26 Basic,
27 Bearer,
28 ApiKey,
29}
30
31#[derive(Clone, Copy, PartialEq, Eq)]
32enum Step {
33 Url,
34 Https,
35 Download,
36 AuthMenu,
37 BasicUser,
38 BasicPass,
39 Bearer,
40 ApiHeader,
41 ApiKey,
42 Summary,
43}
44
45pub struct SetupWizard {
47 step: Step,
48 auth_kind: AuthKind,
49 auth_menu_selected: usize,
50 url: String,
51 url_cursor: usize,
52 download_dir: String,
53 dl_cursor: usize,
54 username: String,
55 user_cursor: usize,
56 password: String,
57 bearer_token: String,
58 bearer_cursor: usize,
59 api_header: String,
60 header_cursor: usize,
61 api_key: String,
62 api_key_cursor: usize,
63 testing: bool,
64 use_https: bool,
65 error: Option<String>,
66}
67
68impl SetupWizard {
69 pub fn new() -> Self {
70 let default_dl = dirs::download_dir()
71 .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Downloads"))
72 .join("romm-cli")
73 .display()
74 .to_string();
75 Self {
76 step: Step::Url,
77 auth_kind: AuthKind::None,
78 auth_menu_selected: 0,
79 url: "https://".to_string(),
80 url_cursor: "https://".len(),
81 download_dir: default_dl,
82 dl_cursor: 0,
83 username: String::new(),
84 user_cursor: 0,
85 password: String::new(),
86 bearer_token: String::new(),
87 bearer_cursor: 0,
88 api_header: String::new(),
89 header_cursor: 0,
90 api_key: String::new(),
91 api_key_cursor: 0,
92 testing: false,
93 use_https: true,
94 error: None,
95 }
96 }
97
98 fn auth_labels() -> [&'static str; 4] {
99 [
100 "No authentication",
101 "Basic (username + password)",
102 "Bearer token",
103 "API key in custom header",
104 ]
105 }
106
107 fn auth_kind_from_index(i: usize) -> AuthKind {
108 match i {
109 1 => AuthKind::Basic,
110 2 => AuthKind::Bearer,
111 3 => AuthKind::ApiKey,
112 _ => AuthKind::None,
113 }
114 }
115
116 fn build_config(&self) -> Result<Config> {
117 let base_url = normalize_romm_origin(self.url.trim());
118 if base_url.is_empty() {
119 return Err(anyhow!("Server URL cannot be empty"));
120 }
121 let auth: Option<AuthConfig> = match self.auth_kind {
122 AuthKind::None => None,
123 AuthKind::Basic => {
124 let u = self.username.trim();
125 if u.is_empty() {
126 return Err(anyhow!("Username cannot be empty"));
127 }
128 if self.password.is_empty() {
129 return Err(anyhow!("Password cannot be empty"));
130 }
131 Some(AuthConfig::Basic {
132 username: u.to_string(),
133 password: self.password.clone(),
134 })
135 }
136 AuthKind::Bearer => {
137 if self.bearer_token.trim().is_empty() {
138 return Err(anyhow!("Bearer token cannot be empty"));
139 }
140 Some(AuthConfig::Bearer {
141 token: self.bearer_token.trim().to_string(),
142 })
143 }
144 AuthKind::ApiKey => {
145 let h = self.api_header.trim();
146 if h.is_empty() {
147 return Err(anyhow!("Header name cannot be empty"));
148 }
149 if self.api_key.is_empty() {
150 return Err(anyhow!("API key cannot be empty"));
151 }
152 Some(AuthConfig::ApiKey {
153 header: h.to_string(),
154 key: self.api_key.clone(),
155 })
156 }
157 };
158 Ok(Config {
159 base_url,
160 download_dir: self.download_dir.trim().to_string(),
161 use_https: self.use_https,
162 auth,
163 })
164 }
165
166 fn render(&mut self, f: &mut ratatui::Frame, area: ratatui::layout::Rect) {
167 let title = match self.step {
168 Step::Url => "Step 1/5 — RomM server URL",
169 Step::Https => "Step 2/5 — Secure connection",
170 Step::Download => "Step 3/5 — Download directory",
171 Step::AuthMenu => "Step 4/5 — Authentication",
172 Step::BasicUser | Step::BasicPass => "Step 5/5 — Basic auth",
173 Step::Bearer => "Step 5/5 — Bearer token",
174 Step::ApiHeader | Step::ApiKey => "Step 5/5 — API key",
175 Step::Summary => "Review & connect",
176 };
177
178 let main = Layout::default()
179 .direction(Direction::Vertical)
180 .constraints([
181 Constraint::Length(3),
182 Constraint::Min(6),
183 Constraint::Length(4),
184 ])
185 .split(area);
186
187 let hint_top = "Same origin as in your browser (no trailing /api). Esc: quit";
188 let p = Paragraph::new(hint_top).style(Style::default().fg(Color::DarkGray));
189 f.render_widget(p, main[0]);
190
191 match self.step {
192 Step::Url => {
193 let line = format!(
194 "{}▏",
195 self.url.chars().take(self.url_cursor).collect::<String>()
196 );
197 let rest: String = self.url.chars().skip(self.url_cursor).collect();
198 let text = format!("{line}{rest}");
199 let block = Block::default().title(title).borders(Borders::ALL);
200 let p = Paragraph::new(text).block(block);
201 f.render_widget(p, main[1]);
202 }
203 Step::Https => {
204 let text = if self.use_https {
205 "[X] Use HTTPS (Recommended)"
206 } else {
207 "[ ] Use HTTPS (Insecure)"
208 };
209 let block = Block::default().title(title).borders(Borders::ALL);
210 let p = Paragraph::new(format!("\n {}\n\n Space: toggle Enter: next", text))
211 .block(block);
212 f.render_widget(p, main[1]);
213 }
214 Step::Download => {
215 let line = format!(
216 "{}▏",
217 self.download_dir
218 .chars()
219 .take(self.dl_cursor)
220 .collect::<String>()
221 );
222 let rest: String = self.download_dir.chars().skip(self.dl_cursor).collect();
223 let text = format!("{line}{rest}");
224 let block = Block::default().title(title).borders(Borders::ALL);
225 let p = Paragraph::new(text).block(block);
226 f.render_widget(p, main[1]);
227 }
228 Step::AuthMenu => {
229 let items: Vec<ListItem> = Self::auth_labels()
230 .iter()
231 .map(|s| ListItem::new(*s))
232 .collect();
233 let mut state = ListState::default();
234 state.select(Some(self.auth_menu_selected));
235 let list = List::new(items)
236 .block(Block::default().title(title).borders(Borders::ALL))
237 .highlight_style(
238 Style::default()
239 .fg(Color::Yellow)
240 .add_modifier(Modifier::BOLD),
241 )
242 .highlight_symbol(">> ");
243 f.render_stateful_widget(list, main[1], &mut state);
244 }
245 Step::BasicUser | Step::BasicPass => {
246 let user_line = if self.step == Step::BasicUser {
247 format!(
248 "{}▏{}",
249 self.username
250 .chars()
251 .take(self.user_cursor)
252 .collect::<String>(),
253 self.username
254 .chars()
255 .skip(self.user_cursor)
256 .collect::<String>()
257 )
258 } else {
259 self.username.clone()
260 };
261 let pass_display: String = if self.step == Step::BasicPass {
262 "•".repeat(self.password.len()) + "▏"
263 } else {
264 "•".repeat(self.password.len())
265 };
266 let block = Block::default().title(title).borders(Borders::ALL);
267 let body = format!("Username\n{user_line}\n\nPassword (hidden)\n{pass_display}\n\nTab: switch field");
268 let p = Paragraph::new(body).block(block);
269 f.render_widget(p, main[1]);
270 }
271 Step::Bearer => {
272 let line = format!(
273 "{}▏{}",
274 self.bearer_token
275 .chars()
276 .take(self.bearer_cursor)
277 .collect::<String>(),
278 self.bearer_token
279 .chars()
280 .skip(self.bearer_cursor)
281 .collect::<String>()
282 );
283 let block = Block::default().title(title).borders(Borders::ALL);
284 let p = Paragraph::new(line).block(block);
285 f.render_widget(p, main[1]);
286 }
287 Step::ApiHeader | Step::ApiKey => {
288 let header_line = if self.step == Step::ApiHeader {
289 format!(
290 "{}▏{}",
291 self.api_header
292 .chars()
293 .take(self.header_cursor)
294 .collect::<String>(),
295 self.api_header
296 .chars()
297 .skip(self.header_cursor)
298 .collect::<String>()
299 )
300 } else {
301 self.api_header.clone()
302 };
303 let key_line = if self.step == Step::ApiKey {
304 "•".repeat(self.api_key.len()) + "▏"
305 } else {
306 "•".repeat(self.api_key.len())
307 };
308 let body = format!(
309 "Header name\n{header_line}\n\nKey (hidden)\n{key_line}\n\nTab: switch field"
310 );
311 let block = Block::default().title(title).borders(Borders::ALL);
312 let p = Paragraph::new(body).block(block);
313 f.render_widget(p, main[1]);
314 }
315 Step::Summary => {
316 let url_line = normalize_romm_origin(self.url.trim());
317 let auth_desc = match self.auth_kind {
318 AuthKind::None => "None",
319 AuthKind::Basic => "Basic",
320 AuthKind::Bearer => "Bearer",
321 AuthKind::ApiKey => "API key header",
322 };
323 let mut lines = vec![
324 format!("Server: {url_line}"),
325 format!("Downloads: {}", self.download_dir.trim()),
326 format!("Use HTTPS: {}", if self.use_https { "Yes" } else { "No" }),
327 format!("Auth: {auth_desc}"),
328 String::new(),
329 ];
330 if self.testing {
331 lines.push("Connecting to server…".to_string());
332 } else if let Some(ref e) = self.error {
333 lines.push(format!("Last error: {e}"));
334 } else {
335 lines.push("Enter: test connection and save Esc: quit".to_string());
336 }
337 let block = Block::default().title(title).borders(Borders::ALL);
338 let p = Paragraph::new(lines.join("\n")).block(block);
339 f.render_widget(p, main[1]);
340 }
341 }
342
343 let footer = match self.step {
344 Step::Url => "Enter: next Backspace: delete Esc: quit",
345 Step::Https => "Space: toggle Enter: next Esc: quit",
346 Step::Download => "Enter: next Backspace: delete Esc: quit",
347 Step::AuthMenu => "↑/↓: choose Enter: next Esc: quit",
348 Step::BasicUser | Step::BasicPass => {
349 "Type text Tab: switch field Enter: next step Esc: quit"
350 }
351 Step::Bearer => "Enter: next step Esc: quit",
352 Step::ApiHeader | Step::ApiKey => "Tab: switch field Enter: next step Esc: quit",
353 Step::Summary => {
354 if self.testing {
355 "Please wait…"
356 } else {
357 "Enter: connect & save"
358 }
359 }
360 };
361 let p = Paragraph::new(footer)
362 .style(Style::default().fg(Color::Cyan))
363 .block(Block::default().borders(Borders::ALL));
364 f.render_widget(p, main[2]);
365 }
366
367 fn cursor_pos(&self, area: ratatui::layout::Rect) -> Option<(u16, u16)> {
368 let main = Layout::default()
369 .direction(Direction::Vertical)
370 .constraints([
371 Constraint::Length(3),
372 Constraint::Min(6),
373 Constraint::Length(4),
374 ])
375 .split(area);
376 let inner = main[1];
377 match self.step {
378 Step::Url => {
379 let x = inner.x + 1 + self.url_cursor.min(self.url.len()) as u16;
380 Some((x, inner.y + 1))
381 }
382 Step::Download => {
383 let x = inner.x + 1 + self.dl_cursor.min(self.download_dir.len()) as u16;
384 Some((x, inner.y + 1))
385 }
386 Step::Bearer => {
387 let x = inner.x + 1 + self.bearer_cursor.min(self.bearer_token.len()) as u16;
388 Some((x, inner.y + 1))
389 }
390 Step::BasicUser => {
391 let x = inner.x + 1 + self.user_cursor.min(self.username.len()) as u16;
392 Some((x, inner.y + 2))
393 }
394 Step::BasicPass => {
395 let x = inner.x + 1 + "•".repeat(self.password.len()).len() as u16;
396 Some((x, inner.y + 6))
397 }
398 Step::ApiHeader => {
399 let x = inner.x + 1 + self.header_cursor.min(self.api_header.len()) as u16;
400 Some((x, inner.y + 2))
401 }
402 Step::ApiKey => {
403 let x = inner.x + 1 + self.api_key_cursor.min(self.api_key.len()) as u16;
404 Some((x, inner.y + 6))
405 }
406 Step::Https | Step::AuthMenu | Step::Summary => None,
407 }
408 }
409
410 fn add_char_url(&mut self, c: char) {
411 let pos = self.url_cursor.min(self.url.len());
412 self.url.insert(pos, c);
413 self.url_cursor = pos + 1;
414 }
415
416 fn del_char_url(&mut self) {
417 if self.url_cursor > 0 && self.url_cursor <= self.url.len() {
418 self.url.remove(self.url_cursor - 1);
419 self.url_cursor -= 1;
420 }
421 }
422
423 fn add_char_dl(&mut self, c: char) {
424 let pos = self.dl_cursor.min(self.download_dir.len());
425 self.download_dir.insert(pos, c);
426 self.dl_cursor = pos + 1;
427 }
428
429 fn del_char_dl(&mut self) {
430 if self.dl_cursor > 0 && self.dl_cursor <= self.download_dir.len() {
431 self.download_dir.remove(self.dl_cursor - 1);
432 self.dl_cursor -= 1;
433 }
434 }
435
436 fn advance_from_auth_menu(&mut self) {
437 self.auth_kind = Self::auth_kind_from_index(self.auth_menu_selected);
438 self.step = match self.auth_kind {
439 AuthKind::None => Step::Summary,
440 AuthKind::Basic => Step::BasicUser,
441 AuthKind::Bearer => Step::Bearer,
442 AuthKind::ApiKey => Step::ApiHeader,
443 };
444 }
445
446 fn advance_step(&mut self) -> Result<()> {
447 self.error = None;
448 match self.step {
449 Step::Url => {
450 if normalize_romm_origin(self.url.trim()).is_empty() {
451 self.error = Some("Enter a valid server URL".to_string());
452 return Ok(());
453 }
454 self.step = Step::Https;
455 }
456 Step::Https => {
457 self.step = Step::Download;
458 self.dl_cursor = self.download_dir.len();
459 }
460 Step::Download => {
461 if self.download_dir.trim().is_empty() {
462 self.error = Some("Download path cannot be empty".to_string());
463 return Ok(());
464 }
465 self.step = Step::AuthMenu;
466 }
467 Step::AuthMenu => self.advance_from_auth_menu(),
468 Step::BasicUser => self.step = Step::BasicPass,
469 Step::BasicPass => self.step = Step::Summary,
470 Step::Bearer => self.step = Step::Summary,
471 Step::ApiHeader => self.step = Step::ApiKey,
472 Step::ApiKey => self.step = Step::Summary,
473 Step::Summary => {}
474 }
475 Ok(())
476 }
477
478 async fn try_connect_and_persist(&mut self, verbose: bool) -> Result<Config> {
479 let cfg = self.build_config()?;
480 let client = RommClient::new(&cfg, verbose)?;
481 client.fetch_openapi_json().await?;
482 let base = cfg.base_url.clone();
483 let download = self.download_dir.trim().to_string();
484 persist_user_config(&base, &download, self.use_https, cfg.auth.clone())?;
485 load_layered_env();
486 load_config()
487 }
488
489 pub async fn run(mut self, verbose: bool) -> Result<Config> {
490 enable_raw_mode()?;
491 let mut stdout = stdout();
492 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
493 let backend = CrosstermBackend::new(stdout);
494 let mut terminal = Terminal::new(backend)?;
495
496 loop {
497 terminal.draw(|f| {
498 let area = f.size();
499 self.render(f, area);
500 if let Some((x, y)) = self.cursor_pos(area) {
501 f.set_cursor(x, y);
502 }
503 })?;
504
505 if event::poll(std::time::Duration::from_millis(100))? {
506 if let Event::Key(key) = event::read()? {
507 if key.kind != KeyEventKind::Press {
508 continue;
509 }
510 if key.code == KeyCode::Esc {
511 disable_raw_mode()?;
512 execute!(
513 terminal.backend_mut(),
514 LeaveAlternateScreen,
515 DisableMouseCapture
516 )?;
517 terminal.show_cursor()?;
518 return Err(anyhow!("setup cancelled"));
519 }
520
521 if self.testing {
522 continue;
523 }
524
525 match self.step {
526 Step::Url => match key.code {
527 KeyCode::Enter => {
528 let _ = self.advance_step();
529 }
530 KeyCode::Char(c) => self.add_char_url(c),
531 KeyCode::Backspace => self.del_char_url(),
532 KeyCode::Left => {
533 if self.url_cursor > 0 {
534 self.url_cursor -= 1;
535 }
536 }
537 KeyCode::Right => {
538 if self.url_cursor < self.url.len() {
539 self.url_cursor += 1;
540 }
541 }
542 _ => {}
543 },
544 Step::Https => match key.code {
545 KeyCode::Enter => {
546 let _ = self.advance_step();
547 }
548 KeyCode::Char(' ') => self.use_https = !self.use_https,
549 _ => {}
550 },
551 Step::Download => match key.code {
552 KeyCode::Enter => {
553 let _ = self.advance_step();
554 }
555 KeyCode::Char(c) => self.add_char_dl(c),
556 KeyCode::Backspace => self.del_char_dl(),
557 KeyCode::Left => {
558 if self.dl_cursor > 0 {
559 self.dl_cursor -= 1;
560 }
561 }
562 KeyCode::Right => {
563 if self.dl_cursor < self.download_dir.len() {
564 self.dl_cursor += 1;
565 }
566 }
567 _ => {}
568 },
569 Step::AuthMenu => match key.code {
570 KeyCode::Up | KeyCode::Char('k') => {
571 if self.auth_menu_selected > 0 {
572 self.auth_menu_selected -= 1;
573 }
574 }
575 KeyCode::Down | KeyCode::Char('j') => {
576 if self.auth_menu_selected < 3 {
577 self.auth_menu_selected += 1;
578 }
579 }
580 KeyCode::Enter => {
581 let _ = self.advance_step();
582 }
583 _ => {}
584 },
585 Step::BasicUser => match key.code {
586 KeyCode::Tab => self.step = Step::BasicPass,
587 KeyCode::Enter => {
588 let _ = self.advance_step();
589 }
590 KeyCode::Char(c) => {
591 let pos = self.user_cursor.min(self.username.len());
592 self.username.insert(pos, c);
593 self.user_cursor = pos + 1;
594 }
595 KeyCode::Backspace => {
596 if self.user_cursor > 0 && self.user_cursor <= self.username.len() {
597 self.username.remove(self.user_cursor - 1);
598 self.user_cursor -= 1;
599 }
600 }
601 KeyCode::Left => {
602 if self.user_cursor > 0 {
603 self.user_cursor -= 1;
604 }
605 }
606 KeyCode::Right => {
607 if self.user_cursor < self.username.len() {
608 self.user_cursor += 1;
609 }
610 }
611 _ => {}
612 },
613 Step::BasicPass => match key.code {
614 KeyCode::Tab => self.step = Step::BasicUser,
615 KeyCode::Enter => {
616 let _ = self.advance_step();
617 }
618 KeyCode::Char(c) => self.password.push(c),
619 KeyCode::Backspace => {
620 self.password.pop();
621 }
622 _ => {}
623 },
624 Step::Bearer => match key.code {
625 KeyCode::Enter => {
626 let _ = self.advance_step();
627 }
628 KeyCode::Char(c) => {
629 let pos = self.bearer_cursor.min(self.bearer_token.len());
630 self.bearer_token.insert(pos, c);
631 self.bearer_cursor = pos + 1;
632 }
633 KeyCode::Backspace => {
634 if self.bearer_cursor > 0
635 && self.bearer_cursor <= self.bearer_token.len()
636 {
637 self.bearer_token.remove(self.bearer_cursor - 1);
638 self.bearer_cursor -= 1;
639 }
640 }
641 KeyCode::Left => {
642 if self.bearer_cursor > 0 {
643 self.bearer_cursor -= 1;
644 }
645 }
646 KeyCode::Right => {
647 if self.bearer_cursor < self.bearer_token.len() {
648 self.bearer_cursor += 1;
649 }
650 }
651 _ => {}
652 },
653 Step::ApiHeader => match key.code {
654 KeyCode::Tab => self.step = Step::ApiKey,
655 KeyCode::Enter => {
656 let _ = self.advance_step();
657 }
658 KeyCode::Char(c) => {
659 let pos = self.header_cursor.min(self.api_header.len());
660 self.api_header.insert(pos, c);
661 self.header_cursor = pos + 1;
662 }
663 KeyCode::Backspace => {
664 if self.header_cursor > 0
665 && self.header_cursor <= self.api_header.len()
666 {
667 self.api_header.remove(self.header_cursor - 1);
668 self.header_cursor -= 1;
669 }
670 }
671 KeyCode::Left => {
672 if self.header_cursor > 0 {
673 self.header_cursor -= 1;
674 }
675 }
676 KeyCode::Right => {
677 if self.header_cursor < self.api_header.len() {
678 self.header_cursor += 1;
679 }
680 }
681 _ => {}
682 },
683 Step::ApiKey => match key.code {
684 KeyCode::Tab => self.step = Step::ApiHeader,
685 KeyCode::Enter => {
686 let _ = self.advance_step();
687 }
688 KeyCode::Char(c) => {
689 let pos = self.api_key_cursor.min(self.api_key.len());
690 self.api_key.insert(pos, c);
691 self.api_key_cursor = pos + 1;
692 }
693 KeyCode::Backspace => {
694 if self.api_key_cursor > 0
695 && self.api_key_cursor <= self.api_key.len()
696 {
697 self.api_key.remove(self.api_key_cursor - 1);
698 self.api_key_cursor -= 1;
699 }
700 }
701 KeyCode::Left => {
702 if self.api_key_cursor > 0 {
703 self.api_key_cursor -= 1;
704 }
705 }
706 KeyCode::Right => {
707 if self.api_key_cursor < self.api_key.len() {
708 self.api_key_cursor += 1;
709 }
710 }
711 _ => {}
712 },
713 Step::Summary => {
714 if key.code == KeyCode::Enter {
715 self.testing = true;
716 self.error = None;
717 terminal.draw(|f| {
718 let area = f.size();
719 self.render(f, area);
720 })?;
721 let result = self.try_connect_and_persist(verbose).await;
722 self.testing = false;
723 match result {
724 Ok(cfg) => {
725 disable_raw_mode()?;
726 execute!(
727 terminal.backend_mut(),
728 LeaveAlternateScreen,
729 DisableMouseCapture
730 )?;
731 terminal.show_cursor()?;
732 return Ok(cfg);
733 }
734 Err(e) => {
735 self.error = Some(format!("{e:#}"));
736 }
737 }
738 }
739 }
740 }
741 }
742 }
743 }
744 }
745}
746
747impl Default for SetupWizard {
748 fn default() -> Self {
749 Self::new()
750 }
751}