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