Skip to main content

squigit/
update.rs

1// Copyright 2026 a7mddra
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shared update refresh and decision logic for Squigit shells.
5
6use 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
128/// Refresh the shared version file from the CLI without probing another
129/// `squigit` executable on PATH.
130pub 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}