1use std::path::PathBuf;
7use std::process::Command;
8use std::time::Duration;
9
10use chrono::{NaiveDate, Utc};
11use semver::Version;
12use serde::Deserialize;
13use thiserror::Error;
14
15use crate::storage::{self, ProductVersion, StorageError, VersionFile, VersionType};
16use crate::urls::SQUIGIT_RELEASES_URL;
17
18const UPDATE_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
19
20#[derive(Debug, Error)]
21pub enum UpdateError {
22 #[error("Network request failed: {0}")]
23 Network(String),
24 #[error("Invalid releases.json payload: {0}")]
25 InvalidRemote(String),
26 #[error("Invalid CalVer format: {0}")]
27 InvalidCalVer(String),
28 #[error("Invalid SemVer format: {0}")]
29 InvalidSemVer(String),
30 #[error("Version storage failed: {0}")]
31 Storage(#[from] StorageError),
32 #[error("Version-store lock task failed: {0}")]
33 LockTask(String),
34}
35
36pub type Result<T> = std::result::Result<T, UpdateError>;
37
38#[derive(Clone, Copy, Debug, Eq, PartialEq)]
39pub enum UpdateProduct {
40 App,
41 Cli,
42 Ocr,
43}
44
45impl UpdateProduct {
46 pub fn key(self) -> &'static str {
47 match self {
48 Self::App => "app",
49 Self::Cli => "cli",
50 Self::Ocr => "ocr",
51 }
52 }
53
54 pub fn display_name(self) -> &'static str {
55 match self {
56 Self::App => "Squigit",
57 Self::Cli => "Squigit CLI",
58 Self::Ocr => "Squigit OCR",
59 }
60 }
61}
62
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64pub enum UpdateShell {
65 App,
66 Cli,
67}
68
69#[derive(Clone, Debug)]
70pub struct PendingUpdate {
71 pub product: UpdateProduct,
72 pub product_name: String,
73 pub current_version: String,
74 pub latest_version: String,
75 pub released_at: String,
76 pub content: String,
77}
78
79#[derive(Clone, Debug, Default)]
80pub struct UpdateRefreshContext {
81 pub app_version: Option<String>,
82 pub ocr_resource_dir: Option<PathBuf>,
83}
84
85#[derive(Clone, Copy, Debug, Eq, PartialEq)]
86pub enum RefreshSource {
87 Network,
88 Cache,
89}
90
91#[derive(Clone, Copy, Debug, Eq, PartialEq)]
92pub struct RefreshOutcome {
93 pub source: RefreshSource,
94}
95
96#[derive(Debug, Deserialize)]
97struct RemoteVersionFile {
98 app: RemoteProductVersion,
99 cli: RemoteProductVersion,
100 ocr: RemoteProductVersion,
101}
102
103#[derive(Debug, Deserialize)]
104struct RemoteProductVersion {
105 current_version: Option<String>,
106 latest_version: String,
107 version_type: VersionType,
108 released_at: String,
109 content: String,
110}
111
112impl RemoteProductVersion {
113 fn into_stored(self, current_version: Option<String>) -> ProductVersion {
114 ProductVersion {
115 current_version,
116 latest_version: self.latest_version,
117 version_type: self.version_type,
118 released_at: self.released_at,
119 content: self.content,
120 }
121 }
122}
123
124pub async fn refresh_version_file(context: UpdateRefreshContext) -> Result<RefreshOutcome> {
125 refresh_version_file_with_cli(context, None).await
126}
127
128pub async fn refresh_cli_version_file(cli_version: String) -> Result<RefreshOutcome> {
131 refresh_version_file_with_cli(UpdateRefreshContext::default(), Some(cli_version)).await
132}
133
134async fn refresh_version_file_with_cli(
135 context: UpdateRefreshContext,
136 cli_version: Option<String>,
137) -> Result<RefreshOutcome> {
138 let store = storage::version_store()?;
139 let lock_store = store.clone();
140 let guard = tokio::task::spawn_blocking(move || lock_store.lock())
141 .await
142 .map_err(|error| UpdateError::LockTask(error.to_string()))??;
143
144 let cached = guard.load()?;
145 let app_version = non_empty(context.app_version).or_else(|| {
146 cached
147 .as_ref()
148 .and_then(|file| file.app.current_version.clone())
149 });
150 let _ocr_resource_dir = context.ocr_resource_dir;
151 let explicit_cli_version = non_empty(cli_version);
152 let cli_version_future = async {
153 match explicit_cli_version {
154 Some(version) => Some(version),
155 None => discover_cli_version().await,
156 }
157 };
158 let (remote_result, cli_version, ocr_version) = tokio::join!(
159 fetch_remote_versions(),
160 cli_version_future,
161 discover_ocr_version(),
162 );
163 let cli_version = cli_version.or_else(|| {
164 cached
165 .as_ref()
166 .and_then(|file| file.cli.current_version.clone())
167 });
168 let ocr_version = ocr_version.or_else(|| {
169 cached
170 .as_ref()
171 .and_then(|file| file.ocr.current_version.clone())
172 });
173
174 let remote_result = remote_result.and_then(|remote| {
175 validate_remote_file(&remote)?;
176 Ok(remote)
177 });
178
179 match remote_result {
180 Ok(remote) => {
181 let file = VersionFile {
182 app: remote.app.into_stored(app_version),
183 cli: remote.cli.into_stored(cli_version),
184 ocr: remote.ocr.into_stored(ocr_version),
185 last_fetch_at: Utc::now(),
186 };
187 guard.save(&file)?;
188 Ok(RefreshOutcome {
189 source: RefreshSource::Network,
190 })
191 }
192 Err(network_error) => {
193 let Some(mut cached) = cached else {
194 return Err(network_error);
195 };
196 cached.app.current_version = app_version;
197 cached.cli.current_version = cli_version;
198 cached.ocr.current_version = ocr_version;
199 guard.save(&cached)?;
200 Ok(RefreshOutcome {
201 source: RefreshSource::Cache,
202 })
203 }
204 }
205}
206
207pub fn decide_update(shell: UpdateShell) -> Result<Option<PendingUpdate>> {
208 let store = storage::version_store()?;
209 let Some(file) = store.load()? else {
210 return Ok(None);
211 };
212
213 let (shell_product, shell_version) = match shell {
214 UpdateShell::App => (UpdateProduct::App, &file.app),
215 UpdateShell::Cli => (UpdateProduct::Cli, &file.cli),
216 };
217
218 match product_is_outdated(shell_version)? {
219 Some(true) => return Ok(Some(pending_update(shell_product, shell_version))),
220 Some(false) => {}
221 None => return Ok(None),
222 }
223
224 match product_is_outdated(&file.ocr)? {
225 Some(true) => Ok(Some(pending_update(UpdateProduct::Ocr, &file.ocr))),
226 Some(false) | None => Ok(None),
227 }
228}
229
230pub fn is_calver_outdated(current: &str, latest: &str) -> Result<bool> {
231 Ok(parse_calver(current)? < parse_calver(latest)?)
232}
233
234pub fn is_semver_outdated(current: &str, latest: &str) -> Result<bool> {
235 let current =
236 Version::parse(current).map_err(|_| UpdateError::InvalidSemVer(current.to_string()))?;
237 let latest =
238 Version::parse(latest).map_err(|_| UpdateError::InvalidSemVer(latest.to_string()))?;
239 Ok(current < latest)
240}
241
242fn product_is_outdated(product: &ProductVersion) -> Result<Option<bool>> {
243 let Some(current) = product.current_version.as_deref() else {
244 return Ok(None);
245 };
246 compare_versions(current, &product.latest_version, product.version_type).map(Some)
247}
248
249fn pending_update(product: UpdateProduct, version: &ProductVersion) -> PendingUpdate {
250 PendingUpdate {
251 product,
252 product_name: product.display_name().to_string(),
253 current_version: version.current_version.clone().unwrap_or_default(),
254 latest_version: version.latest_version.clone(),
255 released_at: version.released_at.clone(),
256 content: version.content.clone(),
257 }
258}
259
260fn compare_versions(current: &str, latest: &str, version_type: VersionType) -> Result<bool> {
261 match version_type {
262 VersionType::Calver => is_calver_outdated(current, latest),
263 VersionType::Semver => is_semver_outdated(current, latest),
264 }
265}
266
267fn parse_calver(value: &str) -> Result<NaiveDate> {
268 let parts = value.split('.').collect::<Vec<_>>();
269 if parts.len() != 3
270 || parts[0].len() != 2
271 || parts[1].len() != 2
272 || parts[2].len() != 2
273 || parts
274 .iter()
275 .any(|part| !part.chars().all(|character| character.is_ascii_digit()))
276 {
277 return Err(UpdateError::InvalidCalVer(value.to_string()));
278 }
279
280 let year = parts[0]
281 .parse::<i32>()
282 .map_err(|_| UpdateError::InvalidCalVer(value.to_string()))?;
283 let month = parts[1]
284 .parse::<u32>()
285 .map_err(|_| UpdateError::InvalidCalVer(value.to_string()))?;
286 let day = parts[2]
287 .parse::<u32>()
288 .map_err(|_| UpdateError::InvalidCalVer(value.to_string()))?;
289 NaiveDate::from_ymd_opt(2000 + year, month, day)
290 .ok_or_else(|| UpdateError::InvalidCalVer(value.to_string()))
291}
292
293async fn fetch_remote_versions() -> Result<RemoteVersionFile> {
294 let client = reqwest::Client::builder()
295 .timeout(UPDATE_REQUEST_TIMEOUT)
296 .build()
297 .map_err(|error| UpdateError::Network(error.to_string()))?;
298 let response = client
299 .get(SQUIGIT_RELEASES_URL)
300 .send()
301 .await
302 .and_then(reqwest::Response::error_for_status)
303 .map_err(|error| UpdateError::Network(error.to_string()))?;
304 response
305 .json::<RemoteVersionFile>()
306 .await
307 .map_err(|error| UpdateError::InvalidRemote(error.to_string()))
308}
309
310fn validate_remote_file(file: &RemoteVersionFile) -> Result<()> {
311 validate_remote_product(&file.app)?;
312 validate_remote_product(&file.cli)?;
313 validate_remote_product(&file.ocr)
314}
315
316fn validate_remote_product(product: &RemoteProductVersion) -> Result<()> {
317 if product.current_version.is_some() {
318 return Err(UpdateError::InvalidRemote(
319 "remote current_version must be null".to_string(),
320 ));
321 }
322 compare_versions(
323 &product.latest_version,
324 &product.latest_version,
325 product.version_type,
326 )?;
327 NaiveDate::parse_from_str(&product.released_at, "%Y-%m-%d")
328 .map_err(|error| UpdateError::InvalidRemote(error.to_string()))?;
329 Ok(())
330}
331
332async fn discover_cli_version() -> Option<String> {
333 tokio::task::spawn_blocking(read_cli_version)
334 .await
335 .ok()
336 .flatten()
337}
338
339fn read_cli_version() -> Option<String> {
340 let mut command = Command::new("squigit");
341 command.arg("--version");
342 #[cfg(target_os = "windows")]
343 {
344 use std::os::windows::process::CommandExt;
345 command.creation_flags(0x08000000);
346 }
347 let output = command.output().ok()?;
348 if !output.status.success() {
349 return None;
350 }
351 let stdout = String::from_utf8_lossy(&output.stdout);
352 let stderr = String::from_utf8_lossy(&output.stderr);
353 extract_semver(&format!("{stdout}\n{stderr}"))
354}
355
356async fn discover_ocr_version() -> Option<String> {
357 tokio::task::spawn_blocking(read_ocr_version)
358 .await
359 .ok()
360 .flatten()
361}
362
363fn read_ocr_version() -> Option<String> {
364 let sidecar_path = squigit_ocr::sidecar::resolve_sidecar_path();
365 squigit_ocr::sidecar::read_sidecar_version(&sidecar_path).ok()
366}
367
368fn extract_semver(raw: &str) -> Option<String> {
369 raw.split(|character: char| {
370 !(character.is_ascii_alphanumeric()
371 || character == '.'
372 || character == '-'
373 || character == '+')
374 })
375 .filter(|part| !part.is_empty())
376 .filter_map(|part| Version::parse(part).ok())
377 .next_back()
378 .map(|version| version.to_string())
379}
380
381fn non_empty(value: Option<String>) -> Option<String> {
382 value.and_then(|value| {
383 let value = value.trim();
384 (!value.is_empty()).then(|| value.to_string())
385 })
386}