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