novel_api/common/utils/
self_update.rs1use 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}