Skip to main content

Mountain/ApplicationState/Internal/ExtensionScanner/
LoadFromCache.rs

1//! # Extension Manifest Cache Loader (B7.P08)
2//!
3//! Loads the pre-baked extension manifest from
4//! `Target/debug/extensions.manifest.json` (written by
5//! `Maintain/Build/Manifest/PreBake.ts` as part of the debug build step).
6//!
7//! ## Why this exists
8//!
9//! Mountain's `ScanAndPopulateExtensions` currently reads 113+ `package.json`
10//! files sequentially from disk during boot, taking ~1200 ms on cold storage.
11//! After the build step runs `PreBake.ts`, the manifests are pre-merged into a
12//! single JSON blob. `LoadFromCache` reads that blob with a single `fs::read`
13//! and deserializes with `serde_json::from_slice`, reducing boot cost to <50
14//! ms.
15//!
16//! ## Fallback
17//!
18//! If the cache file is missing, stale (older than 10 min), or corrupt, the
19//! caller falls back to the normal `ScanAndPopulateExtensions` path.
20//!
21//! ## Cache format (written by PreBake.ts)
22//!
23//! ```json
24//! {
25//!   "version": 1,
26//!   "count": 113,
27//!   "extensions": [
28//!     { "id": "publisher.name", "path": "/abs/path/to/ext", "manifest": { … } }
29//!   ]
30//! }
31//! ```
32
33use std::{collections::HashMap, path::PathBuf, time::Duration};
34
35use CommonLibrary::Error::CommonError::CommonError;
36use serde::Deserialize;
37use serde_json::Value;
38
39use crate::{ApplicationState::DTO::ExtensionDescriptionStateDTO::ExtensionDescriptionStateDTO, dev_log};
40
41/// One entry in the pre-baked cache file.
42#[derive(Debug, Deserialize)]
43struct CachedEntry {
44	id:String,
45	path:String,
46	manifest:Value,
47}
48
49/// Top-level cache blob.
50#[derive(Debug, Deserialize)]
51struct CacheBlob {
52	version:u32,
53	#[allow(dead_code)]
54	count:u32,
55	extensions:Vec<CachedEntry>,
56}
57
58/// Maximum cache age for dev/repo runs (binary sits next to the cache file).
59const MAX_CACHE_AGE:Duration = Duration::from_secs(600); // 10 minutes
60
61/// Try to load extension descriptors from the pre-baked manifest cache.
62///
63/// Probes two locations in order:
64///   1. `BinaryDir/extensions.manifest.json` - dev binary next to repo cache
65///   2. `BinaryDir/../Resources/extensions.manifest.json` - .app bundle path
66///      (tauri.conf.json copies Sky/Target/extensions.manifest.json there)
67///
68/// Bundled caches skip the stale check: they were written at build time and
69/// are always consistent with the extensions packed into the same .app.
70///
71/// Returns `Ok(Some(map))` on a cache hit, `Ok(None)` when the cache is
72/// missing/stale/incompatible, and `Err(_)` only on unexpected I/O errors.
73pub async fn Fn(BinaryDir:&PathBuf) -> Result<Option<HashMap<String, ExtensionDescriptionStateDTO>>, CommonError> {
74	// Probe 1: alongside the binary (dev / repo run).
75	let DevCachePath = BinaryDir.join("extensions.manifest.json");
76	// Probe 2: inside .app bundle at Contents/Resources/ (bundle run).
77	let BundleCachePath = BinaryDir.join("../Resources/extensions.manifest.json");
78
79	// Pick the first probe that exists, noting whether it is the bundled copy.
80	let (CachePath, IsBundled) = if tokio::fs::metadata(&DevCachePath).await.is_ok() {
81		(DevCachePath, false)
82	} else if tokio::fs::metadata(&BundleCachePath).await.is_ok() {
83		(BundleCachePath, true)
84	} else {
85		dev_log!("extensions", "[ExtensionCache] Cache not found at {}", DevCachePath.display());
86		return Ok(None);
87	};
88
89	// --- Freshness check (skipped for bundled caches - built with the app) ---
90	let Age = if IsBundled {
91		Duration::ZERO
92	} else {
93		let Metadata = tokio::fs::metadata(&CachePath)
94			.await
95			.map_err(|_| CommonError::Unknown { Description:"cache stat failed".into() })?;
96		Metadata.modified().ok().and_then(|T| T.elapsed().ok()).unwrap_or(Duration::MAX)
97	};
98	if !IsBundled && Age > MAX_CACHE_AGE {
99		dev_log!(
100			"extensions",
101			"[ExtensionCache] Cache is stale ({:.0}s > {:.0}s), falling back to live scan",
102			Age.as_secs_f32(),
103			MAX_CACHE_AGE.as_secs_f32()
104		);
105		return Ok(None);
106	}
107
108	// --- Read + parse ---
109	let Bytes = match tokio::fs::read(&CachePath).await {
110		Ok(B) => B,
111		Err(E) => {
112			dev_log!(
113				"extensions",
114				"warn: [ExtensionCache] Read failed: {}; falling back to live scan",
115				E
116			);
117			return Ok(None);
118		},
119	};
120
121	let Blob:CacheBlob = match serde_json::from_slice(&Bytes) {
122		Ok(B) => B,
123		Err(E) => {
124			dev_log!(
125				"extensions",
126				"warn: [ExtensionCache] Parse error: {}; falling back to live scan",
127				E
128			);
129			return Ok(None);
130		},
131	};
132
133	if Blob.version != 1 {
134		dev_log!(
135			"extensions",
136			"[ExtensionCache] Unsupported cache version {}; falling back to live scan",
137			Blob.version
138		);
139		return Ok(None);
140	}
141
142	// --- Hydrate into ExtensionDescriptionStateDTO ---
143	let mut Map:HashMap<String, ExtensionDescriptionStateDTO> = HashMap::with_capacity(Blob.extensions.len());
144
145	for Entry in Blob.extensions {
146		let Manifest = &Entry.manifest;
147		let Path = &Entry.path;
148
149		// Helpers scoped to each manifest to eliminate repeated extraction chains.
150		let str = |k:&str| Manifest.get(k).and_then(Value::as_str).map(str::to_string);
151		let str_or = |k:&str, d:&str| Manifest.get(k).and_then(Value::as_str).unwrap_or(d).to_string();
152		let arr =
153			|k:&str| -> Option<Vec<String>> { Manifest.get(k).and_then(|V| serde_json::from_value(V.clone()).ok()) };
154
155		let ExtId = Entry.id.clone();
156		let Publisher = Manifest
157			.get("publisher")
158			.and_then(Value::as_str)
159			.unwrap_or_else(|| Entry.id.split('.').next().unwrap_or("unknown"))
160			.to_string();
161
162		// Built-in when the parent directory is named "extensions".
163		let IsBuiltin = PathBuf::from(Path)
164			.parent()
165			.and_then(|P| P.file_name())
166			.and_then(|N| N.to_str())
167			.map(|N| N == "extensions")
168			.unwrap_or(false);
169
170		let Dto = ExtensionDescriptionStateDTO {
171			Identifier:serde_json::json!({ "value": ExtId }),
172			Name:str_or("name", ""),
173			Version:str_or("version", "0.0.0"),
174			Publisher,
175			Engines:Manifest.get("engines").cloned().unwrap_or(serde_json::json!({})),
176			Main:str("main"),
177			Browser:str("browser"),
178			ModuleType:str("type"),
179			IsBuiltin,
180			IsUnderDevelopment:false,
181			// file:// URI string - Normalize.rs parses it via FromUrl::Fn into
182			// the {scheme, authority, path, …} UriComponents shape.
183			ExtensionLocation:Value::String(format!("file://{}", Path)),
184			ActivationEvents:arr("activationEvents"),
185			Contributes:Manifest.get("contributes").cloned(),
186			Categories:arr("categories"),
187			DisplayName:str("displayName"),
188			Description:str("description"),
189			Keywords:arr("keywords"),
190			Repository:Manifest.get("repository").cloned(),
191			Bugs:Manifest.get("bugs").cloned(),
192			Homepage:str("homepage"),
193			License:str("license"),
194			Icon:str("icon"),
195			AiKey:str("aiKey"),
196			ExtensionKind:Manifest.get("extensionKind").cloned(),
197			Capabilities:Manifest.get("capabilities").cloned(),
198			ExtensionDependencies:arr("extensionDependencies"),
199			ExtensionPack:arr("extensionPack"),
200		};
201
202		Map.insert(ExtId, Dto);
203	}
204
205	dev_log!(
206		"extensions",
207		"[ExtensionCache] Loaded {} extensions from {} cache ({} bytes{})",
208		Map.len(),
209		if IsBundled { "bundled" } else { "dev" },
210		Bytes.len(),
211		if IsBundled {
212			String::new()
213		} else {
214			format!(", {:.0}s old", Age.as_secs_f32())
215		}
216	);
217
218	Ok(Some(Map))
219}