romm_cli/tui/screens/setup_wizard/
config.rs1use anyhow::{anyhow, Context, Result};
4
5use crate::client::RommClient;
6use crate::config::{
7 default_theme_id, is_keyring_placeholder, load_config, normalize_romm_origin,
8 persist_user_config, read_user_config_json_from_disk, AuthConfig, Config, RomsLayoutConfig,
9 TuiLayoutConfig,
10};
11use crate::core::download::validate_configured_download_directory;
12use crate::endpoints::client_tokens::ExchangeClientToken;
13use crate::tui::path_picker::{PathPicker, PathPickerMode};
14
15use super::layout::extras_defaults_from_disk;
16use super::types::{AuthKind, SetupWizard, Step};
17
18fn tui_layout_from_disk() -> TuiLayoutConfig {
19 read_user_config_json_from_disk()
20 .map(|c| c.tui_layout.normalized())
21 .unwrap_or_default()
22}
23
24impl SetupWizard {
25 pub fn new() -> Self {
26 let default_dl = dirs::download_dir()
27 .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Downloads"))
28 .join("romm-cli")
29 .display()
30 .to_string();
31 Self {
32 step: Step::Url,
33 auth_kind: AuthKind::Pairing,
34 auth_menu_selected: 0,
35 url: "https://".to_string(),
36 url_cursor: "https://".len(),
37 download_picker: PathPicker::new(PathPickerMode::Directory, &default_dl),
38 username: String::new(),
39 user_cursor: 0,
40 password: String::new(),
41 bearer_token: String::new(),
42 bearer_cursor: 0,
43 api_header: String::new(),
44 header_cursor: 0,
45 api_key: String::new(),
46 api_key_cursor: 0,
47 pairing_code: String::new(),
48 pairing_cursor: 0,
49 reuse_keyring_password: false,
50 reuse_keyring_bearer: false,
51 reuse_keyring_api_key: false,
52 testing: false,
53 use_https: true,
54 skip_custom_console_paths: false,
55 error: None,
56 }
57 }
58
59 pub fn new_auth_only(config: &Config) -> Self {
60 let mut wizard = Self::new();
61 wizard.step = Step::AuthMenu;
62 wizard.url = config.base_url.clone();
63 wizard
64 .download_picker
65 .set_path_text(config.download_dir.clone());
66 wizard.use_https = config.use_https;
67 wizard.skip_custom_console_paths = true;
68
69 let disk = read_user_config_json_from_disk();
70
71 match &config.auth {
72 Some(AuthConfig::Basic { username, password }) => {
73 wizard.auth_kind = AuthKind::Basic;
74 wizard.auth_menu_selected = 1;
75 wizard.username = username.clone();
76 wizard.user_cursor = username.len();
77 let disk_pass = disk
78 .as_ref()
79 .and_then(|c| c.auth.as_ref())
80 .and_then(|a| match a {
81 AuthConfig::Basic { password, .. } => Some(password.as_str()),
82 _ => None,
83 });
84 if disk_pass.is_some_and(is_keyring_placeholder) {
85 wizard.password = String::new();
86 wizard.reuse_keyring_password = true;
87 } else {
88 wizard.password = password.clone();
89 }
90 }
91 Some(AuthConfig::Bearer { token }) => {
92 wizard.auth_kind = AuthKind::Bearer;
93 wizard.auth_menu_selected = 2;
94 let disk_tok = disk
95 .as_ref()
96 .and_then(|c| c.auth.as_ref())
97 .and_then(|a| match a {
98 AuthConfig::Bearer { token } => Some(token.as_str()),
99 _ => None,
100 });
101 if disk_tok.is_some_and(is_keyring_placeholder) {
102 wizard.bearer_token = String::new();
103 wizard.bearer_cursor = 0;
104 wizard.reuse_keyring_bearer = true;
105 } else {
106 wizard.bearer_token = token.clone();
107 wizard.bearer_cursor = token.len();
108 }
109 }
110 Some(AuthConfig::ApiKey { header, key }) => {
111 wizard.auth_kind = AuthKind::ApiKey;
112 wizard.auth_menu_selected = 3;
113 wizard.api_header = header.clone();
114 wizard.header_cursor = header.len();
115 let disk_key = disk
116 .as_ref()
117 .and_then(|c| c.auth.as_ref())
118 .and_then(|a| match a {
119 AuthConfig::ApiKey { key, .. } => Some(key.as_str()),
120 _ => None,
121 });
122 if disk_key.is_some_and(is_keyring_placeholder) {
123 wizard.api_key = String::new();
124 wizard.api_key_cursor = 0;
125 wizard.reuse_keyring_api_key = true;
126 } else {
127 wizard.api_key = key.clone();
128 wizard.api_key_cursor = key.len();
129 }
130 }
131 None => {
132 wizard.auth_kind = AuthKind::Pairing;
133 wizard.auth_menu_selected = 0;
134 }
135 }
136 wizard
137 }
138
139 pub(crate) fn auth_labels() -> [&'static str; 4] {
140 [
141 "Pair with Web UI (8-character code) (Recommended)",
142 "Username + password",
143 "API Token",
144 "API key in custom header",
145 ]
146 }
147
148 pub(crate) fn auth_kind_from_index(i: usize) -> AuthKind {
149 match i {
150 0 => AuthKind::Pairing,
151 1 => AuthKind::Basic,
152 2 => AuthKind::Bearer,
153 _ => AuthKind::ApiKey,
154 }
155 }
156
157 fn roms_layout_from_wizard(&self) -> RomsLayoutConfig {
158 read_user_config_json_from_disk()
159 .map(|c| c.roms_layout)
160 .unwrap_or_default()
161 }
162
163 pub(crate) async fn pairing_config_from_exchange(&self, verbose: bool) -> Result<Config> {
165 let base_url = normalize_romm_origin(self.url.trim());
166 if base_url.is_empty() {
167 return Err(anyhow!("Server URL cannot be empty"));
168 }
169 let code = self.pairing_code.trim().to_string();
170 if code.is_empty() {
171 return Err(anyhow!("Pairing code cannot be empty"));
172 }
173 let download_dir =
174 validate_configured_download_directory(self.download_picker.path_trimmed().trim())?
175 .display()
176 .to_string();
177 let temp_config = Config {
178 base_url: base_url.clone(),
179 download_dir: download_dir.clone(),
180 use_https: self.use_https,
181 auth: None,
182 extras_defaults: extras_defaults_from_disk(),
183 save_sync: read_user_config_json_from_disk()
184 .map(|c| c.save_sync)
185 .unwrap_or_default(),
186 roms_layout: self.roms_layout_from_wizard(),
187 theme: read_user_config_json_from_disk()
188 .map(|c| c.theme)
189 .unwrap_or_else(default_theme_id),
190 tui_layout: tui_layout_from_disk(),
191 };
192 let client = RommClient::new(&temp_config, verbose)?;
193 let response = client
194 .call(&ExchangeClientToken { code })
195 .await
196 .context("failed to exchange pairing code")?;
197 Ok(Config {
198 base_url,
199 download_dir,
200 use_https: self.use_https,
201 auth: Some(AuthConfig::Bearer {
202 token: response.raw_token,
203 }),
204 extras_defaults: extras_defaults_from_disk(),
205 save_sync: read_user_config_json_from_disk()
206 .map(|c| c.save_sync)
207 .unwrap_or_default(),
208 roms_layout: self.roms_layout_from_wizard(),
209 theme: read_user_config_json_from_disk()
210 .map(|c| c.theme)
211 .unwrap_or_else(default_theme_id),
212 tui_layout: tui_layout_from_disk(),
213 })
214 }
215
216 fn build_config(&self) -> Result<Config> {
217 let base_url = normalize_romm_origin(self.url.trim());
218 if base_url.is_empty() {
219 return Err(anyhow!("Server URL cannot be empty"));
220 }
221 let download_dir =
222 validate_configured_download_directory(self.download_picker.path_trimmed().trim())?
223 .display()
224 .to_string();
225 let auth: Option<AuthConfig> = match self.auth_kind {
226 AuthKind::Basic => {
227 let u = self.username.trim();
228 if u.is_empty() {
229 return Err(anyhow!("Username cannot be empty"));
230 }
231 let password = if self.password.is_empty() && self.reuse_keyring_password {
232 crate::config::keyring_get("API_PASSWORD").ok_or_else(|| {
233 anyhow!("Password not in OS keyring; enter a password or run romm-cli init")
234 })?
235 } else if self.password.is_empty() {
236 return Err(anyhow!("Password cannot be empty"));
237 } else {
238 self.password.clone()
239 };
240 Some(AuthConfig::Basic {
241 username: u.to_string(),
242 password,
243 })
244 }
245 AuthKind::Bearer => {
246 let token = if self.bearer_token.trim().is_empty() && self.reuse_keyring_bearer {
247 crate::config::keyring_get("API_TOKEN").ok_or_else(|| {
248 anyhow!("API token not in OS keyring; enter a token or run romm-cli init")
249 })?
250 } else if self.bearer_token.trim().is_empty() {
251 return Err(anyhow!("Bearer token cannot be empty"));
252 } else {
253 self.bearer_token.trim().to_string()
254 };
255 Some(AuthConfig::Bearer { token })
256 }
257 AuthKind::ApiKey => {
258 let h = self.api_header.trim();
259 if h.is_empty() {
260 return Err(anyhow!("Header name cannot be empty"));
261 }
262 let key = if self.api_key.is_empty() && self.reuse_keyring_api_key {
263 crate::config::keyring_get("API_KEY").ok_or_else(|| {
264 anyhow!("API key not in OS keyring; enter a key or run romm-cli init")
265 })?
266 } else if self.api_key.is_empty() {
267 return Err(anyhow!("API key cannot be empty"));
268 } else {
269 self.api_key.clone()
270 };
271 Some(AuthConfig::ApiKey {
272 header: h.to_string(),
273 key,
274 })
275 }
276 AuthKind::Pairing => {
277 return Err(anyhow!(
278 "Pairing auth is applied when connecting; use the pairing code step and connect"
279 ));
280 }
281 };
282 Ok(Config {
283 base_url,
284 download_dir,
285 use_https: self.use_https,
286 auth,
287 extras_defaults: extras_defaults_from_disk(),
288 save_sync: read_user_config_json_from_disk()
289 .map(|c| c.save_sync)
290 .unwrap_or_default(),
291 roms_layout: self.roms_layout_from_wizard(),
292 theme: read_user_config_json_from_disk()
293 .map(|c| c.theme)
294 .unwrap_or_else(default_theme_id),
295 tui_layout: tui_layout_from_disk(),
296 })
297 }
298
299 pub async fn try_connect_and_persist(&mut self, verbose: bool) -> Result<Config> {
300 let cfg = if self.auth_kind == AuthKind::Pairing {
301 self.pairing_config_from_exchange(verbose).await?
302 } else {
303 self.build_config()?
304 };
305 let client = RommClient::new(&cfg, verbose)?;
306 client.fetch_openapi_json().await?;
307 persist_user_config(&cfg)?;
308 load_config().map_err(Into::into)
309 }
310}