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