Skip to main content

romm_cli/tui/screens/
setup_wizard.rs

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