Skip to main content

DevelopmentNodeEnvironment_MicrosoftVSCodeDependency_22NodeVersion_Bundle_Clean_Debug_ElectronProfile_EsbuildCompiler_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
46	path:String,
47
48	manifest:Value,
49}
50
51/// Top-level cache blob.
52#[derive(Debug, Deserialize)]
53struct CacheBlob {
54	version:u32,
55
56	count:u32,
57
58	extensions:Vec<CachedEntry>,
59}
60
61/// Maximum cache age for dev/repo runs (binary sits next to the cache file).
62/// 24 hours: extensions don't change between builds; the build always
63/// regenerates the cache, so 10 min was too tight for normal dev workflows.
64const MAX_CACHE_AGE:Duration = Duration::from_secs(86_400);
65
66/// Try to load extension descriptors from the pre-baked manifest cache.
67///
68/// Probes two locations in order:
69///   1. `BinaryDir/extensions.manifest.json` - dev binary next to repo cache
70///   2. `BinaryDir/../Resources/extensions.manifest.json` - .app bundle path
71///      (tauri.conf.json copies Sky/Target/extensions.manifest.json there)
72///
73/// Bundled caches skip the stale check: they were written at build time and
74/// are always consistent with the extensions packed into the same .app.
75///
76/// Returns `Ok(Some(map))` on a cache hit, `Ok(None)` when the cache is
77/// missing/stale/incompatible, and `Err(_)` only on unexpected I/O errors.
78pub async fn Fn(BinaryDir:&PathBuf) -> Result<Option<HashMap<String, ExtensionDescriptionStateDTO>>, CommonError> {
79	// Probe 1: alongside the binary (dev / repo run).
80	let DevCachePath = BinaryDir.join("extensions.manifest.json");
81
82	// Probe 2: inside .app bundle at Contents/Resources/ (bundle run).
83	let BundleCachePath = BinaryDir.join("../Resources/extensions.manifest.json");
84
85	// Pick the first probe that exists, noting whether it is the bundled copy.
86	let (CachePath, IsBundled) = if tokio::fs::metadata(&DevCachePath).await.is_ok() {
87		(DevCachePath, false)
88	} else if tokio::fs::metadata(&BundleCachePath).await.is_ok() {
89		(BundleCachePath, true)
90	} else {
91		dev_log!("extensions", "[ExtensionCache] Cache not found at {}", DevCachePath.display());
92
93		return Ok(None);
94	};
95
96	// --- Freshness check (skipped for bundled caches - built with the app) ---
97	let Age = if IsBundled {
98		Duration::ZERO
99	} else {
100		let Metadata = tokio::fs::metadata(&CachePath)
101			.await
102			.map_err(|_| CommonError::Unknown { Description:"cache stat failed".into() })?;
103
104		Metadata.modified().ok().and_then(|T| T.elapsed().ok()).unwrap_or(Duration::MAX)
105	};
106
107	if !IsBundled && Age > MAX_CACHE_AGE {
108		dev_log!(
109			"extensions",
110			"[ExtensionCache] Cache is stale ({:.0}s > {:.0}s), falling back to live scan",
111			Age.as_secs_f32(),
112			MAX_CACHE_AGE.as_secs_f32()
113		);
114
115		return Ok(None);
116	}
117
118	// --- Read + parse ---
119	let Bytes = match tokio::fs::read(&CachePath).await {
120		Ok(B) => B,
121
122		Err(E) => {
123			dev_log!(
124				"extensions",
125				"warn: [ExtensionCache] Read failed: {}; falling back to live scan",
126				E
127			);
128
129			return Ok(None);
130		},
131	};
132
133	let Blob:CacheBlob = match serde_json::from_slice(&Bytes) {
134		Ok(B) => B,
135
136		Err(E) => {
137			dev_log!(
138				"extensions",
139				"warn: [ExtensionCache] Parse error: {}; falling back to live scan",
140				E
141			);
142
143			return Ok(None);
144		},
145	};
146
147	if Blob.version != 1 {
148		dev_log!(
149			"extensions",
150			"[ExtensionCache] Unsupported cache version {}; falling back to live scan",
151			Blob.version
152		);
153
154		return Ok(None);
155	}
156
157	// An empty extension list means the cache was written as a stub (e.g.
158	// by build.rs before a real BakeExtensionManifest run). Treat it as a
159	// cache miss so the live scan produces the actual extension list.
160	if Blob.extensions.is_empty() {
161		dev_log!(
162			"extensions",
163			"[ExtensionCache] Empty cache (count=0), falling back to live scan"
164		);
165
166		return Ok(None);
167	}
168
169	// --- Hydrate into ExtensionDescriptionStateDTO ---
170	let mut Map:HashMap<String, ExtensionDescriptionStateDTO> = HashMap::with_capacity(Blob.extensions.len());
171
172	for Entry in Blob.extensions {
173		let Manifest = &Entry.manifest;
174
175		let Path = &Entry.path;
176
177		// Helpers scoped to each manifest to eliminate repeated extraction chains.
178		let str = |k:&str| Manifest.get(k).and_then(Value::as_str).map(str::to_string);
179
180		let str_or = |k:&str, d:&str| Manifest.get(k).and_then(Value::as_str).unwrap_or(d).to_string();
181
182		let arr =
183			|k:&str| -> Option<Vec<String>> { Manifest.get(k).and_then(|V| serde_json::from_value(V.clone()).ok()) };
184
185		let ExtId = Entry.id.clone();
186
187		let Publisher = Manifest
188			.get("publisher")
189			.and_then(Value::as_str)
190			.unwrap_or_else(|| Entry.id.split('.').next().unwrap_or("unknown"))
191			.to_string();
192
193		// Built-in when the parent directory is named "extensions".
194		let IsBuiltin = PathBuf::from(Path)
195			.parent()
196			.and_then(|P| P.file_name())
197			.and_then(|N| N.to_str())
198			.map(|N| N == "extensions")
199			.unwrap_or(false);
200
201		let Dto = ExtensionDescriptionStateDTO {
202			Identifier:serde_json::json!({ "value": ExtId }),
203
204			Name:str_or("name", ""),
205
206			Version:str_or("version", "0.0.0"),
207
208			Publisher,
209
210			Engines:Manifest.get("engines").cloned().unwrap_or(serde_json::json!({})),
211
212			Main:str("main"),
213
214			Browser:str("browser"),
215
216			ModuleType:str("type"),
217
218			IsBuiltin,
219
220			IsUnderDevelopment:false,
221
222			// file:// URI string - Normalize.rs parses it via FromUrl::Fn into
223			// the {scheme, authority, path, …} UriComponents shape.
224			ExtensionLocation:Value::String(format!("file://{}", Path)),
225
226			ActivationEvents:arr("activationEvents"),
227
228			Contributes:Manifest.get("contributes").cloned(),
229
230			Categories:arr("categories"),
231
232			DisplayName:str("displayName"),
233
234			Description:str("description"),
235
236			Keywords:arr("keywords"),
237
238			Repository:Manifest.get("repository").cloned(),
239
240			Bugs:Manifest.get("bugs").cloned(),
241
242			Homepage:str("homepage"),
243
244			License:str("license"),
245
246			Icon:str("icon"),
247
248			AiKey:str("aiKey"),
249
250			ExtensionKind:Manifest.get("extensionKind").cloned(),
251
252			Capabilities:Manifest.get("capabilities").cloned(),
253
254			ExtensionDependencies:arr("extensionDependencies"),
255
256			ExtensionPack:arr("extensionPack"),
257		};
258
259		Map.insert(ExtId, Dto);
260	}
261
262	dev_log!(
263		"extensions",
264		"[ExtensionCache] Loaded {} extensions from {} cache ({} bytes{})",
265		Map.len(),
266		if IsBundled { "bundled" } else { "dev" },
267		Bytes.len(),
268		if IsBundled {
269			String::new()
270		} else {
271			format!(", {:.0}s old", Age.as_secs_f32())
272		}
273	);
274
275	Ok(Some(Map))
276}