1use mashrl::{
2 HTTP::{Headers, ResponseCode},
3 make_get_request,
4};
5use simple_json_parser::{JSONKey, RootJSONValue, parse as parse_json};
6
7use std::fs::File;
8use std::io::Read;
9use std::path::Path;
10
11#[cfg(target_os = "windows")]
12const OS_MATCHER: &str = "windows";
13#[cfg(target_os = "linux")]
14const OS_MATCHER: &str = "linux";
15#[cfg(target_os = "macos")]
16const OS_MATCHER: &str = "macos";
17
18#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
19const ARCH_MATCHER: &str = "x86";
20#[cfg(target_arch = "arm")]
21const ARCH_MATCHER: &str = "arm";
22#[cfg(target_arch = "aarch64")]
23const ARCH_MATCHER: &str = "aarch64";
24
25type BoxedError = Box<dyn std::error::Error>;
26
27#[derive(Debug, Clone, Copy)]
28pub struct DownloadOptions<'a> {
29 pub github_token: Option<&'a str>,
30 pub trace: bool,
31 pub pattern: utilities::Pattern<'a>,
32 pub tag: Option<&'a str>,
33 pub match_architecture: bool,
34}
35
36impl Default for DownloadOptions<'static> {
37 fn default() -> Self {
38 DownloadOptions {
39 github_token: None,
40 trace: false,
41 pattern: utilities::Pattern::all(),
42 tag: None,
43 match_architecture: true,
44 }
45 }
46}
47
48fn get_assets_from_github_releases(
49 owner: &str,
50 repository: &str,
51 options: DownloadOptions<'_>,
52) -> Result<mashrl::HTTP::Response<'static>, BoxedError> {
53 let mut headers = Headers::from_iter([
54 ("Accept", "application/vnd.github+json"),
55 ("X-GitHub-Api-Version", "2022-11-28"),
56 ("User-Agent", "kaleidwave/release-downloader"),
57 ]);
58
59 if let Some(token) = options.github_token {
60 headers.append("Authorization", &format!("Bearer {token}"));
61 }
62
63 let release_id: String = {
65 let path = if let Some(tag) = options.tag {
66 format!("repos/{owner}/{repository}/releases/tags/{tag}")
67 } else {
68 format!("repos/{owner}/{repository}/releases/latest")
69 };
70
71 let mut response = make_get_request("api.github.com", &path, &headers)?;
72
73 let mut body = String::new();
74 response.body.read_to_string(&mut body)?;
75
76 if response.code != ResponseCode::OK {
77 let message = format!(
78 "could not make request, repository ({repository}) or user ({owner}) may not exist. recieved {code:?} from 'api.github.com/{path}'. Recieved body ({body:?})",
79 code = response.code
80 );
81 return Err(message.into());
82 }
83
84 let mut release_id = "".to_owned();
85 let _ = parse_json(&body, |keys, value| {
86 if let [JSONKey::Slice("id")] = keys {
87 let RootJSONValue::Number(value) = value else {
88 panic!("expect asset label to be string")
89 };
90
91 release_id = value.to_owned();
92
93 }
95 });
96
97 release_id
98 };
99
100 const PER_PAGE: u8 = 100;
102
103 let path =
104 format!("repos/{owner}/{repository}/releases/{release_id}/assets?per_page={PER_PAGE}");
105 let response = make_get_request("api.github.com", &path, &headers)?;
106
107 if response.code == ResponseCode::OK {
108 Ok(response)
109 } else {
110 Err(format!(
111 "could not make request for assets. recieved {code:?} from 'api.github.com'",
112 code = response.code
113 )
114 .into())
115 }
116}
117
118pub fn get_asset_urls_and_names_from_github_releases(
120 owner: &str,
121 repository: &str,
122 options: DownloadOptions<'_>,
123) -> Result<Vec<(String, String)>, BoxedError> {
124 let mut response = get_assets_from_github_releases(owner, repository, options)?;
125
126 let mut body = String::new();
127 response.body.read_to_string(&mut body)?;
128
129 let mut download_next_release = false;
131 let mut name: &str = "";
132 let mut assets = Vec::new();
133 let mut asset_idx = 0;
134
135 let _ = parse_json(&body, |keys, value| {
136 if let [JSONKey::Index(idx), key] = keys {
137 match key {
138 JSONKey::Slice("label") => {
139 let RootJSONValue::String(value) = value else {
140 panic!("expect asset label to be string")
141 };
142 let name = if value.is_empty() { name } else { value };
147
148 let architecture = if options.match_architecture {
149 name.contains(OS_MATCHER) && name.contains(ARCH_MATCHER)
150 } else {
151 true
152 };
153
154 download_next_release = architecture && options.pattern.matches(name);
155
156 if options.trace {
157 let action = if download_next_release {
158 "downloading"
159 } else {
160 "not downloading"
161 };
162 let pattern = options.pattern;
163 eprintln!("{action} {name:?} (pattern = {pattern:?})");
164 }
165 }
166 JSONKey::Slice("browser_download_url") => {
167 if download_next_release {
168 let RootJSONValue::String(url) = value else {
169 panic!("expected asset url to be string")
170 };
171
172 assets.push((name.to_owned(), url.to_owned()));
173
174 }
176 }
177 JSONKey::Slice("name") => {
178 if let RootJSONValue::String(name2) = value {
179 name = name2;
180 };
181 }
182 _key => {
183 }
185 }
186 asset_idx = idx + 1;
187 }
188 });
189
190 if options.trace {
191 eprintln!("Scanned {asset_idx} assets");
192 }
193
194 Ok(assets)
195}
196
197pub fn download_from_github(
198 url: &str,
199 github_token: Option<&str>,
200) -> Result<impl Read, BoxedError> {
201 let mut headers = Headers::from_iter([
202 ("User-Agent", "kaleidwave/release-downloader"),
205 ]);
206
207 if let Some(token) = github_token {
208 headers.append("Authorization", &format!("Bearer {token}"));
209 }
210
211 let actual_asset_url = {
212 let url = url
213 .strip_prefix("https://github.com")
214 .ok_or_else(|| format!("Asset url {url:?} does not start with 'https://github.com'"))?;
215
216 let response = make_get_request("github.com", url, &headers)?;
217
218 let location = response
219 .headers
220 .iter()
221 .find_map(|(key, value)| (key == "Location").then_some(value.to_owned()));
222
223 location.ok_or("no location")?
224 };
225
226 let parts = actual_asset_url
228 .strip_prefix("https://")
229 .and_then(|url| url.split_once('/'));
230
231 let Some((base, url)) = parts else {
232 return Err("asset url does not start with 'https://' and have path".into());
233 };
234
235 let response = make_get_request(base, url, &headers)?;
236
237 Ok(response.body)
238}
239
240pub fn write_binary(
241 to: &str,
242 name: &str,
243 mut reader: impl Read,
244 only_binaries: bool,
245 trace: bool,
246) -> Result<(), BoxedError> {
247 let p = format!("{to}/{name}");
248 let path = Path::new(&p);
249 let to = Path::new(to);
250
251 #[cfg(feature = "decompress")]
252 if name.ends_with(".tar.gz") {
253 return extract_tar_gz(reader, to, only_binaries, trace);
254 } else if name.ends_with(".tar") {
255 return extract_tar(reader, to, only_binaries, trace);
256 } else if name.ends_with(".zip") {
257 #[cfg(windows)]
258 {
259 let mut buffer = Vec::new();
261 reader.read_to_end(&mut buffer)?;
262 let reader = std::io::Cursor::new(&buffer);
263 return extract_zip(reader, to, only_binaries, trace);
264 };
265
266 #[cfg(not(windows))]
267 panic!("cannot unzip on `not(windows)`")
268 }
269
270 if trace {
271 eprintln!("Writing to {path:?}");
272 }
273
274 let mut options = File::options();
275 options.write(true).truncate(true).read(true).create(true);
276 let mut file = options.open(path)?;
277
278 #[cfg(unix)]
279 {
280 let mut buf = [0; 4];
281 let _read_result = reader.read_exact(&mut buf);
283 let is_binary = &buf == b"\x7fELF"
284 || u32::from_le_bytes(buf) == 0xFEEDFACFu32
285 || u32::from_le_bytes(buf) == 0xFEEDFACEu32;
286
287 if is_binary {
288 let permission =
289 <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o777);
290 file.set_permissions(permission)?;
291 }
292
293 std::io::copy(&mut buf.as_slice(), &mut file)?;
295 }
296
297 std::io::copy(&mut reader, &mut file)?;
298
299 Ok(())
300}
301
302pub fn move_if_binary(
303 mut content: impl Read,
304 path: &Path,
305 only_binaries: bool,
306 trace: bool,
307) -> Result<(), BoxedError> {
308 use std::fs::File;
309 use std::io::{Write, copy};
310
311 let mut buf = [0; 4];
312 let _read_result = content.read_exact(&mut buf);
314 let is_binary = &buf == b"\x7fELF"
315 || u32::from_le_bytes(buf) == 0xFEEDFACFu32
316 || u32::from_le_bytes(buf) == 0xFEEDFACEu32;
317
318 if only_binaries && !is_binary {
319 return Ok(());
320 }
321
322 let mut file = File::create(path)?;
323
324 let _ = file.write(&buf)?;
326 let _ = copy(&mut content, &mut file)?;
327
328 #[cfg(unix)]
330 if is_binary {
331 let permission =
332 <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o777);
333 file.set_permissions(permission)?;
334 }
335
336 if trace {
337 eprintln!("Writing to {path:?}");
338 }
339
340 Ok(())
341}
342
343#[cfg(feature = "decompress")]
344pub fn extract_tar(
345 reader: impl Read,
346 output_dir: &Path,
347 only_binaries: bool,
348 trace: bool,
349) -> Result<(), BoxedError> {
350 let mut archive = tar::Archive::new(reader);
351 let entries = archive.entries()?;
353 for entry in entries {
354 let entry = entry?;
355 if let Some(name) = entry.path()?.file_name() {
356 let path = output_dir.join(name);
357 move_if_binary(entry, &path, only_binaries, trace)?;
358 }
359 }
360 Ok(())
361}
362
363#[cfg(feature = "decompress")]
364pub fn extract_tar_gz(
365 reader: impl Read,
366 output_dir: &Path,
367 only_binaries: bool,
368 trace: bool,
369) -> Result<(), BoxedError> {
370 let decompressor = flate2::read::GzDecoder::new(reader);
371 extract_tar(decompressor, output_dir, only_binaries, trace)
372}
373
374#[cfg(all(windows, feature = "decompress"))]
375pub fn extract_zip(
376 reader: impl Read + std::io::Seek,
377 output_dir: &Path,
378 only_binaries: bool,
379 trace: bool,
380) -> Result<(), BoxedError> {
381 let mut archive = zip::ZipArchive::new(reader)?;
382 for i in 0..archive.len() {
383 let entry = archive.by_index(i)?;
384 if entry.is_file()
385 && let Some(name) = Path::new(entry.name()).file_name()
386 {
387 let path = output_dir.join(name);
388 move_if_binary(entry, &path, only_binaries, trace)?;
389 }
390 }
391
392 Ok(())
393}
394
395#[cfg(feature = "self-update")]
397pub fn replace_self(mut content: impl Read) -> Result<(), BoxedError> {
398 let temporary_binary_name = "temporary";
399 let mut file = File::create(temporary_binary_name)?;
400
401 std::io::copy(&mut content, &mut file)?;
402
403 self_replace::self_replace(temporary_binary_name)?;
404 std::fs::remove_file(temporary_binary_name)?;
405
406 Ok(())
407}
408
409pub fn get_statistics(
411 owner: &str,
412 repository: &str,
413 options: DownloadOptions<'_>,
414) -> Result<Vec<(String, usize)>, BoxedError> {
415 let mut response = get_assets_from_github_releases(owner, repository, options)?;
416
417 let mut body = String::new();
418 response.body.read_to_string(&mut body)?;
419
420 let mut name: &str = "";
421
422 let mut items = Vec::new();
423
424 let _ = parse_json(&body, |keys, value| {
425 if let [JSONKey::Index(_idx), key] = keys {
426 match key {
427 JSONKey::Slice("download_count") => {
428 let RootJSONValue::Number(count) = value else {
429 panic!("expected download count to be number")
430 };
431
432 items.push((name.to_owned(), count.parse().unwrap()));
433 }
434 JSONKey::Slice("name") => {
435 if let RootJSONValue::String(name2) = value {
436 name = name2;
437 };
438 }
439 _key => {
440 }
442 }
443 }
444 });
445
446 Ok(items)
447}
448
449pub mod utilities {
450 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
451 pub struct Pattern<'a>(&'a str);
452
453 impl<'a> Pattern<'a> {
454 pub fn new(pattern: &'a str) -> Self {
455 Self(pattern)
456 }
457
458 pub fn all() -> Self {
459 Self("*")
460 }
461
462 pub fn matches(&self, value: &str) -> bool {
464 if let "*" = self.0 {
465 true
466 } else {
467 for part in self.0.split('|') {
468 if value.contains(part) {
469 return true;
470 }
471 }
472 false
473 }
474 }
475 }
476}