Skip to main content

qualia_client_core/
bundled_qapps.rs

1//! Seed qapps shipped beside the desktop executable into `{storage}/Qapps/`.
2//!
3//! Release bundles place packages under `{exe}/bundled/qapps/{Name}/`.
4//! Dev builds resolve `bundled/qapps/{Name}/`, then gitignored `app-development/{Name}/`.
5
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use serde::Serialize;
10
11use crate::qapp_paths::{ensure_qapps_dir, qapps_dir, resolve_package_manifest_path};
12use crate::qapp_registry::QAPP_PACKAGE_MANIFEST;
13use crate::qapp_version::{is_version_newer, normalize_version_label};
14
15/// Default qapps copied on first launch when absent from user storage.
16pub const DEFAULT_BUNDLED_QAPPS: &[&str] = &["Anatomy"];
17
18/// Version + update offer for an installed qapp.
19#[derive(Debug, Clone, Serialize)]
20pub struct QappVersionStatus {
21    pub qapp_name: String,
22    pub installed: bool,
23    pub installed_version: Option<String>,
24    pub offered_version: Option<String>,
25    pub update_available: bool,
26    /// `bundled` | `external` | `none`
27    pub offer_source: String,
28    pub message: String,
29}
30
31fn exe_dir() -> Option<PathBuf> {
32    std::env::current_exe()
33        .ok()
34        .and_then(|p| p.parent().map(|d| d.to_path_buf()))
35}
36
37fn has_valid_manifest(dir: &Path) -> bool {
38    resolve_package_manifest_path(dir).is_some_and(|p| p.is_file())
39}
40
41/// Read `version` from a qapp package directory's manifest.
42pub fn read_qapp_version_from_dir(qapp_dir: &Path) -> Option<String> {
43    let manifest_path = resolve_package_manifest_path(qapp_dir)?;
44    let content = fs::read_to_string(manifest_path).ok()?;
45    let manifest: crate::qapp_registry::QappPackageManifest =
46        serde_json::from_str(&content).ok()?;
47    Some(normalize_version_label(&manifest.version))
48}
49
50/// Resolve bundled source for a qapp directory name (e.g. `"Anatomy"`).
51pub fn resolve_bundled_qapp_source(qapp_name: &str) -> Option<PathBuf> {
52    if let Ok(extra) = std::env::var("QUALIA_BUNDLED_QAPPS_DIR") {
53        let candidate = PathBuf::from(extra).join(qapp_name);
54        if has_valid_manifest(&candidate) {
55            return Some(candidate);
56        }
57    }
58
59    if let Some(root) = exe_dir() {
60        for rel in [
61            format!("bundled/qapps/{qapp_name}"),
62            format!("qapps/{qapp_name}"),
63            format!("bundled/{qapp_name}"),
64        ] {
65            let candidate = root.join(&rel);
66            if has_valid_manifest(&candidate) {
67                return Some(candidate);
68            }
69        }
70    }
71
72    let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
73    for rel in [
74        format!("bundled/qapps/{qapp_name}"),
75        format!("app-development/{qapp_name}"),
76    ] {
77        let candidate = repo_root.join(&rel);
78        if has_valid_manifest(&candidate) {
79            return Some(candidate);
80        }
81    }
82
83    None
84}
85
86pub fn installed_qapp_version(storage_path: &Path, qapp_name: &str) -> Option<String> {
87    let dir = qapps_dir(storage_path).join(qapp_name);
88    if dir.join(QAPP_PACKAGE_MANIFEST).is_file() {
89        read_qapp_version_from_dir(&dir)
90    } else {
91        None
92    }
93}
94
95pub fn bundled_offered_version(qapp_name: &str) -> Option<String> {
96    resolve_bundled_qapp_source(qapp_name).and_then(|p| read_qapp_version_from_dir(&p))
97}
98
99fn build_version_status(
100    qapp_name: &str,
101    installed_version: Option<String>,
102    offered_version: Option<String>,
103    offer_source: &str,
104) -> QappVersionStatus {
105    let installed = installed_version.is_some();
106    let update_available = match (&installed_version, &offered_version) {
107        (Some(inst), Some(off)) => is_version_newer(off, inst),
108        (None, Some(_)) => true,
109        _ => false,
110    };
111
112    let message = match (&installed_version, &offered_version, update_available) {
113        (None, Some(off), true) => format!("Install qapp {qapp_name} v{off} from {offer_source}."),
114        (Some(inst), Some(off), true) => {
115            format!("Update available: v{inst} → v{off} ({offer_source}).")
116        }
117        (Some(inst), None, false) => format!("Installed v{inst}; no newer package found."),
118        (Some(inst), Some(off), false) if inst == off => {
119            format!("Up to date at v{inst}.")
120        }
121        (Some(inst), Some(off), false) => {
122            format!("Installed v{inst}; bundled/external offer v{off} is not newer.")
123        }
124        (None, None, false) => {
125            format!("{qapp_name} is not installed and no package source was found.")
126        }
127        _ => format!("{qapp_name} version status evaluated."),
128    };
129
130    QappVersionStatus {
131        qapp_name: qapp_name.to_string(),
132        installed,
133        installed_version,
134        offered_version,
135        update_available,
136        offer_source: offer_source.to_string(),
137        message,
138    }
139}
140
141/// Compare installed copy against the bundled/desktop-shipped package.
142pub fn check_bundled_qapp_update(qapp_name: &str, storage_path: &Path) -> QappVersionStatus {
143    let installed_version = installed_qapp_version(storage_path, qapp_name);
144    let offered_version = bundled_offered_version(qapp_name);
145    build_version_status(qapp_name, installed_version, offered_version, "bundled")
146}
147
148/// Compare installed copy against an external directory (manual install / dev tree).
149pub fn check_qapp_update_from_source(
150    qapp_name: &str,
151    storage_path: &Path,
152    source_dir: &Path,
153) -> Result<QappVersionStatus, String> {
154    if !has_valid_manifest(source_dir) {
155        return Err(format!("qapp.json not found in {}", source_dir.display()));
156    }
157    let offered_version = read_qapp_version_from_dir(source_dir)
158        .ok_or_else(|| "Could not read version from source qapp.json".to_string())?;
159    let installed_version = installed_qapp_version(storage_path, qapp_name);
160    Ok(build_version_status(
161        qapp_name,
162        installed_version,
163        Some(offered_version),
164        "external",
165    ))
166}
167
168/// List update offers for installed qapps and default bundled catalog entries.
169pub fn list_bundled_qapp_updates(storage_path: &Path) -> Vec<QappVersionStatus> {
170    let mut names = Vec::new();
171    let qapps_root = qapps_dir(storage_path);
172    if let Ok(entries) = fs::read_dir(&qapps_root) {
173        for entry in entries.filter_map(Result::ok) {
174            if entry.path().is_dir() {
175                names.push(entry.file_name().to_string_lossy().to_string());
176            }
177        }
178    }
179    for name in DEFAULT_BUNDLED_QAPPS {
180        if !names.iter().any(|n| n == *name) {
181            names.push((*name).to_string());
182        }
183    }
184
185    let mut reports: Vec<QappVersionStatus> = names
186        .iter()
187        .map(|name| check_bundled_qapp_update(name, storage_path))
188        .collect();
189    reports.sort_by(|a, b| a.qapp_name.cmp(&b.qapp_name));
190    reports
191}
192
193/// Copy bundled qapp into `{storage}/Qapps/{name}` when not already installed.
194pub fn seed_qapp_if_missing(
195    storage_path: impl AsRef<Path>,
196    qapp_name: &str,
197) -> Result<bool, String> {
198    let storage = storage_path.as_ref();
199    let dest = qapps_dir(storage).join(qapp_name);
200    if dest.join(QAPP_PACKAGE_MANIFEST).is_file() {
201        return Ok(false);
202    }
203
204    let source = resolve_bundled_qapp_source(qapp_name)
205        .ok_or_else(|| format!("Bundled qapp source not found for {qapp_name}"))?;
206
207    crate::qapp_install::install_package_atomic(
208        storage,
209        &source,
210        crate::qapp_install::InstallPolicy::Development,
211        None,
212    )
213    .map_err(|e| e.to_string())?;
214    Ok(true)
215}
216
217/// Replace an installed qapp with files from `source_dir` and re-register capabilities.
218pub fn upgrade_qapp_from_source(
219    storage_path: &Path,
220    qapp_name: &str,
221    source_dir: &Path,
222) -> Result<String, String> {
223    if !has_valid_manifest(source_dir) {
224        return Err(format!("qapp.json not found in {}", source_dir.display()));
225    }
226
227    let manifest = crate::api::load_qapp_package_from_dir(source_dir)
228        .map_err(|e| format!("Invalid source manifest: {e}"))?;
229    if manifest.name != qapp_name {
230        return Err(format!(
231            "Source manifest name '{}' does not match expected '{}'",
232            manifest.name, qapp_name
233        ));
234    }
235
236    let previous = installed_qapp_version(storage_path, qapp_name);
237    let next = normalize_version_label(&manifest.version);
238    let entry = crate::qapp_install::install_package_atomic(
239        storage_path,
240        source_dir,
241        crate::qapp_install::InstallPolicy::Development,
242        None,
243    )
244    .map_err(|e| e.to_string())?;
245
246    let _ = crate::qapp_manifest::install_qapp_capabilities(&manifest);
247    let _ = entry;
248
249    Ok(match previous {
250        Some(old) => format!("Upgraded {qapp_name} v{old} → v{next}"),
251        None => format!("Installed {qapp_name} v{next}"),
252    })
253}
254
255/// Apply a bundled update when a newer version is available.
256pub fn apply_bundled_qapp_update(storage_path: &Path, qapp_name: &str) -> Result<String, String> {
257    let status = check_bundled_qapp_update(qapp_name, storage_path);
258    if !status.update_available {
259        return Err(status.message);
260    }
261    let source = resolve_bundled_qapp_source(qapp_name)
262        .ok_or_else(|| format!("Bundled qapp source not found for {qapp_name}"))?;
263    upgrade_qapp_from_source(storage_path, qapp_name, &source)
264}
265
266fn register_default_capabilities(_storage_path: &Path) {
267    for name in DEFAULT_BUNDLED_QAPPS {
268        if let Ok(manifest) = crate::api::load_installed_qapp_package(name) {
269            let _ = crate::qapp_manifest::install_qapp_capabilities(&manifest);
270        } else if let Some(src) = resolve_bundled_qapp_source(name) {
271            if let Ok(manifest) = crate::api::load_qapp_package_from_dir(&src) {
272                let _ = crate::qapp_manifest::install_qapp_capabilities(&manifest);
273            }
274        }
275    }
276}
277
278/// Seed all default bundled qapps and register Anatomy capabilities when present.
279pub fn seed_bundled_qapps() -> Result<Vec<String>, String> {
280    let state = crate::state::APP_STATE
281        .get()
282        .ok_or("APP_STATE not initialized")?;
283    let storage = state
284        .config
285        .lock()
286        .map_err(|e| e.to_string())?
287        .storage_path
288        .clone();
289    let storage_path = PathBuf::from(&storage);
290    let _ = ensure_qapps_dir(&storage_path).map_err(|e| e.to_string())?;
291
292    let mut seeded = Vec::new();
293    for name in DEFAULT_BUNDLED_QAPPS {
294        match seed_qapp_if_missing(&storage_path, name) {
295            Ok(true) => seeded.push((*name).to_string()),
296            Ok(false) => {}
297            Err(e) => eprintln!("[bundled_qapps] skip {name}: {e}"),
298        }
299    }
300
301    register_default_capabilities(&storage_path);
302    let _ = crate::qapp_install::reconcile_registry_with_disk(&storage_path);
303
304    for offer in list_bundled_qapp_updates(&storage_path) {
305        if offer.update_available {
306            eprintln!(
307                "[bundled_qapps] update available for {}: {}",
308                offer.qapp_name, offer.message
309            );
310        }
311    }
312
313    Ok(seeded)
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn tracked_anatomy_source_resolves_when_present() {
322        let tracked =
323            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../bundled/qapps/Anatomy/qapp.json");
324        if tracked.is_file() {
325            let src = resolve_bundled_qapp_source("Anatomy");
326            assert!(src.is_some(), "expected bundled Anatomy path");
327        }
328    }
329
330    #[test]
331    fn anatomy_manifest_version_reads_when_present() {
332        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
333        let anatomy = root.join("bundled/qapps/Anatomy");
334        if !anatomy.join("qapp.json").is_file() {
335            return;
336        }
337        let v = read_qapp_version_from_dir(&anatomy);
338        assert_eq!(v.as_deref(), Some("0.0.12"));
339    }
340}