Skip to main content

Mountain/IPC/WindServiceHandlers/Extensions/
ExtensionsGetInstalled.rs

1//! `extensions:getInstalled(type?)` - return scanned extensions reshaped as
2//! VS Code's `ILocalExtension[]` so `ExtensionManagementChannelClient
3//! .getInstalled` can destructure `extension.identifier.id`,
4//! `extension.manifest.*`, and `extension.location` without blowing up.
5//!
6//! ## Argument contract
7//!
8//! `Arguments[0]` is the optional `ExtensionType` filter VS Code passes:
9//! - `0` (System) → only built-ins.
10//! - `1` (User) → only VSIX-installed.
11//! - `null` / missing → every known extension.
12//!
13//! Without the filter the trusted-publishers boot migration iterates
14//! User-typed extensions over System manifests and crashes on
15//! `manifest.publisher.toLowerCase()`.
16//!
17//! ## Boot-time race
18//!
19//! The workbench fires `getInstalled` ~13 times within the first second.
20//! `ExtensionPopulate` runs in parallel and writes to `ScannedExtensions`
21//! 250-500 ms in. We await `ExtensionState.ScanReady` (a `tokio::sync::Notify`
22//! fired once the scan commits its results) with a 5 s hard cap, then return
23//! whatever is available. No 50 ms polling loop - we wake exactly when data
24//! arrives.
25//!
26//! ## Manifest skeleton
27//!
28//! VS Code unconditionally calls `manifest.publisher.toLowerCase()`. A `null`
29//! or non-object manifest crashes the webview before its first paint. We
30//! coerce to `{}` and inject `publisher`/`name`/`version` defaults.
31
32use std::{
33	sync::{Arc, OnceLock},
34	time::Duration,
35};
36
37use CommonLibrary::ExtensionManagement::ExtensionManagementService::ExtensionManagementService;
38use serde_json::{Value, json};
39
40use crate::{
41	IPC::UriComponents::Normalize::Fn as NormalizeUri,
42	RunTime::ApplicationRunTime::ApplicationRunTime,
43	dev_log,
44};
45
46const EXTENSION_TYPE_SYSTEM:u8 = 0;
47
48const EXTENSION_TYPE_USER:u8 = 1;
49
50const SCAN_WAIT_CAP_MS:u64 = 5000;
51
52// Per-type cached responses. Extensions don't change during a session so
53// building the ILocalExtension[] once per type and returning the cached Value
54// on subsequent calls avoids re-serializing ~1.8 MB on every getInstalled call.
55// Keyed by TypeFilter: index 0=None(all), 1=System(0), 2=User(1).
56static INSTALLED_CACHE:[OnceLock<Value>; 3] = [OnceLock::new(), OnceLock::new(), OnceLock::new()];
57
58fn CacheIndex(TypeFilter:Option<u8>) -> usize {
59	match TypeFilter {
60		None => 0,
61
62		Some(EXTENSION_TYPE_SYSTEM) => 1,
63
64		Some(EXTENSION_TYPE_USER) => 2,
65
66		Some(_) => 0,
67	}
68}
69
70pub async fn Fn(RunTime:Arc<ApplicationRunTime>, Arguments:Vec<Value>) -> Result<Value, String> {
71	let TypeFilter:Option<u8> = Arguments.first().and_then(|V| V.as_u64()).map(|N| N as u8);
72
73	// Fast path: return cached response if available (built on first call per
74	// type).
75	let CacheSlot = CacheIndex(TypeFilter);
76
77	if let Some(Cached) = INSTALLED_CACHE[CacheSlot].get() {
78		let Count = Cached.as_array().map(|A| A.len()).unwrap_or(0);
79
80		dev_log!(
81			"extensions",
82			"extensions:getInstalled type={:?} returning {} entries (cache hit)",
83			TypeFilter,
84			Count
85		);
86
87		return Ok(Cached.clone());
88	}
89
90	// Subscribe to the scan-ready notify BEFORE calling GetExtensions() to
91	// close the TOCTOU window: if the scan completes between GetExtensions()
92	// returning empty and notified() being registered, the signal would be
93	// lost (Notify does not latch) and we'd wait the full 5 s timeout.
94	let ScanReady = RunTime.Environment.ApplicationState.Extension.ScanReady.clone();
95
96	let NotifyFuture = ScanReady.notified();
97
98	let mut Extensions = RunTime
99		.Environment
100		.GetExtensions()
101		.await
102		.map_err(|Error| format!("extensions:getInstalled failed: {}", Error))?;
103
104	if Extensions.is_empty() {
105		let Notified = tokio::time::timeout(Duration::from_millis(SCAN_WAIT_CAP_MS), NotifyFuture).await;
106
107		Extensions = RunTime
108			.Environment
109			.GetExtensions()
110			.await
111			.map_err(|Error| format!("extensions:getInstalled failed: {}", Error))?;
112
113		match Notified {
114			Ok(()) => {
115				dev_log!(
116					"extensions",
117					"extensions:getInstalled: scan-ready signal received, {} entries available",
118					Extensions.len()
119				);
120			},
121
122			Err(_) => {
123				dev_log!(
124					"extensions",
125					"warn: extensions:getInstalled: scan-ready timed out after {}ms; {} entries available",
126					SCAN_WAIT_CAP_MS,
127					Extensions.len()
128				);
129			},
130		}
131	}
132
133	let Wrapped:Vec<Value> = Extensions
134		.into_iter()
135		.filter_map(|Manifest| {
136			let IsBuiltin = Manifest.get("isBuiltin").and_then(Value::as_bool).unwrap_or(true);
137
138			let ExtensionType = if IsBuiltin { EXTENSION_TYPE_SYSTEM } else { EXTENSION_TYPE_USER };
139
140			if let Some(Wanted) = TypeFilter
141				&& Wanted != ExtensionType
142			{
143				return None;
144			}
145
146			let Publisher = Manifest
147				.get("publisher")
148				.and_then(Value::as_str)
149				.filter(|S| !S.is_empty())
150				.unwrap_or("unknown")
151				.to_string();
152
153			let Name = Manifest
154				.get("name")
155				.and_then(Value::as_str)
156				.filter(|S| !S.is_empty())
157				.unwrap_or("unknown")
158				.to_string();
159
160			let Id = format!("{}.{}", Publisher, Name);
161
162			let Location = NormalizeUri(Manifest.get("extensionLocation"));
163
164			let mut Manifest = match Manifest {
165				Value::Object(_) => Manifest,
166				_ => json!({}),
167			};
168
169			if let Value::Object(ref mut Map) = Manifest {
170				Map.insert("extensionLocation".to_string(), Location.clone());
171
172				Map.entry("publisher".to_string()).or_insert_with(|| json!(Publisher.clone()));
173
174				Map.entry("name".to_string()).or_insert_with(|| json!(Name.clone()));
175
176				Map.entry("version".to_string()).or_insert_with(|| json!("0.0.0"));
177			}
178
179			Some(json!({
180				"type": ExtensionType,
181				"isBuiltin": IsBuiltin,
182				"identifier": { "id": Id },
183				"manifest": Manifest,
184				"location": Location,
185				"targetPlatform": "undefined",
186				"isValid": true,
187				"validations": [],
188				"preRelease": false,
189				"isWorkspaceScoped": false,
190				"isMachineScoped": false,
191				"isApplicationScoped": false,
192				"publisherId": null,
193				"isPreReleaseVersion": false,
194				"hasPreReleaseVersion": false,
195				"private": false,
196				"updated": false,
197				"pinned": false,
198				"forceAutoUpdate": false,
199				"source": if IsBuiltin { "system" } else { "vsix" },
200				"size": 0,
201			}))
202		})
203		.collect();
204
205	dev_log!(
206		"extensions",
207		"extensions:getInstalled type={:?} returning {} ILocalExtension-shaped entries",
208		TypeFilter,
209		Wrapped.len()
210	);
211
212	let Response = json!(Wrapped);
213
214	// Only cache non-empty responses - an empty response on first call (timeout)
215	// shouldn't poison the cache for subsequent calls that would get real data.
216	if !Wrapped.is_empty() {
217		let _ = INSTALLED_CACHE[CacheSlot].set(Response.clone());
218	}
219
220	Ok(Response)
221}