Skip to main content

Mountain/IPC/WindServiceHandlers/Extensions/
ExtensionsGetInstalled.rs

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