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