Skip to main content

romm_cli/tui/screens/
setup_wizard.rs

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