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