Skip to main content

banished_nexus/
native.rs

1//! Sending requests and downloading files on native builds (blocking; run it off the main
2//! thread in the game), and writing installed packages to the pack folder.
3
4use std::io::Read;
5use std::path::{Path, PathBuf};
6
7use crate::{install::Package, Error, Request};
8
9fn agent() -> ureq::Agent {
10    ureq::Agent::config_builder()
11        .http_status_as_error(false)
12        .timeout_global(Some(std::time::Duration::from_secs(600)))
13        .build()
14        .into()
15}
16
17/// Send a request and return the body of a 2xx answer. `download` marks a download-link
18/// request, whose 403 means the account needs Premium.
19pub fn send(request: &Request, download: bool) -> Result<Vec<u8>, Error> {
20    let agent = agent();
21    let result = match request.method {
22        "POST" => {
23            let mut r = agent.post(&request.url);
24            for (k, v) in &request.headers {
25                r = r.header(k, v);
26            }
27            r.send(request.body.clone().unwrap_or_default())
28        }
29        _ => {
30            let mut r = agent.get(&request.url);
31            for (k, v) in &request.headers {
32                r = r.header(k, v);
33            }
34            r.call()
35        }
36    };
37    let mut response = result.map_err(|e| Error::Network(e.to_string()))?;
38    let status = response.status().as_u16();
39    let body = response
40        .body_mut()
41        .with_config()
42        .limit(16 << 20)
43        .read_to_vec()
44        .map_err(|e| Error::Network(e.to_string()))?;
45    if !(200..300).contains(&status) {
46        return Err(crate::api::status_error(status, &body, download));
47    }
48    Ok(body)
49}
50
51/// Download a file, calling `progress(done, total)` as it arrives (`total` when the server
52/// says it). Download links need no headers.
53pub fn download(url: &str, mut progress: impl FnMut(u64, Option<u64>)) -> Result<Vec<u8>, Error> {
54    let mut response = agent()
55        .get(url)
56        .call()
57        .map_err(|e| Error::Network(e.to_string()))?;
58    let status = response.status().as_u16();
59    if !(200..300).contains(&status) {
60        return Err(Error::Status(status, "the download failed".into()));
61    }
62    let total = response
63        .headers()
64        .get("content-length")
65        .and_then(|v| v.to_str().ok())
66        .and_then(|v| v.parse().ok());
67    let mut reader = response
68        .body_mut()
69        .with_config()
70        .limit(crate::install::MAX_PACKAGE * 2)
71        .reader();
72    let mut data = Vec::with_capacity(total.unwrap_or(0).min(256 << 20) as usize);
73    let mut chunk = vec![0; 64 << 10];
74    loop {
75        let n = reader
76            .read(&mut chunk)
77            .map_err(|e| Error::Network(e.to_string()))?;
78        if n == 0 {
79            break;
80        }
81        data.extend_from_slice(&chunk[..n]);
82        progress(data.len() as u64, total);
83    }
84    Ok(data)
85}
86
87/// Write packages into the pack folder (`WinData`), replacing packages of the same name. Each
88/// is written to a temporary file first and then renamed, so a failed write leaves no
89/// half-written package.
90pub fn install(dir: &Path, packages: &[Package]) -> Result<Vec<PathBuf>, Error> {
91    let mut written = Vec::new();
92    for p in packages {
93        let path = dir.join(&p.name);
94        let temp = dir.join(format!(".{}.download", p.name));
95        let io = |e: std::io::Error| Error::Network(format!("could not write {}: {e}", p.name));
96        std::fs::write(&temp, &p.data).map_err(io)?;
97        std::fs::rename(&temp, &path).map_err(io)?;
98        written.push(path);
99    }
100    Ok(written)
101}
102
103/// What to install: a file of a mod (Premium), or the file an `nxm://` link names.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum Source {
106    /// A file from a mod's file list (needs Premium).
107    File {
108        /// The mod's id.
109        mod_id: u32,
110        /// The file's id.
111        file_id: u32,
112        /// The file's name on disk, which tells a bare `.pkm` from an archive.
113        file_name: String,
114    },
115    /// The file a "Mod Manager Download" link names.
116    Link(crate::nxm::NxmLink),
117}
118
119/// How far an installation has got, for a progress display.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum Step {
122    /// Asking Nexus Mods for the download link.
123    Link,
124    /// Downloading: bytes so far, and the size when known.
125    Download(u64, Option<u64>),
126    /// Unpacking and writing the packages.
127    Install,
128}
129
130/// Get the download link for `source`, download it, take out its `.pkm` packages and write
131/// them to `dir`. Returns the paths written.
132pub fn install_from(
133    app: &crate::App,
134    key: &str,
135    source: &Source,
136    dir: &Path,
137    mut progress: impl FnMut(Step),
138) -> Result<Vec<PathBuf>, Error> {
139    progress(Step::Link);
140    let (mod_id, file_id, link) = match source {
141        Source::File {
142            mod_id, file_id, ..
143        } => (*mod_id, *file_id, None),
144        Source::Link(l) => (l.mod_id, l.file_id, Some(l)),
145    };
146    let request = crate::api::download_link(app, key, mod_id, file_id, link);
147    let links = crate::api::parse_download_links(&send(&request, true)?)?;
148    let url = &links[0];
149    let data = download(url, |done, total| progress(Step::Download(done, total)))?;
150    progress(Step::Install);
151    // The file name is the last part of the download address, without its query.
152    let file_name = match source {
153        Source::File { file_name, .. } if !file_name.is_empty() => file_name.clone(),
154        _ => url
155            .split('?')
156            .next()
157            .and_then(|u| u.rsplit('/').next())
158            .map(|n| n.replace("%20", " "))
159            .unwrap_or_default(),
160    };
161    let packages = crate::install::packages(&file_name, &data)?;
162    install(dir, &packages)
163}
164
165/// The environment variable that holds an API key; it wins over the settings file.
166pub const KEY_VARIABLE: &str = "BANISHED_NEXUS_API_KEY";
167
168/// The settings file's key for the API key (the file is `key=value` lines).
169pub const KEY_SETTING: &str = "NexusApiKey";
170
171/// The game's settings file: `BANISHED_SETTINGS` if set, else `settings.txt` in the
172/// `banished-wasm` folder of the platform's configuration folder (as the game reads it).
173pub fn settings_path() -> Option<PathBuf> {
174    if let Ok(p) = std::env::var("BANISHED_SETTINGS") {
175        return (!p.is_empty()).then(|| PathBuf::from(p));
176    }
177    let base = if cfg!(target_os = "windows") {
178        std::env::var_os("APPDATA").map(PathBuf::from)
179    } else if cfg!(target_os = "macos") {
180        std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support"))
181    } else {
182        std::env::var_os("XDG_CONFIG_HOME")
183            .map(PathBuf::from)
184            .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
185    }?;
186    Some(base.join("banished-wasm").join("settings.txt"))
187}
188
189/// The API key: [`KEY_VARIABLE`], else the [`KEY_SETTING`] line of the settings file. An
190/// empty value counts as none.
191pub fn stored_key() -> Option<String> {
192    if let Ok(k) = std::env::var(KEY_VARIABLE) {
193        let k = k.trim();
194        return (!k.is_empty()).then(|| k.to_string());
195    }
196    let text = std::fs::read_to_string(settings_path()?).ok()?;
197    key_in_settings(&text)
198}
199
200/// The [`KEY_SETTING`] value in a settings file's text.
201pub fn key_in_settings(text: &str) -> Option<String> {
202    text.lines()
203        .filter_map(|l| l.split_once('='))
204        .find(|(k, _)| k.trim() == KEY_SETTING)
205        .map(|(_, v)| v.trim().to_string())
206        .filter(|v| !v.is_empty())
207}
208
209#[cfg(test)]
210mod tests {
211    #[test]
212    fn reads_the_key_from_settings() {
213        let text = "VideoVSync=true\nNexusApiKey= abc123 \n";
214        assert_eq!(super::key_in_settings(text).as_deref(), Some("abc123"));
215        assert_eq!(super::key_in_settings("NexusApiKey=\n"), None);
216    }
217}