cli/desktop/
version_manager.rs

1/*---------------------------------------------------------------------------------------------
2 *  Copyright (c) Microsoft Corporation. All rights reserved.
3 *  Licensed under the MIT License. See License.txt in the project root for license information.
4 *--------------------------------------------------------------------------------------------*/
5
6use std::{
7	ffi::OsString,
8	fmt, io,
9	path::{Path, PathBuf},
10};
11
12use lazy_static::lazy_static;
13use regex::Regex;
14use serde::{Deserialize, Serialize};
15
16use crate::{
17	constants::{PRODUCT_DOWNLOAD_URL, QUALITY, QUALITYLESS_PRODUCT_NAME},
18	log,
19	state::{LauncherPaths, PersistedState},
20	update_service::Platform,
21	util::{
22		command::new_std_command,
23		errors::{AnyError, InvalidRequestedVersion},
24	},
25};
26
27/// Parsed instance that a user can request.
28#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
29#[serde(tag = "t", content = "c")]
30pub enum RequestedVersion {
31	Default,
32	Commit(String),
33	Path(String),
34}
35
36lazy_static! {
37	static ref COMMIT_RE: Regex = Regex::new(r"^[a-e0-f]{40}$").unwrap();
38}
39
40impl RequestedVersion {
41	pub fn get_command(&self) -> String {
42		match self {
43			RequestedVersion::Default => {
44				format!("code version use {}", QUALITY)
45			}
46			RequestedVersion::Commit(commit) => {
47				format!("code version use {}/{}", QUALITY, commit)
48			}
49			RequestedVersion::Path(path) => {
50				format!("code version use {}", path)
51			}
52		}
53	}
54}
55
56impl std::fmt::Display for RequestedVersion {
57	fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
58		match self {
59			RequestedVersion::Default => {
60				write!(f, "{}", QUALITY)
61			}
62			RequestedVersion::Commit(commit) => {
63				write!(f, "{}/{}", QUALITY, commit)
64			}
65			RequestedVersion::Path(path) => write!(f, "{}", path),
66		}
67	}
68}
69
70impl TryFrom<&str> for RequestedVersion {
71	type Error = InvalidRequestedVersion;
72
73	fn try_from(s: &str) -> Result<Self, Self::Error> {
74		if s == QUALITY {
75			return Ok(RequestedVersion::Default);
76		}
77
78		if Path::is_absolute(&PathBuf::from(s)) {
79			return Ok(RequestedVersion::Path(s.to_string()));
80		}
81
82		if COMMIT_RE.is_match(s) {
83			return Ok(RequestedVersion::Commit(s.to_string()));
84		}
85
86		Err(InvalidRequestedVersion())
87	}
88}
89
90#[derive(Serialize, Deserialize, Clone, Default)]
91struct Stored {
92	/// Map of requested versions to locations where those versions are installed.
93	versions: Vec<(RequestedVersion, OsString)>,
94	current: usize,
95}
96
97pub struct CodeVersionManager {
98	state: PersistedState<Stored>,
99	log: log::Logger,
100}
101
102impl CodeVersionManager {
103	pub fn new(log: log::Logger, lp: &LauncherPaths, _platform: Platform) -> Self {
104		CodeVersionManager {
105			log,
106			state: PersistedState::new(lp.root().join("versions.json")),
107		}
108	}
109
110	/// Tries to find the binary entrypoint for VS Code installed in the path.
111	pub async fn get_entrypoint_for_install_dir(path: &Path) -> Option<PathBuf> {
112		use tokio::sync::mpsc;
113
114		// Check whether the user is supplying a path to the CLI directly (e.g. #164622)
115		if let Ok(true) = path.metadata().map(|m| m.is_file()) {
116			let result = new_std_command(path)
117				.args(["--version"])
118				.output()
119				.map(|o| o.status.success());
120
121			if let Ok(true) = result {
122				return Some(path.to_owned());
123			}
124		}
125
126		let (tx, mut rx) = mpsc::channel(1);
127
128		// Look for all the possible paths in parallel
129		for entry in DESKTOP_CLI_RELATIVE_PATH.split(',') {
130			let my_path = path.join(entry);
131			let my_tx = tx.clone();
132			tokio::spawn(async move {
133				if tokio::fs::metadata(&my_path).await.is_ok() {
134					my_tx.send(my_path).await.ok();
135				}
136			});
137		}
138
139		drop(tx); // drop so rx gets None if no sender emits
140
141		rx.recv().await
142	}
143
144	/// Sets the "version" as the persisted one for the user.
145	pub async fn set_preferred_version(
146		&self,
147		version: RequestedVersion,
148		path: PathBuf,
149	) -> Result<(), AnyError> {
150		let mut stored = self.state.load();
151		stored.current = self.store_version_path(&mut stored, version, path);
152		self.state.save(stored)?;
153		Ok(())
154	}
155
156	/// Stores or updates the path used for the given version. Returns the index
157	/// that the path exists at.
158	fn store_version_path(
159		&self,
160		state: &mut Stored,
161		version: RequestedVersion,
162		path: PathBuf,
163	) -> usize {
164		if let Some(i) = state.versions.iter().position(|(v, _)| v == &version) {
165			state.versions[i].1 = path.into_os_string();
166			i
167		} else {
168			state
169				.versions
170				.push((version.clone(), path.into_os_string()));
171			state.versions.len() - 1
172		}
173	}
174
175	/// Gets the currently preferred version based on set_preferred_version.
176	pub fn get_preferred_version(&self) -> RequestedVersion {
177		let stored = self.state.load();
178		stored
179			.versions
180			.get(stored.current)
181			.map(|(v, _)| v.clone())
182			.unwrap_or(RequestedVersion::Default)
183	}
184
185	/// Tries to get the entrypoint for the version, if one can be found.
186	pub async fn try_get_entrypoint(&self, version: &RequestedVersion) -> Option<PathBuf> {
187		let mut state = self.state.load();
188		if let Some((_, install_path)) = state.versions.iter().find(|(v, _)| v == version) {
189			let p = PathBuf::from(install_path);
190			if p.exists() {
191				return Some(p);
192			}
193		}
194
195		// For simple quality requests, see if that's installed already on the system
196		let candidates = match &version {
197			RequestedVersion::Default => match detect_installed_program(&self.log) {
198				Ok(p) => p,
199				Err(e) => {
200					warning!(self.log, "error looking up installed applications: {}", e);
201					return None;
202				}
203			},
204			_ => return None,
205		};
206
207		let found = match candidates.into_iter().next() {
208			Some(p) => p,
209			None => return None,
210		};
211
212		// stash the found path for faster lookup
213		self.store_version_path(&mut state, version.clone(), found.clone());
214		if let Err(e) = self.state.save(state) {
215			debug!(self.log, "error caching version path: {}", e);
216		}
217
218		Some(found)
219	}
220}
221
222/// Shows a nice UI prompt to users asking them if they want to install the
223/// requested version.
224pub fn prompt_to_install(version: &RequestedVersion) {
225	println!(
226		"No installation of {} {} was found.",
227		QUALITYLESS_PRODUCT_NAME, version
228	);
229
230	if let RequestedVersion::Default = version {
231		if let Some(uri) = PRODUCT_DOWNLOAD_URL {
232			// todo: on some platforms, we may be able to help automate installation. For example,
233			// we can unzip the app ourselves on macOS and on windows we can download and spawn the GUI installer
234			#[cfg(target_os = "linux")]
235			println!("Install it from your system's package manager or {}, restart your shell, and try again.", uri);
236			#[cfg(target_os = "macos")]
237			println!("Download and unzip it from {} and try again.", uri);
238			#[cfg(target_os = "windows")]
239			println!("Install it from {} and try again.", uri);
240		}
241	}
242
243	println!();
244	println!("If you already installed {} and we didn't detect it, run `{} --install-dir /path/to/installation`", QUALITYLESS_PRODUCT_NAME, version.get_command());
245}
246
247#[cfg(target_os = "macos")]
248fn detect_installed_program(log: &log::Logger) -> io::Result<Vec<PathBuf>> {
249	use crate::constants::PRODUCT_NAME_LONG;
250
251	// easy, fast detection for where apps are usually installed
252	let mut probable = PathBuf::from("/Applications");
253	probable.push(format!("{}.app", PRODUCT_NAME_LONG));
254	if probable.exists() {
255		probable.extend(["Contents/Resources", "app", "bin", "code"]);
256		return Ok(vec![probable]);
257	}
258
259	// _Much_ slower detection using the system_profiler (~10s for me). While the
260	// profiler can output nicely structure plist xml, pulling in an xml parser
261	// just for this is overkill. The default output looks something like...
262	//
263	//     Visual Studio Code - Exploration 2:
264	//
265	//        Version: 1.73.0-exploration
266	//        Obtained from: Identified Developer
267	//        Last Modified: 9/23/22, 10:16 AM
268	//        Kind: Intel
269	//        Signed by: Developer ID Application: Microsoft Corporation (UBF8T346G9), Developer ID Certification Authority, Apple Root CA
270	//        Location: /Users/connor/Downloads/Visual Studio Code - Exploration 2.app
271	//
272	// So, use a simple state machine that looks for the first line, and then for
273	// the `Location:` line for the path.
274	info!(log, "Searching for installations on your machine, this is done once and will take about 10 seconds...");
275
276	let stdout = new_std_command("system_profiler")
277		.args(["SPApplicationsDataType", "-detailLevel", "mini"])
278		.output()?
279		.stdout;
280
281	enum State {
282		LookingForName,
283		LookingForLocation,
284	}
285
286	let mut state = State::LookingForName;
287	let mut output: Vec<PathBuf> = vec![];
288	const LOCATION_PREFIX: &str = "Location:";
289	for mut line in String::from_utf8_lossy(&stdout).lines() {
290		line = line.trim();
291		match state {
292			State::LookingForName => {
293				if line.starts_with(PRODUCT_NAME_LONG) && line.ends_with(':') {
294					state = State::LookingForLocation;
295				}
296			}
297			State::LookingForLocation => {
298				if let Some(suffix) = line.strip_prefix(LOCATION_PREFIX) {
299					output.push(
300						[suffix.trim(), "Contents/Resources", "app", "bin", "code"]
301							.iter()
302							.collect(),
303					);
304					state = State::LookingForName;
305				}
306			}
307		}
308	}
309
310	// Sort shorter paths to the front, preferring "more global" installs, and
311	// incidentally preferring local installs over Parallels 'installs'.
312	output.sort_by_key(|a| a.as_os_str().len());
313
314	Ok(output)
315}
316
317#[cfg(windows)]
318fn detect_installed_program(_log: &log::Logger) -> io::Result<Vec<PathBuf>> {
319	use crate::constants::{APPLICATION_NAME, WIN32_APP_IDS};
320	use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
321	use winreg::RegKey;
322
323	let mut output: Vec<PathBuf> = vec![];
324	let app_ids = match WIN32_APP_IDS.as_ref() {
325		Some(ids) => ids,
326		None => return Ok(output),
327	};
328
329	let scopes = [
330		(
331			HKEY_LOCAL_MACHINE,
332			"SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
333		),
334		(
335			HKEY_LOCAL_MACHINE,
336			"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
337		),
338		(
339			HKEY_CURRENT_USER,
340			"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
341		),
342	];
343
344	for (scope, key) in scopes {
345		let cur_ver = match RegKey::predef(scope).open_subkey(key) {
346			Ok(k) => k,
347			Err(_) => continue,
348		};
349
350		for key in cur_ver.enum_keys().flatten() {
351			if app_ids.iter().any(|id| key.contains(id)) {
352				let sk = cur_ver.open_subkey(&key)?;
353				if let Ok(location) = sk.get_value::<String, _>("InstallLocation") {
354					output.push(
355						[
356							location.as_str(),
357							"bin",
358							&format!("{}.cmd", APPLICATION_NAME),
359						]
360						.iter()
361						.collect(),
362					)
363				}
364			}
365		}
366	}
367
368	Ok(output)
369}
370
371// Looks for the given binary name in the PATH, returning all candidate matches.
372// Based on https://github.dev/microsoft/vscode-js-debug/blob/7594d05518df6700df51771895fcad0ddc7f92f9/src/common/pathUtils.ts#L15
373#[cfg(target_os = "linux")]
374fn detect_installed_program(log: &log::Logger) -> io::Result<Vec<PathBuf>> {
375	use crate::constants::APPLICATION_NAME;
376
377	let path = match std::env::var("PATH") {
378		Ok(p) => p,
379		Err(e) => {
380			info!(log, "PATH is empty ({}), skipping detection", e);
381			return Ok(vec![]);
382		}
383	};
384
385	let current_exe = std::env::current_exe().expect("expected to read current exe");
386	let mut output = vec![];
387	for dir in path.split(':') {
388		let target: PathBuf = [dir, APPLICATION_NAME].iter().collect();
389		match std::fs::canonicalize(&target) {
390			Ok(m) if m == current_exe => continue,
391			Ok(_) => {}
392			Err(_) => continue,
393		};
394
395		// note: intentionally store the non-canonicalized version, since if it's a
396		// symlink, (1) it's probably desired to use it and (2) resolving the link
397		// breaks snap installations.
398		output.push(target);
399	}
400
401	Ok(output)
402}
403
404const DESKTOP_CLI_RELATIVE_PATH: &str = if cfg!(target_os = "macos") {
405	"Contents/Resources/app/bin/code"
406} else if cfg!(target_os = "windows") {
407	"bin/code.cmd,bin/code-insiders.cmd,bin/code-exploration.cmd"
408} else {
409	"bin/code,bin/code-insiders,bin/code-exploration"
410};
411
412#[cfg(test)]
413mod tests {
414	use std::{
415		fs::{create_dir_all, File},
416		io::Write,
417	};
418
419	use super::*;
420
421	fn make_fake_vscode_install(path: &Path) {
422		let bin = DESKTOP_CLI_RELATIVE_PATH
423			.split(',')
424			.next()
425			.expect("expected exe path");
426
427		let binary_file_path = path.join(bin);
428		let parent_dir_path = binary_file_path.parent().expect("expected parent path");
429
430		create_dir_all(parent_dir_path).expect("expected to create parent dir");
431
432		let mut binary_file = File::create(binary_file_path).expect("expected to make file");
433		binary_file
434			.write_all(b"")
435			.expect("expected to write binary");
436	}
437
438	fn make_multiple_vscode_install() -> tempfile::TempDir {
439		let dir = tempfile::tempdir().expect("expected to make temp dir");
440		make_fake_vscode_install(&dir.path().join("desktop/stable"));
441		make_fake_vscode_install(&dir.path().join("desktop/1.68.2"));
442		dir
443	}
444
445	#[test]
446	fn test_detect_installed_program() {
447		// developers can run this test and debug output manually; VS Code will not
448		// be installed in CI, so the test only makes sure it doesn't error out
449		let result = detect_installed_program(&log::Logger::test());
450		println!("result: {:?}", result);
451		assert!(result.is_ok());
452	}
453
454	#[tokio::test]
455	async fn test_set_preferred_version() {
456		let dir = make_multiple_vscode_install();
457		let lp = LauncherPaths::new_without_replacements(dir.path().to_owned());
458		let vm1 = CodeVersionManager::new(log::Logger::test(), &lp, Platform::LinuxARM64);
459
460		assert_eq!(vm1.get_preferred_version(), RequestedVersion::Default);
461		vm1.set_preferred_version(
462			RequestedVersion::Commit("foobar".to_string()),
463			dir.path().join("desktop/stable"),
464		)
465		.await
466		.expect("expected to store");
467		vm1.set_preferred_version(
468			RequestedVersion::Commit("foobar2".to_string()),
469			dir.path().join("desktop/stable"),
470		)
471		.await
472		.expect("expected to store");
473
474		assert_eq!(
475			vm1.get_preferred_version(),
476			RequestedVersion::Commit("foobar2".to_string()),
477		);
478
479		let vm2 = CodeVersionManager::new(log::Logger::test(), &lp, Platform::LinuxARM64);
480		assert_eq!(
481			vm2.get_preferred_version(),
482			RequestedVersion::Commit("foobar2".to_string()),
483		);
484	}
485
486	#[tokio::test]
487	async fn test_gets_entrypoint() {
488		let dir = make_multiple_vscode_install();
489
490		assert!(CodeVersionManager::get_entrypoint_for_install_dir(
491			&dir.path().join("desktop").join("stable")
492		)
493		.await
494		.is_some());
495
496		assert!(
497			CodeVersionManager::get_entrypoint_for_install_dir(&dir.path().join("invalid"))
498				.await
499				.is_none()
500		);
501	}
502
503	#[tokio::test]
504	async fn test_gets_entrypoint_as_binary() {
505		let dir = tempfile::tempdir().expect("expected to make temp dir");
506
507		#[cfg(windows)]
508		let binary_file_path = {
509			let path = dir.path().join("code.cmd");
510			File::create(&path).expect("expected to create file");
511			path
512		};
513
514		#[cfg(unix)]
515		let binary_file_path = {
516			use std::fs;
517			use std::os::unix::fs::PermissionsExt;
518
519			let path = dir.path().join("code");
520			{
521				let mut f = File::create(&path).expect("expected to create file");
522				f.write_all(b"#!/bin/sh")
523					.expect("expected to write to file");
524			}
525			fs::set_permissions(&path, fs::Permissions::from_mode(0o777))
526				.expect("expected to set permissions");
527			path
528		};
529
530		assert_eq!(
531			CodeVersionManager::get_entrypoint_for_install_dir(&binary_file_path).await,
532			Some(binary_file_path)
533		);
534	}
535}