Skip to main content

romm_cli/tui/screens/
setup_wizard.rs

1//! First-run setup: server URL, ROMs directory, authentication, test connection, persist config.
2
3use anyhow::{anyhow, Context, Result};
4use crossterm::event::{
5    self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, 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, Rect};
13use ratatui::style::{Color, Modifier, Style};
14use ratatui::text::{Line, Span, Text};
15use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
16use ratatui::Terminal;
17use std::io::stdout;
18
19use crate::client::RommClient;
20use crate::config::{
21    is_keyring_placeholder, load_config, normalize_romm_origin, persist_user_config,
22    read_user_config_json_from_disk, AuthConfig, Config, ExtrasDefaults,
23};
24use crate::core::download::validate_configured_download_directory;
25use crate::endpoints::client_tokens::ExchangeClientToken;
26use crate::tui::path_picker::{PathPicker, PathPickerEvent, PathPickerMode};
27
28fn extras_defaults_from_disk() -> ExtrasDefaults {
29    read_user_config_json_from_disk()
30        .map(|c| c.extras_defaults)
31        .unwrap_or_default()
32}
33
34#[derive(Clone, Copy, PartialEq, Eq)]
35enum AuthKind {
36    Pairing,
37    Basic,
38    Bearer,
39    ApiKey,
40}
41
42#[derive(Clone, Copy, PartialEq, Eq)]
43enum Step {
44    Url,
45    Https,
46    Download,
47    AuthMenu,
48    BasicUser,
49    BasicPass,
50    Bearer,
51    ApiHeader,
52    ApiKey,
53    PairingCode,
54    Summary,
55}
56
57fn wizard_layout(area: Rect, step: Step) -> [Rect; 3] {
58    let top = if matches!(step, Step::Url) { 5 } else { 3 };
59    let v = Layout::default()
60        .direction(Direction::Vertical)
61        .constraints([
62            Constraint::Length(top),
63            Constraint::Min(6),
64            Constraint::Length(4),
65        ])
66        .split(area);
67    [v[0], v[1], v[2]]
68}
69
70fn wizard_footer_text(keys: &str) -> Text<'_> {
71    let ver = format!("romm-cli {}", env!("CARGO_PKG_VERSION"));
72    Text::from(vec![
73        Line::from(keys).style(Style::default().fg(Color::Cyan)),
74        Line::from(ver).style(Style::default().fg(Color::DarkGray)),
75    ])
76}
77
78/// Interactive setup run before the main TUI when `API_BASE_URL` is missing.
79pub struct SetupWizard {
80    step: Step,
81    auth_kind: AuthKind,
82    auth_menu_selected: usize,
83    url: String,
84    url_cursor: usize,
85    download_picker: PathPicker,
86    username: String,
87    user_cursor: usize,
88    password: String,
89    bearer_token: String,
90    bearer_cursor: usize,
91    api_header: String,
92    header_cursor: usize,
93    api_key: String,
94    api_key_cursor: usize,
95    pairing_code: String,
96    pairing_cursor: usize,
97    /// Empty field + `true` means resolve secret from OS keyring on save (disk had `<stored-in-keyring>`).
98    reuse_keyring_password: bool,
99    reuse_keyring_bearer: bool,
100    reuse_keyring_api_key: bool,
101    pub testing: bool,
102    pub use_https: bool,
103    pub error: Option<String>,
104}
105
106impl SetupWizard {
107    pub fn new() -> Self {
108        let default_dl = dirs::download_dir()
109            .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Downloads"))
110            .join("romm-cli")
111            .display()
112            .to_string();
113        Self {
114            step: Step::Url,
115            auth_kind: AuthKind::Pairing,
116            auth_menu_selected: 0,
117            url: "https://".to_string(),
118            url_cursor: "https://".len(),
119            download_picker: PathPicker::new(PathPickerMode::Directory, &default_dl),
120            username: String::new(),
121            user_cursor: 0,
122            password: String::new(),
123            bearer_token: String::new(),
124            bearer_cursor: 0,
125            api_header: String::new(),
126            header_cursor: 0,
127            api_key: String::new(),
128            api_key_cursor: 0,
129            pairing_code: String::new(),
130            pairing_cursor: 0,
131            reuse_keyring_password: false,
132            reuse_keyring_bearer: false,
133            reuse_keyring_api_key: false,
134            testing: false,
135            use_https: true,
136            error: None,
137        }
138    }
139
140    pub fn new_auth_only(config: &Config) -> Self {
141        let mut wizard = Self::new();
142        wizard.step = Step::AuthMenu;
143        wizard.url = config.base_url.clone();
144        wizard
145            .download_picker
146            .set_path_text(config.download_dir.clone());
147        wizard.use_https = config.use_https;
148
149        let disk = read_user_config_json_from_disk();
150
151        match &config.auth {
152            Some(AuthConfig::Basic { username, password }) => {
153                wizard.auth_kind = AuthKind::Basic;
154                wizard.auth_menu_selected = 1;
155                wizard.username = username.clone();
156                wizard.user_cursor = username.len();
157                let disk_pass = disk
158                    .as_ref()
159                    .and_then(|c| c.auth.as_ref())
160                    .and_then(|a| match a {
161                        AuthConfig::Basic { password, .. } => Some(password.as_str()),
162                        _ => None,
163                    });
164                if disk_pass.is_some_and(is_keyring_placeholder) {
165                    wizard.password = String::new();
166                    wizard.reuse_keyring_password = true;
167                } else {
168                    wizard.password = password.clone();
169                }
170            }
171            Some(AuthConfig::Bearer { token }) => {
172                wizard.auth_kind = AuthKind::Bearer;
173                wizard.auth_menu_selected = 2;
174                let disk_tok = disk
175                    .as_ref()
176                    .and_then(|c| c.auth.as_ref())
177                    .and_then(|a| match a {
178                        AuthConfig::Bearer { token } => Some(token.as_str()),
179                        _ => None,
180                    });
181                if disk_tok.is_some_and(is_keyring_placeholder) {
182                    wizard.bearer_token = String::new();
183                    wizard.bearer_cursor = 0;
184                    wizard.reuse_keyring_bearer = true;
185                } else {
186                    wizard.bearer_token = token.clone();
187                    wizard.bearer_cursor = token.len();
188                }
189            }
190            Some(AuthConfig::ApiKey { header, key }) => {
191                wizard.auth_kind = AuthKind::ApiKey;
192                wizard.auth_menu_selected = 3;
193                wizard.api_header = header.clone();
194                wizard.header_cursor = header.len();
195                let disk_key = disk
196                    .as_ref()
197                    .and_then(|c| c.auth.as_ref())
198                    .and_then(|a| match a {
199                        AuthConfig::ApiKey { key, .. } => Some(key.as_str()),
200                        _ => None,
201                    });
202                if disk_key.is_some_and(is_keyring_placeholder) {
203                    wizard.api_key = String::new();
204                    wizard.api_key_cursor = 0;
205                    wizard.reuse_keyring_api_key = true;
206                } else {
207                    wizard.api_key = key.clone();
208                    wizard.api_key_cursor = key.len();
209                }
210            }
211            None => {
212                wizard.auth_kind = AuthKind::Pairing;
213                wizard.auth_menu_selected = 0;
214            }
215        }
216        wizard
217    }
218
219    fn auth_labels() -> [&'static str; 4] {
220        [
221            "Pair with Web UI (8-character code) (Recommended)",
222            "Username + password",
223            "API Token",
224            "API key in custom header",
225        ]
226    }
227
228    fn auth_kind_from_index(i: usize) -> AuthKind {
229        match i {
230            0 => AuthKind::Pairing,
231            1 => AuthKind::Basic,
232            2 => AuthKind::Bearer,
233            _ => AuthKind::ApiKey,
234        }
235    }
236
237    /// Build config after exchanging a Web UI pairing code (unauthenticated POST).
238    async fn pairing_config_from_exchange(&self, verbose: bool) -> Result<Config> {
239        let base_url = normalize_romm_origin(self.url.trim());
240        if base_url.is_empty() {
241            return Err(anyhow!("Server URL cannot be empty"));
242        }
243        let code = self.pairing_code.trim().to_string();
244        if code.is_empty() {
245            return Err(anyhow!("Pairing code cannot be empty"));
246        }
247        let download_dir =
248            validate_configured_download_directory(self.download_picker.path_trimmed().trim())?
249                .display()
250                .to_string();
251        let temp_config = Config {
252            base_url: base_url.clone(),
253            download_dir: download_dir.clone(),
254            use_https: self.use_https,
255            auth: None,
256            extras_defaults: extras_defaults_from_disk(),
257            save_sync: read_user_config_json_from_disk()
258                .map(|c| c.save_sync)
259                .unwrap_or_default(),
260        };
261        let client = RommClient::new(&temp_config, verbose)?;
262        let response = client
263            .call(&ExchangeClientToken { code })
264            .await
265            .context("failed to exchange pairing code")?;
266        Ok(Config {
267            base_url,
268            download_dir,
269            use_https: self.use_https,
270            auth: Some(AuthConfig::Bearer {
271                token: response.raw_token,
272            }),
273            extras_defaults: extras_defaults_from_disk(),
274            save_sync: read_user_config_json_from_disk()
275                .map(|c| c.save_sync)
276                .unwrap_or_default(),
277        })
278    }
279
280    fn build_config(&self) -> Result<Config> {
281        let base_url = normalize_romm_origin(self.url.trim());
282        if base_url.is_empty() {
283            return Err(anyhow!("Server URL cannot be empty"));
284        }
285        let download_dir =
286            validate_configured_download_directory(self.download_picker.path_trimmed().trim())?
287                .display()
288                .to_string();
289        let auth: Option<AuthConfig> = match self.auth_kind {
290            AuthKind::Basic => {
291                let u = self.username.trim();
292                if u.is_empty() {
293                    return Err(anyhow!("Username cannot be empty"));
294                }
295                let password = if self.password.is_empty() && self.reuse_keyring_password {
296                    crate::config::keyring_get("API_PASSWORD").ok_or_else(|| {
297                        anyhow!("Password not in OS keyring; enter a password or run romm-cli init")
298                    })?
299                } else if self.password.is_empty() {
300                    return Err(anyhow!("Password cannot be empty"));
301                } else {
302                    self.password.clone()
303                };
304                Some(AuthConfig::Basic {
305                    username: u.to_string(),
306                    password,
307                })
308            }
309            AuthKind::Bearer => {
310                let token = if self.bearer_token.trim().is_empty() && self.reuse_keyring_bearer {
311                    crate::config::keyring_get("API_TOKEN").ok_or_else(|| {
312                        anyhow!("API token not in OS keyring; enter a token or run romm-cli init")
313                    })?
314                } else if self.bearer_token.trim().is_empty() {
315                    return Err(anyhow!("Bearer token cannot be empty"));
316                } else {
317                    self.bearer_token.trim().to_string()
318                };
319                Some(AuthConfig::Bearer { token })
320            }
321            AuthKind::ApiKey => {
322                let h = self.api_header.trim();
323                if h.is_empty() {
324                    return Err(anyhow!("Header name cannot be empty"));
325                }
326                let key = if self.api_key.is_empty() && self.reuse_keyring_api_key {
327                    crate::config::keyring_get("API_KEY").ok_or_else(|| {
328                        anyhow!("API key not in OS keyring; enter a key or run romm-cli init")
329                    })?
330                } else if self.api_key.is_empty() {
331                    return Err(anyhow!("API key cannot be empty"));
332                } else {
333                    self.api_key.clone()
334                };
335                Some(AuthConfig::ApiKey {
336                    header: h.to_string(),
337                    key,
338                })
339            }
340            AuthKind::Pairing => {
341                return Err(anyhow!(
342                    "Pairing auth is applied when connecting; use the pairing code step and connect"
343                ));
344            }
345        };
346        Ok(Config {
347            base_url,
348            download_dir,
349            use_https: self.use_https,
350            auth,
351            extras_defaults: extras_defaults_from_disk(),
352            save_sync: read_user_config_json_from_disk()
353                .map(|c| c.save_sync)
354                .unwrap_or_default(),
355        })
356    }
357
358    pub fn render(&mut self, f: &mut ratatui::Frame, area: ratatui::layout::Rect) {
359        let title = match self.step {
360            Step::Url => "Step 1/5 — RomM server URL",
361            Step::Https => "Step 2/5 — Secure connection",
362            Step::Download => "Step 3/5 — ROMs directory",
363            Step::AuthMenu => "Step 4/5 — Authentication",
364            Step::BasicUser | Step::BasicPass => "Step 5/5 — Basic auth",
365            Step::Bearer => "Step 5/5 — API Token",
366            Step::ApiHeader | Step::ApiKey => "Step 5/5 — API key",
367            Step::PairingCode => "Step 5/5 — Pair with Web UI",
368            Step::Summary => "Review & connect",
369        };
370
371        let main = wizard_layout(area, self.step);
372
373        match self.step {
374            Step::Url => {
375                let intro = Text::from(vec![
376                    Line::from("First-time setup: point the CLI at your RomM server."),
377                    Line::from(Span::styled(
378                        "Example: https://romm.example.com or http://192.168.1.10:8080",
379                        Style::default().fg(Color::DarkGray),
380                    )),
381                    Line::from(Span::styled(
382                        "Same origin as in your browser (no trailing /api).",
383                        Style::default().fg(Color::DarkGray),
384                    )),
385                ]);
386                f.render_widget(Paragraph::new(intro), main[0]);
387            }
388            step => {
389                let hint_top = match step {
390                    Step::Https => "HTTPS ensures your credentials are encrypted in transit. Only disable if necessary.",
391                    Step::Download => "Choose a directory to save ROMs. Make sure you have write permissions.",
392                    Step::AuthMenu => "Select how you authenticate with the RomM server.",
393                    Step::BasicUser | Step::BasicPass => "Enter the exact same username and password you use to log into the RomM web UI.",
394                    Step::Bearer => "To get an API token, go to the RomM web UI -> client API Tokens -> generate a new token.",
395                    Step::PairingCode => "Login to RomM in your browser, go to your profile menu -> Client API Tokens, and create a new token.",
396                    Step::ApiHeader | Step::ApiKey => "Use this only if you have a custom reverse proxy setup requiring specific headers (e.g., X-Api-Key). Otherwise, use API Token.",
397                    Step::Summary => "Review your configuration before testing the connection.",
398                    Step::Url => "",
399                };
400                let p = Paragraph::new(hint_top).style(Style::default().fg(Color::DarkGray));
401                f.render_widget(p, main[0]);
402            }
403        }
404
405        match self.step {
406            Step::Url => {
407                let line = format!(
408                    "{}▏",
409                    self.url.chars().take(self.url_cursor).collect::<String>()
410                );
411                let rest: String = self.url.chars().skip(self.url_cursor).collect();
412                let text = format!("{line}{rest}");
413                let block = Block::default().title(title).borders(Borders::ALL);
414                let p = Paragraph::new(text).block(block);
415                f.render_widget(p, main[1]);
416            }
417            Step::Https => {
418                let text = if self.use_https {
419                    "[X] Use HTTPS (Recommended)"
420                } else {
421                    "[ ] Use HTTPS (Insecure)"
422                };
423                let block = Block::default().title(title).borders(Borders::ALL);
424                let p = Paragraph::new(format!("\n  {}\n\n  Space: toggle   Enter: next", text))
425                    .block(block);
426                f.render_widget(p, main[1]);
427            }
428            Step::Download => {
429                self.download_picker.render(f, main[1], title, "");
430            }
431            Step::AuthMenu => {
432                let items: Vec<ListItem> = Self::auth_labels()
433                    .iter()
434                    .map(|s| ListItem::new(*s))
435                    .collect();
436                let mut state = ListState::default();
437                state.select(Some(self.auth_menu_selected));
438                let list = List::new(items)
439                    .block(Block::default().title(title).borders(Borders::ALL))
440                    .highlight_style(
441                        Style::default()
442                            .fg(Color::Yellow)
443                            .add_modifier(Modifier::BOLD),
444                    )
445                    .highlight_symbol(">> ");
446                f.render_stateful_widget(list, main[1], &mut state);
447            }
448            Step::BasicUser | Step::BasicPass => {
449                let user_line = if self.step == Step::BasicUser {
450                    format!(
451                        "{}▏{}",
452                        self.username
453                            .chars()
454                            .take(self.user_cursor)
455                            .collect::<String>(),
456                        self.username
457                            .chars()
458                            .skip(self.user_cursor)
459                            .collect::<String>()
460                    )
461                } else {
462                    self.username.clone()
463                };
464                let pass_display: String = "•".repeat(self.password.len());
465                let kr_hint = if self.step == Step::BasicPass
466                    && self.password.is_empty()
467                    && self.reuse_keyring_password
468                {
469                    "\n\n(stored in OS keyring — leave blank to keep, or type a new password)"
470                } else {
471                    ""
472                };
473                let block = Block::default().title(title).borders(Borders::ALL);
474                let body = format!(
475                    "Username\n{user_line}\n\nPassword (hidden)\n{pass_display}{kr_hint}\n\nTab: switch field"
476                );
477                let p = Paragraph::new(body).block(block);
478                f.render_widget(p, main[1]);
479            }
480            Step::Bearer => {
481                let line = format!(
482                    "{}▏{}",
483                    self.bearer_token
484                        .chars()
485                        .take(self.bearer_cursor)
486                        .collect::<String>(),
487                    self.bearer_token
488                        .chars()
489                        .skip(self.bearer_cursor)
490                        .collect::<String>()
491                );
492                let mut bearer_text = Text::from(vec![
493                    Line::from("API Token"),
494                    Line::from(""),
495                    Line::from(line),
496                ]);
497                if self.bearer_token.is_empty() && self.reuse_keyring_bearer {
498                    bearer_text.push_line(Line::from(""));
499                    bearer_text.push_line(Line::from(Span::styled(
500                        "Token stored in OS keyring — leave blank to keep, or type a new token.",
501                        Style::default().fg(Color::DarkGray),
502                    )));
503                }
504                let block = Block::default().title(title).borders(Borders::ALL);
505                let p = Paragraph::new(bearer_text).block(block);
506                f.render_widget(p, main[1]);
507            }
508            Step::PairingCode => {
509                let line = format!(
510                    "{}▏{}",
511                    self.pairing_code
512                        .chars()
513                        .take(self.pairing_cursor)
514                        .collect::<String>(),
515                    self.pairing_code
516                        .chars()
517                        .skip(self.pairing_cursor)
518                        .collect::<String>()
519                );
520                let body = format!("Enter the 8-character code provided.\n\n{line}");
521                let block = Block::default().title(title).borders(Borders::ALL);
522                let p = Paragraph::new(body).block(block);
523                f.render_widget(p, main[1]);
524            }
525            Step::ApiHeader | Step::ApiKey => {
526                let header_line = if self.step == Step::ApiHeader {
527                    format!(
528                        "{}▏{}",
529                        self.api_header
530                            .chars()
531                            .take(self.header_cursor)
532                            .collect::<String>(),
533                        self.api_header
534                            .chars()
535                            .skip(self.header_cursor)
536                            .collect::<String>()
537                    )
538                } else {
539                    self.api_header.clone()
540                };
541                let key_line = "•".repeat(self.api_key.len());
542                let kr_hint = if self.step == Step::ApiKey
543                    && self.api_key.is_empty()
544                    && self.reuse_keyring_api_key
545                {
546                    "\n\n(stored in OS keyring — leave blank to keep, or type a new key)"
547                } else {
548                    ""
549                };
550                let body = format!(
551                    "Header name\n{header_line}\n\nKey (hidden)\n{key_line}{kr_hint}\n\nTab: switch field"
552                );
553                let block = Block::default().title(title).borders(Borders::ALL);
554                let p = Paragraph::new(body).block(block);
555                f.render_widget(p, main[1]);
556            }
557            Step::Summary => {
558                let url_line = normalize_romm_origin(self.url.trim());
559                let auth_desc = match self.auth_kind {
560                    AuthKind::Basic => "Basic",
561                    AuthKind::Bearer => "API Token",
562                    AuthKind::ApiKey => "API key header",
563                    AuthKind::Pairing => {
564                        if self.pairing_code.trim().is_empty() {
565                            "Pair with Web UI (no code yet)"
566                        } else {
567                            "Pair with Web UI (code entered)"
568                        }
569                    }
570                };
571                let mut lines = vec![
572                    format!("Server: {url_line}"),
573                    format!("ROMs Dir: {}", self.download_picker.path_trimmed()),
574                    format!("Use HTTPS: {}", if self.use_https { "Yes" } else { "No" }),
575                    format!("Auth: {auth_desc}"),
576                    String::new(),
577                ];
578                if self.testing {
579                    lines.push("Connecting to server…".to_string());
580                } else if let Some(ref e) = self.error {
581                    lines.push(format!("Last error: {e}"));
582                } else {
583                    lines.push("Enter: test connection and save   Esc: quit".to_string());
584                }
585                let block = Block::default().title(title).borders(Borders::ALL);
586                let p = Paragraph::new(lines.join("\n")).block(block);
587                f.render_widget(p, main[1]);
588            }
589        }
590
591        let footer_keys = match self.step {
592            Step::Url => "Enter: next   Backspace: delete   Esc: quit",
593            Step::Https => "Space: toggle   Enter: next   Esc: quit",
594            Step::Download => "Ctrl+Enter: next (creates path)   ↑ list top: path bar   ↓/↑: list focus   Tab: path/list   Esc: quit",
595            Step::AuthMenu => "↑/↓: choose   Enter: next   Esc: quit",
596            Step::BasicUser | Step::BasicPass => {
597                "Type text   Tab: switch field   Enter: next step   Esc: quit"
598            }
599            Step::Bearer => "Enter: next step   Esc: quit",
600            Step::PairingCode => "Enter: next step   Esc: quit",
601            Step::ApiHeader | Step::ApiKey => "Tab: switch field   Enter: next step   Esc: quit",
602            Step::Summary => {
603                if self.testing {
604                    "Please wait…"
605                } else {
606                    "Enter: connect & save"
607                }
608            }
609        };
610        let p = Paragraph::new(wizard_footer_text(footer_keys))
611            .block(Block::default().borders(Borders::ALL));
612        f.render_widget(p, main[2]);
613    }
614
615    pub fn cursor_pos(&self, area: ratatui::layout::Rect) -> Option<(u16, u16)> {
616        let main = wizard_layout(area, self.step);
617        let inner = main[1];
618        match self.step {
619            Step::Url => {
620                let x = inner.x + 1 + self.url_cursor.min(self.url.len()) as u16;
621                Some((x, inner.y + 1))
622            }
623            Step::Download => self
624                .download_picker
625                .cursor_position(inner, "Step 3/5 — ROMs directory"),
626            Step::Bearer => {
627                let x = inner.x + 1 + self.bearer_cursor.min(self.bearer_token.len()) as u16;
628                Some((x, inner.y + 1))
629            }
630            Step::PairingCode => {
631                let x = inner.x + 1 + self.pairing_cursor.min(self.pairing_code.len()) as u16;
632                Some((x, inner.y + 3))
633            }
634            Step::BasicUser => {
635                let x = inner.x + 1 + self.user_cursor.min(self.username.len()) as u16;
636                Some((x, inner.y + 2))
637            }
638            Step::BasicPass => {
639                let x = inner.x + 1 + "•".repeat(self.password.len()).len() as u16;
640                Some((x, inner.y + 6))
641            }
642            Step::ApiHeader => {
643                let x = inner.x + 1 + self.header_cursor.min(self.api_header.len()) as u16;
644                Some((x, inner.y + 2))
645            }
646            Step::ApiKey => {
647                let x = inner.x + 1 + self.api_key_cursor.min(self.api_key.len()) as u16;
648                Some((x, inner.y + 6))
649            }
650            Step::Https | Step::AuthMenu | Step::Summary => None,
651        }
652    }
653
654    fn add_char_url(&mut self, c: char) {
655        let pos = self.url_cursor.min(self.url.len());
656        self.url.insert(pos, c);
657        self.url_cursor = pos + 1;
658    }
659
660    fn del_char_url(&mut self) {
661        if self.url_cursor > 0 && self.url_cursor <= self.url.len() {
662            self.url.remove(self.url_cursor - 1);
663            self.url_cursor -= 1;
664        }
665    }
666
667    fn advance_from_auth_menu(&mut self) {
668        self.auth_kind = Self::auth_kind_from_index(self.auth_menu_selected);
669        self.step = match self.auth_kind {
670            AuthKind::Basic => Step::BasicUser,
671            AuthKind::Bearer => Step::Bearer,
672            AuthKind::ApiKey => Step::ApiHeader,
673            AuthKind::Pairing => {
674                self.pairing_cursor = self.pairing_code.len();
675                Step::PairingCode
676            }
677        };
678    }
679
680    fn advance_step(&mut self) -> Result<()> {
681        self.error = None;
682        match self.step {
683            Step::Url => {
684                if normalize_romm_origin(self.url.trim()).is_empty() {
685                    self.error = Some("Enter a valid server URL".to_string());
686                    return Ok(());
687                }
688                self.step = Step::Https;
689            }
690            Step::Https => {
691                self.step = Step::Download;
692            }
693            Step::Download => {}
694            Step::AuthMenu => self.advance_from_auth_menu(),
695            Step::BasicUser => self.step = Step::BasicPass,
696            Step::BasicPass => self.step = Step::Summary,
697            Step::Bearer => self.step = Step::Summary,
698            Step::ApiHeader => self.step = Step::ApiKey,
699            Step::ApiKey => self.step = Step::Summary,
700            Step::PairingCode => self.step = Step::Summary,
701            Step::Summary => {}
702        }
703        Ok(())
704    }
705
706    pub async fn try_connect_and_persist(&mut self, verbose: bool) -> Result<Config> {
707        let cfg = if self.auth_kind == AuthKind::Pairing {
708            self.pairing_config_from_exchange(verbose).await?
709        } else {
710            self.build_config()?
711        };
712        let client = RommClient::new(&cfg, verbose)?;
713        client.fetch_openapi_json().await?;
714        persist_user_config(&cfg)?;
715        load_config()
716    }
717
718    pub fn handle_key(&mut self, key: &KeyEvent) -> Result<bool> {
719        if key.kind != KeyEventKind::Press {
720            return Ok(false);
721        }
722        if key.code == KeyCode::Esc {
723            return Ok(true); // Signal to caller that we should exit/cancel
724        }
725
726        if self.testing {
727            return Ok(false);
728        }
729
730        match self.step {
731            Step::Url => match key.code {
732                KeyCode::Enter => {
733                    let _ = self.advance_step();
734                }
735                KeyCode::Char(c) => self.add_char_url(c),
736                KeyCode::Backspace => self.del_char_url(),
737                KeyCode::Left if self.url_cursor > 0 => {
738                    self.url_cursor -= 1;
739                }
740                KeyCode::Right if self.url_cursor < self.url.len() => {
741                    self.url_cursor += 1;
742                }
743                _ => {}
744            },
745            Step::Https => match key.code {
746                KeyCode::Enter => {
747                    let _ = self.advance_step();
748                }
749                KeyCode::Char(' ') => self.use_https = !self.use_https,
750                _ => {}
751            },
752            Step::Download => match self.download_picker.handle_key(key) {
753                PathPickerEvent::Confirmed(p) => {
754                    self.error = None;
755                    match validate_configured_download_directory(p.to_string_lossy().as_ref()) {
756                        Ok(canonical) => {
757                            self.download_picker
758                                .set_path_text(canonical.display().to_string());
759                            self.step = Step::AuthMenu;
760                        }
761                        Err(e) => {
762                            self.error = Some(format!("{e:#}"));
763                        }
764                    }
765                }
766                PathPickerEvent::None => {}
767            },
768            Step::AuthMenu => match key.code {
769                KeyCode::Up | KeyCode::Char('k') if self.auth_menu_selected > 0 => {
770                    self.auth_menu_selected -= 1;
771                }
772                KeyCode::Down | KeyCode::Char('j') if self.auth_menu_selected < 3 => {
773                    self.auth_menu_selected += 1;
774                }
775                KeyCode::Enter => {
776                    let _ = self.advance_step();
777                }
778                _ => {}
779            },
780            Step::BasicUser => match key.code {
781                KeyCode::Tab => self.step = Step::BasicPass,
782                KeyCode::Enter => {
783                    let _ = self.advance_step();
784                }
785                KeyCode::Char(c) => {
786                    let pos = self.user_cursor.min(self.username.len());
787                    self.username.insert(pos, c);
788                    self.user_cursor = pos + 1;
789                }
790                KeyCode::Backspace
791                    if self.user_cursor > 0 && self.user_cursor <= self.username.len() =>
792                {
793                    self.username.remove(self.user_cursor - 1);
794                    self.user_cursor -= 1;
795                }
796                KeyCode::Left if self.user_cursor > 0 => {
797                    self.user_cursor -= 1;
798                }
799                KeyCode::Right if self.user_cursor < self.username.len() => {
800                    self.user_cursor += 1;
801                }
802                _ => {}
803            },
804            Step::BasicPass => match key.code {
805                KeyCode::Tab => self.step = Step::BasicUser,
806                KeyCode::Enter => {
807                    let _ = self.advance_step();
808                }
809                KeyCode::Char(c) => {
810                    self.reuse_keyring_password = false;
811                    self.password.push(c);
812                }
813                KeyCode::Backspace => {
814                    self.password.pop();
815                }
816                _ => {}
817            },
818            Step::Bearer => match key.code {
819                KeyCode::Enter => {
820                    let _ = self.advance_step();
821                }
822                KeyCode::Char(c) => {
823                    self.reuse_keyring_bearer = false;
824                    let pos = self.bearer_cursor.min(self.bearer_token.len());
825                    self.bearer_token.insert(pos, c);
826                    self.bearer_cursor = pos + 1;
827                }
828                KeyCode::Backspace
829                    if self.bearer_cursor > 0 && self.bearer_cursor <= self.bearer_token.len() =>
830                {
831                    self.bearer_token.remove(self.bearer_cursor - 1);
832                    self.bearer_cursor -= 1;
833                }
834                KeyCode::Left if self.bearer_cursor > 0 => {
835                    self.bearer_cursor -= 1;
836                }
837                KeyCode::Right if self.bearer_cursor < self.bearer_token.len() => {
838                    self.bearer_cursor += 1;
839                }
840                _ => {}
841            },
842            Step::PairingCode => match key.code {
843                KeyCode::Enter => {
844                    let _ = self.advance_step();
845                }
846                KeyCode::Char(c) => {
847                    let pos = self.pairing_cursor.min(self.pairing_code.len());
848                    self.pairing_code.insert(pos, c);
849                    self.pairing_cursor = pos + 1;
850                }
851                KeyCode::Backspace
852                    if self.pairing_cursor > 0
853                        && self.pairing_cursor <= self.pairing_code.len() =>
854                {
855                    self.pairing_code.remove(self.pairing_cursor - 1);
856                    self.pairing_cursor -= 1;
857                }
858                KeyCode::Left if self.pairing_cursor > 0 => {
859                    self.pairing_cursor -= 1;
860                }
861                KeyCode::Right if self.pairing_cursor < self.pairing_code.len() => {
862                    self.pairing_cursor += 1;
863                }
864                _ => {}
865            },
866            Step::ApiHeader => match key.code {
867                KeyCode::Tab => self.step = Step::ApiKey,
868                KeyCode::Enter => {
869                    let _ = self.advance_step();
870                }
871                KeyCode::Char(c) => {
872                    let pos = self.header_cursor.min(self.api_header.len());
873                    self.api_header.insert(pos, c);
874                    self.header_cursor = pos + 1;
875                }
876                KeyCode::Backspace
877                    if self.header_cursor > 0 && self.header_cursor <= self.api_header.len() =>
878                {
879                    self.api_header.remove(self.header_cursor - 1);
880                    self.header_cursor -= 1;
881                }
882                KeyCode::Left if self.header_cursor > 0 => {
883                    self.header_cursor -= 1;
884                }
885                KeyCode::Right if self.header_cursor < self.api_header.len() => {
886                    self.header_cursor += 1;
887                }
888                _ => {}
889            },
890            Step::ApiKey => match key.code {
891                KeyCode::Tab => self.step = Step::ApiHeader,
892                KeyCode::Enter => {
893                    let _ = self.advance_step();
894                }
895                KeyCode::Char(c) => {
896                    self.reuse_keyring_api_key = false;
897                    let pos = self.api_key_cursor.min(self.api_key.len());
898                    self.api_key.insert(pos, c);
899                    self.api_key_cursor = pos + 1;
900                }
901                KeyCode::Backspace
902                    if self.api_key_cursor > 0 && self.api_key_cursor <= self.api_key.len() =>
903                {
904                    self.api_key.remove(self.api_key_cursor - 1);
905                    self.api_key_cursor -= 1;
906                }
907                KeyCode::Left if self.api_key_cursor > 0 => {
908                    self.api_key_cursor -= 1;
909                }
910                KeyCode::Right if self.api_key_cursor < self.api_key.len() => {
911                    self.api_key_cursor += 1;
912                }
913                _ => {}
914            },
915            Step::Summary => {
916                if key.code == KeyCode::Enter {
917                    self.testing = true;
918                    self.error = None;
919                    // The caller handles the actual async try_connect_and_persist call
920                    // when they see testing = true.
921                }
922            }
923        }
924        Ok(false)
925    }
926
927    pub fn handle_paste(&mut self, text: &str) {
928        // Remove any newlines or carriage returns that might break single-line fields
929        let clean_text = text.replace(['\n', '\r'], "");
930        if clean_text.is_empty() {
931            return;
932        }
933
934        match self.step {
935            Step::Url => {
936                let pos = self.url_cursor.min(self.url.len());
937                self.url.insert_str(pos, &clean_text);
938                self.url_cursor += clean_text.len();
939            }
940            Step::BasicUser => {
941                let pos = self.user_cursor.min(self.username.len());
942                self.username.insert_str(pos, &clean_text);
943                self.user_cursor += clean_text.len();
944            }
945            Step::BasicPass => {
946                self.reuse_keyring_password = false;
947                self.password.push_str(&clean_text);
948            }
949            Step::Bearer => {
950                self.reuse_keyring_bearer = false;
951                let pos = self.bearer_cursor.min(self.bearer_token.len());
952                self.bearer_token.insert_str(pos, &clean_text);
953                self.bearer_cursor += clean_text.len();
954            }
955            Step::PairingCode => {
956                let pos = self.pairing_cursor.min(self.pairing_code.len());
957                self.pairing_code.insert_str(pos, &clean_text);
958                self.pairing_cursor += clean_text.len();
959            }
960            Step::ApiHeader => {
961                let pos = self.header_cursor.min(self.api_header.len());
962                self.api_header.insert_str(pos, &clean_text);
963                self.header_cursor += clean_text.len();
964            }
965            Step::ApiKey => {
966                self.reuse_keyring_api_key = false;
967                let pos = self.api_key_cursor.min(self.api_key.len());
968                self.api_key.insert_str(pos, &clean_text);
969                self.api_key_cursor += clean_text.len();
970            }
971            _ => {}
972        }
973    }
974
975    pub async fn run(mut self, verbose: bool) -> Result<Config> {
976        enable_raw_mode()?;
977        let mut stdout = stdout();
978        execute!(
979            stdout,
980            EnterAlternateScreen,
981            EnableMouseCapture,
982            crossterm::event::EnableBracketedPaste
983        )?;
984        let backend = CrosstermBackend::new(stdout);
985        let mut terminal = Terminal::new(backend)?;
986
987        loop {
988            terminal.draw(|f| {
989                let area = f.area();
990                self.render(f, area);
991                if let Some((x, y)) = self.cursor_pos(area) {
992                    f.set_cursor_position((x, y));
993                }
994            })?;
995
996            if event::poll(std::time::Duration::from_millis(100))? {
997                let ev = event::read()?;
998                let mut should_exit = false;
999
1000                match ev {
1001                    Event::Key(key) if self.handle_key(&key)? => {
1002                        should_exit = true;
1003                    }
1004                    Event::Paste(text) => {
1005                        self.handle_paste(&text);
1006                    }
1007                    _ => {}
1008                }
1009
1010                if should_exit {
1011                    disable_raw_mode()?;
1012                    execute!(
1013                        terminal.backend_mut(),
1014                        crossterm::event::DisableBracketedPaste,
1015                        LeaveAlternateScreen,
1016                        DisableMouseCapture
1017                    )?;
1018                    terminal.show_cursor()?;
1019                    return Err(anyhow!("setup cancelled"));
1020                }
1021
1022                if self.testing {
1023                    terminal.draw(|f| {
1024                        let area = f.area();
1025                        self.render(f, area);
1026                    })?;
1027                    let result = self.try_connect_and_persist(verbose).await;
1028                    self.testing = false;
1029                    match result {
1030                        Ok(cfg) => {
1031                            disable_raw_mode()?;
1032                            execute!(
1033                                terminal.backend_mut(),
1034                                crossterm::event::DisableBracketedPaste,
1035                                LeaveAlternateScreen,
1036                                DisableMouseCapture
1037                            )?;
1038                            terminal.show_cursor()?;
1039                            return Ok(cfg);
1040                        }
1041                        Err(e) => {
1042                            self.error = Some(format!("{e:#}"));
1043                        }
1044                    }
1045                }
1046            }
1047        }
1048    }
1049}
1050
1051impl Default for SetupWizard {
1052    fn default() -> Self {
1053        Self::new()
1054    }
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059    use super::*;
1060    use ratatui::backend::TestBackend;
1061    use ratatui::Terminal;
1062    use std::path::PathBuf;
1063    use wiremock::matchers::{method, path};
1064    use wiremock::{Mock, MockServer, ResponseTemplate};
1065
1066    fn unique_test_download_dir() -> PathBuf {
1067        let suffix = std::time::SystemTime::now()
1068            .duration_since(std::time::UNIX_EPOCH)
1069            .unwrap_or_default()
1070            .as_nanos();
1071        std::env::temp_dir().join(format!("romm-dl-test-{}-{suffix}", std::process::id()))
1072    }
1073
1074    fn wizard_with_pairing(mock_uri: &str, code: &str, download_dir: &str) -> SetupWizard {
1075        SetupWizard {
1076            step: Step::PairingCode,
1077            auth_kind: AuthKind::Pairing,
1078            auth_menu_selected: 4,
1079            url: mock_uri.to_string(),
1080            url_cursor: mock_uri.len(),
1081            download_picker: PathPicker::new(PathPickerMode::Directory, download_dir),
1082            username: String::new(),
1083            user_cursor: 0,
1084            password: String::new(),
1085            bearer_token: String::new(),
1086            bearer_cursor: 0,
1087            api_header: String::new(),
1088            header_cursor: 0,
1089            api_key: String::new(),
1090            api_key_cursor: 0,
1091            pairing_code: code.to_string(),
1092            pairing_cursor: code.len(),
1093            reuse_keyring_password: false,
1094            reuse_keyring_bearer: false,
1095            reuse_keyring_api_key: false,
1096            testing: false,
1097            use_https: false,
1098            error: None,
1099        }
1100    }
1101
1102    #[tokio::test]
1103    async fn pairing_config_from_exchange_returns_bearer_token() {
1104        let mock_server = MockServer::start().await;
1105
1106        let token_json = serde_json::json!({
1107            "id": 1,
1108            "name": "cli-device",
1109            "scopes": [],
1110            "expires_at": null,
1111            "last_used_at": null,
1112            "created_at": "2020-01-01T00:00:00Z",
1113            "user_id": 42,
1114            "raw_token": "exchanged-bearer-secret"
1115        });
1116
1117        Mock::given(method("POST"))
1118            .and(path("/api/client-tokens/exchange"))
1119            .respond_with(ResponseTemplate::new(200).set_body_json(&token_json))
1120            .mount(&mock_server)
1121            .await;
1122
1123        let uri = mock_server.uri();
1124        let download_dir = unique_test_download_dir();
1125        let download_dir = download_dir.to_string_lossy().into_owned();
1126        let wizard = wizard_with_pairing(&uri, "ABCD1234", &download_dir);
1127        let cfg = wizard
1128            .pairing_config_from_exchange(false)
1129            .await
1130            .expect("pairing exchange should succeed");
1131
1132        match cfg.auth {
1133            Some(AuthConfig::Bearer { token }) => {
1134                assert_eq!(token, "exchanged-bearer-secret");
1135            }
1136            _ => panic!("expected bearer auth after pairing exchange"),
1137        }
1138        assert_eq!(cfg.base_url, normalize_romm_origin(&uri));
1139        let expected_download_dir = validate_configured_download_directory(&download_dir).unwrap();
1140        assert_eq!(
1141            cfg.download_dir,
1142            expected_download_dir.display().to_string()
1143        );
1144    }
1145
1146    #[test]
1147    fn hidden_password_field_does_not_render_inline_cursor_glyph() {
1148        let mut wizard = SetupWizard::new();
1149        wizard.step = Step::BasicPass;
1150        wizard.password = "secret".to_string();
1151        let backend = TestBackend::new(80, 24);
1152        let mut terminal = Terminal::new(backend).expect("create test terminal");
1153        terminal
1154            .draw(|frame| {
1155                let area = frame.area();
1156                wizard.render(frame, area);
1157            })
1158            .expect("render setup wizard");
1159        let backend = terminal.backend();
1160        let buffer = backend.buffer();
1161        let has_cursor_glyph = buffer.content().iter().any(|cell| cell.symbol() == "▏");
1162        assert!(
1163            !has_cursor_glyph,
1164            "password field should rely on terminal cursor, not inline glyph"
1165        );
1166    }
1167
1168    #[test]
1169    fn hidden_api_key_field_does_not_render_inline_cursor_glyph() {
1170        let mut wizard = SetupWizard::new();
1171        wizard.step = Step::ApiKey;
1172        wizard.api_key = "secret-key".to_string();
1173        wizard.api_key_cursor = wizard.api_key.len();
1174        let backend = TestBackend::new(80, 24);
1175        let mut terminal = Terminal::new(backend).expect("create test terminal");
1176        terminal
1177            .draw(|frame| {
1178                let area = frame.area();
1179                wizard.render(frame, area);
1180            })
1181            .expect("render setup wizard");
1182        let backend = terminal.backend();
1183        let buffer = backend.buffer();
1184        let has_cursor_glyph = buffer.content().iter().any(|cell| cell.symbol() == "▏");
1185        assert!(
1186            !has_cursor_glyph,
1187            "API key field should rely on terminal cursor, not inline glyph"
1188        );
1189    }
1190}