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