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