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