Skip to main content

romm_cli/tui/screens/setup_wizard/
config.rs

1//! Config construction and pairing exchange for the setup wizard.
2
3use 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    /// Build config after exchanging a Web UI pairing code (unauthenticated POST).
164    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}