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