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