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