Skip to main content

novel_api/common/utils/
self_update.rs

1use std::env;
2use std::env::consts;
3use std::io::Cursor;
4use std::path::{Path, PathBuf};
5
6use bon::bon;
7use bytes::Bytes;
8use current_platform::CURRENT_PLATFORM;
9use hex_simd::AsciiCase;
10use http::{HeaderMap, HeaderValue};
11use osc94::Progress;
12use semver::{Version, VersionReq};
13use serde::Deserialize;
14use serde::de::DeserializeOwned;
15use url::Url;
16
17use crate::{Error, HTTPClient};
18
19pub struct SelfUpdate {
20    owner: String,
21    repo: String,
22    auth_token: Option<String>,
23    bin_name: String,
24    current_version: Version,
25    show_release_body: bool,
26
27    client: HTTPClient,
28    client_rss: HTTPClient,
29}
30
31#[derive(Deserialize)]
32pub struct LatestReleases {
33    pub tag_name: String,
34    pub body: String,
35    pub assets: Vec<ReleaseAsset>,
36}
37
38#[derive(Deserialize)]
39pub struct ReleaseAsset {
40    pub name: String,
41    pub url: Url,
42    pub digest: String,
43}
44
45#[bon]
46impl SelfUpdate {
47    const HOST: &'static str = "https://api.github.com";
48    const API_VERSION: &'static str = "2022-11-28";
49
50    #[builder]
51    pub async fn new(
52        owner: &str,
53        repo: &str,
54        auth_token: Option<&str>,
55        bin_name: &str,
56        current_version: Version,
57        show_release_body: bool,
58        proxy: Option<Url>,
59        no_proxy: Option<bool>,
60        cert_path: Option<PathBuf>,
61    ) -> Result<Self, Error> {
62        let mut headers = HeaderMap::new();
63        headers.insert(
64            "X-GitHub-Api-Version",
65            HeaderValue::from_static(SelfUpdate::API_VERSION),
66        );
67
68        let client = HTTPClient::builder()
69            .app_name("self-update")
70            .accept(HeaderValue::from_static("application/vnd.github+json"))
71            .headers(headers)
72            .maybe_proxy(proxy.clone())
73            .maybe_no_proxy(no_proxy)
74            .maybe_cert_path(cert_path.clone())
75            .retry_url(Url::parse(SelfUpdate::HOST)?)
76            .build()
77            .await?;
78
79        let client_rss = HTTPClient::builder()
80            .app_name("self-update-rss")
81            .accept(HeaderValue::from_static("application/octet-stream"))
82            .maybe_proxy(proxy)
83            .maybe_no_proxy(no_proxy)
84            .maybe_cert_path(cert_path.clone())
85            .build()
86            .await?;
87
88        Ok(Self {
89            owner: owner.to_string(),
90            repo: repo.to_string(),
91            auth_token: auth_token.map(str::to_owned),
92            bin_name: bin_name.to_string(),
93            current_version,
94            show_release_body,
95            client,
96            client_rss,
97        })
98    }
99
100    pub async fn update(self) -> Result<(), Error> {
101        println!("Checking target-arch... {CURRENT_PLATFORM}");
102        println!("Checking current version... {}", self.current_version);
103
104        let latest_releases = self.get_latest_releases().await?;
105        let latest_version = Version::parse(latest_releases.tag_name.trim_start_matches("v"))?;
106        println!("Checking latest released version... {latest_version}");
107
108        if !self.need_update(&latest_version)? {
109            return Ok(());
110        }
111
112        println!(
113            "New release found! {} --> {latest_version}",
114            self.current_version
115        );
116        if self.show_release_body {
117            println!("Release notes...\n\n{}", latest_releases.body);
118        }
119
120        let Some(target_asset) = self.get_target_asset(&latest_releases)? else {
121            return Err(Error::NovelApi(String::from(
122                "no assets available for download",
123            )));
124        };
125
126        println!(
127            r#"
128{} release status:
129  * Current exe: "{}"
130  * New exe release: "{}"
131  * New exe download url: "{}"
132"#,
133            self.bin_name,
134            env::current_exe()?.display(),
135            target_asset.name,
136            target_asset.url
137        );
138
139        println!(
140            "The new release will be downloaded/extracted and the existing binary will be replaced"
141        );
142        if !crate::confirm("Do you want to continue?")? {
143            return Ok(());
144        }
145
146        let bytes = if crate::support_osc94() {
147            let mut progress = Progress::default();
148            progress.indeterminate().flush()?;
149            let bytes = self.download_with_verify(target_asset).await?;
150            progress.hidden();
151            bytes
152        } else {
153            self.download_with_verify(target_asset).await?
154        };
155
156        let temp_dir = tempfile::tempdir()?;
157        crate::unzip(Cursor::new(bytes), &temp_dir)?;
158
159        let new_release_exe =
160            temp_dir
161                .path()
162                .join(format!("{}{}", self.bin_name, consts::EXE_SUFFIX));
163        self_replace::self_replace(new_release_exe)?;
164
165        Ok(())
166    }
167
168    async fn get_latest_releases(&self) -> Result<LatestReleases, Error> {
169        let url = format!("/repos/{}/{}/releases/latest", self.owner, self.repo);
170        let latest_releases: LatestReleases = self.get(url).await?;
171        Ok(latest_releases)
172    }
173
174    fn need_update(&self, latest_version: &Version) -> Result<bool, Error> {
175        let req = VersionReq::parse(&format!(">{}", self.current_version))?;
176        Ok(req.matches(latest_version))
177    }
178
179    fn get_target_asset<'a>(
180        &self,
181        latest_releases: &'a LatestReleases,
182    ) -> Result<Option<&'a ReleaseAsset>, Error> {
183        for asset in &latest_releases.assets {
184            if asset.name.contains(CURRENT_PLATFORM)
185                && Path::new(&asset.name)
186                    .extension()
187                    .is_some_and(|ext| ext == "zip")
188            {
189                return Ok(Some(asset));
190            }
191        }
192
193        Ok(None)
194    }
195
196    async fn download_with_verify(&self, asset: &ReleaseAsset) -> Result<Bytes, Error> {
197        let bytes = self.get_rss(asset.url.to_string()).await?;
198
199        let hash = crate::sha256_hex(&bytes, AsciiCase::Lower);
200        let asset_hash = asset.digest.trim_start_matches("sha256:");
201
202        if hash != asset_hash {
203            return Err(Error::NovelApi(format!(
204                "incorrect hash value: `{hash}` vs `{asset_hash}`"
205            )));
206        }
207
208        Ok(bytes)
209    }
210
211    async fn get<T, R>(&self, url: T) -> Result<R, Error>
212    where
213        T: AsRef<str>,
214        R: DeserializeOwned,
215    {
216        let mut builder = self.client.get(SelfUpdate::HOST.to_string() + url.as_ref());
217        if let Some(auth_token) = &self.auth_token {
218            builder = builder.bearer_auth(auth_token);
219        }
220
221        let response = builder.send().await?.error_for_status()?;
222
223        Ok(sonic_rs::from_slice(&response.bytes().await?)?)
224    }
225
226    async fn get_rss<T>(&self, url: T) -> Result<Bytes, Error>
227    where
228        T: AsRef<str>,
229    {
230        let mut builder = self.client_rss.get(url.as_ref());
231        if let Some(auth_token) = &self.auth_token {
232            builder = builder.bearer_auth(auth_token);
233        }
234
235        Ok(builder.send().await?.error_for_status()?.bytes().await?)
236    }
237}