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 => {
720 if self.url_cursor > 0 {
721 self.url_cursor -= 1;
722 }
723 }
724 KeyCode::Right => {
725 if self.url_cursor < self.url.len() {
726 self.url_cursor += 1;
727 }
728 }
729 _ => {}
730 },
731 Step::Https => match key.code {
732 KeyCode::Enter => {
733 let _ = self.advance_step();
734 }
735 KeyCode::Char(' ') => self.use_https = !self.use_https,
736 _ => {}
737 },
738 Step::Download => match key.code {
739 KeyCode::Enter => {
740 let _ = self.advance_step();
741 }
742 KeyCode::Char(c) => self.add_char_dl(c),
743 KeyCode::Backspace => self.del_char_dl(),
744 KeyCode::Left => {
745 if self.dl_cursor > 0 {
746 self.dl_cursor -= 1;
747 }
748 }
749 KeyCode::Right => {
750 if self.dl_cursor < self.download_dir.len() {
751 self.dl_cursor += 1;
752 }
753 }
754 _ => {}
755 },
756 Step::AuthMenu => match key.code {
757 KeyCode::Up | KeyCode::Char('k') => {
758 if self.auth_menu_selected > 0 {
759 self.auth_menu_selected -= 1;
760 }
761 }
762 KeyCode::Down | KeyCode::Char('j') => {
763 if self.auth_menu_selected < 4 {
764 self.auth_menu_selected += 1;
765 }
766 }
767 KeyCode::Enter => {
768 let _ = self.advance_step();
769 }
770 _ => {}
771 },
772 Step::BasicUser => match key.code {
773 KeyCode::Tab => self.step = Step::BasicPass,
774 KeyCode::Enter => {
775 let _ = self.advance_step();
776 }
777 KeyCode::Char(c) => {
778 let pos = self.user_cursor.min(self.username.len());
779 self.username.insert(pos, c);
780 self.user_cursor = pos + 1;
781 }
782 KeyCode::Backspace => {
783 if self.user_cursor > 0 && self.user_cursor <= self.username.len() {
784 self.username.remove(self.user_cursor - 1);
785 self.user_cursor -= 1;
786 }
787 }
788 KeyCode::Left => {
789 if self.user_cursor > 0 {
790 self.user_cursor -= 1;
791 }
792 }
793 KeyCode::Right => {
794 if self.user_cursor < self.username.len() {
795 self.user_cursor += 1;
796 }
797 }
798 _ => {}
799 },
800 Step::BasicPass => match key.code {
801 KeyCode::Tab => self.step = Step::BasicUser,
802 KeyCode::Enter => {
803 let _ = self.advance_step();
804 }
805 KeyCode::Char(c) => {
806 self.reuse_keyring_password = false;
807 self.password.push(c);
808 }
809 KeyCode::Backspace => {
810 self.password.pop();
811 }
812 _ => {}
813 },
814 Step::Bearer => match key.code {
815 KeyCode::Enter => {
816 let _ = self.advance_step();
817 }
818 KeyCode::Char(c) => {
819 self.reuse_keyring_bearer = false;
820 let pos = self.bearer_cursor.min(self.bearer_token.len());
821 self.bearer_token.insert(pos, c);
822 self.bearer_cursor = pos + 1;
823 }
824 KeyCode::Backspace => {
825 if self.bearer_cursor > 0 && self.bearer_cursor <= self.bearer_token.len() {
826 self.bearer_token.remove(self.bearer_cursor - 1);
827 self.bearer_cursor -= 1;
828 }
829 }
830 KeyCode::Left => {
831 if self.bearer_cursor > 0 {
832 self.bearer_cursor -= 1;
833 }
834 }
835 KeyCode::Right => {
836 if self.bearer_cursor < self.bearer_token.len() {
837 self.bearer_cursor += 1;
838 }
839 }
840 _ => {}
841 },
842 Step::PairingCode => match key.code {
843 KeyCode::Enter => {
844 let _ = self.advance_step();
845 }
846 KeyCode::Char(c) => {
847 let pos = self.pairing_cursor.min(self.pairing_code.len());
848 self.pairing_code.insert(pos, c);
849 self.pairing_cursor = pos + 1;
850 }
851 KeyCode::Backspace => {
852 if self.pairing_cursor > 0 && self.pairing_cursor <= self.pairing_code.len() {
853 self.pairing_code.remove(self.pairing_cursor - 1);
854 self.pairing_cursor -= 1;
855 }
856 }
857 KeyCode::Left => {
858 if self.pairing_cursor > 0 {
859 self.pairing_cursor -= 1;
860 }
861 }
862 KeyCode::Right => {
863 if self.pairing_cursor < self.pairing_code.len() {
864 self.pairing_cursor += 1;
865 }
866 }
867 _ => {}
868 },
869 Step::ApiHeader => match key.code {
870 KeyCode::Tab => self.step = Step::ApiKey,
871 KeyCode::Enter => {
872 let _ = self.advance_step();
873 }
874 KeyCode::Char(c) => {
875 let pos = self.header_cursor.min(self.api_header.len());
876 self.api_header.insert(pos, c);
877 self.header_cursor = pos + 1;
878 }
879 KeyCode::Backspace => {
880 if self.header_cursor > 0 && self.header_cursor <= self.api_header.len() {
881 self.api_header.remove(self.header_cursor - 1);
882 self.header_cursor -= 1;
883 }
884 }
885 KeyCode::Left => {
886 if self.header_cursor > 0 {
887 self.header_cursor -= 1;
888 }
889 }
890 KeyCode::Right => {
891 if self.header_cursor < self.api_header.len() {
892 self.header_cursor += 1;
893 }
894 }
895 _ => {}
896 },
897 Step::ApiKey => match key.code {
898 KeyCode::Tab => self.step = Step::ApiHeader,
899 KeyCode::Enter => {
900 let _ = self.advance_step();
901 }
902 KeyCode::Char(c) => {
903 self.reuse_keyring_api_key = false;
904 let pos = self.api_key_cursor.min(self.api_key.len());
905 self.api_key.insert(pos, c);
906 self.api_key_cursor = pos + 1;
907 }
908 KeyCode::Backspace => {
909 if self.api_key_cursor > 0 && self.api_key_cursor <= self.api_key.len() {
910 self.api_key.remove(self.api_key_cursor - 1);
911 self.api_key_cursor -= 1;
912 }
913 }
914 KeyCode::Left => {
915 if self.api_key_cursor > 0 {
916 self.api_key_cursor -= 1;
917 }
918 }
919 KeyCode::Right => {
920 if self.api_key_cursor < self.api_key.len() {
921 self.api_key_cursor += 1;
922 }
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.size();
948 self.render(f, area);
949 if let Some((x, y)) = self.cursor_pos(area) {
950 f.set_cursor(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.size();
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 assert_eq!(cfg.download_dir, "/tmp/romm-dl-test");
1073 }
1074}