Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/cli/src/desktop/version_manager.rs
3314 views
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
6
use std::{
7
ffi::OsString,
8
fmt, io,
9
path::{Path, PathBuf},
10
};
11
12
use lazy_static::lazy_static;
13
use regex::Regex;
14
use serde::{Deserialize, Serialize};
15
16
use 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")]
30
pub enum RequestedVersion {
31
Default,
32
Commit(String),
33
Path(String),
34
}
35
36
lazy_static! {
37
static ref COMMIT_RE: Regex = Regex::new(r"(?i)^[0-9a-f]{40}$").unwrap();
38
}
39
40
impl 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
56
impl 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
70
impl 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)]
91
struct Stored {
92
/// Map of requested versions to locations where those versions are installed.
93
versions: Vec<(RequestedVersion, OsString)>,
94
current: usize,
95
}
96
97
pub struct CodeVersionManager {
98
state: PersistedState<Stored>,
99
log: log::Logger,
100
}
101
102
impl 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.
224
pub fn prompt_to_install(version: &RequestedVersion) {
225
println!("No installation of {QUALITYLESS_PRODUCT_NAME} {version} was found.");
226
227
if let RequestedVersion::Default = version {
228
if let Some(uri) = PRODUCT_DOWNLOAD_URL {
229
// todo: on some platforms, we may be able to help automate installation. For example,
230
// we can unzip the app ourselves on macOS and on windows we can download and spawn the GUI installer
231
#[cfg(target_os = "linux")]
232
println!("Install it from your system's package manager or {uri}, restart your shell, and try again.");
233
#[cfg(target_os = "macos")]
234
println!("Download and unzip it from {} and try again.", uri);
235
#[cfg(target_os = "windows")]
236
println!("Install it from {} and try again.", uri);
237
}
238
}
239
240
println!();
241
println!("If you already installed {} and we didn't detect it, run `{} --install-dir /path/to/installation`", QUALITYLESS_PRODUCT_NAME, version.get_command());
242
}
243
244
#[cfg(target_os = "macos")]
245
fn detect_installed_program(log: &log::Logger) -> io::Result<Vec<PathBuf>> {
246
use crate::constants::PRODUCT_NAME_LONG;
247
248
// easy, fast detection for where apps are usually installed
249
let mut probable = PathBuf::from("/Applications");
250
probable.push(format!("{}.app", PRODUCT_NAME_LONG));
251
if probable.exists() {
252
probable.extend(["Contents/Resources", "app", "bin", "code"]);
253
return Ok(vec![probable]);
254
}
255
256
// _Much_ slower detection using the system_profiler (~10s for me). While the
257
// profiler can output nicely structure plist xml, pulling in an xml parser
258
// just for this is overkill. The default output looks something like...
259
//
260
// Visual Studio Code - Exploration 2:
261
//
262
// Version: 1.73.0-exploration
263
// Obtained from: Identified Developer
264
// Last Modified: 9/23/22, 10:16 AM
265
// Kind: Intel
266
// Signed by: Developer ID Application: Microsoft Corporation (UBF8T346G9), Developer ID Certification Authority, Apple Root CA
267
// Location: /Users/connor/Downloads/Visual Studio Code - Exploration 2.app
268
//
269
// So, use a simple state machine that looks for the first line, and then for
270
// the `Location:` line for the path.
271
info!(log, "Searching for installations on your machine, this is done once and will take about 10 seconds...");
272
273
let stdout = new_std_command("system_profiler")
274
.args(["SPApplicationsDataType", "-detailLevel", "mini"])
275
.output()?
276
.stdout;
277
278
enum State {
279
LookingForName,
280
LookingForLocation,
281
}
282
283
let mut state = State::LookingForName;
284
let mut output: Vec<PathBuf> = vec![];
285
const LOCATION_PREFIX: &str = "Location:";
286
for mut line in String::from_utf8_lossy(&stdout).lines() {
287
line = line.trim();
288
match state {
289
State::LookingForName => {
290
if line.starts_with(PRODUCT_NAME_LONG) && line.ends_with(':') {
291
state = State::LookingForLocation;
292
}
293
}
294
State::LookingForLocation => {
295
if let Some(suffix) = line.strip_prefix(LOCATION_PREFIX) {
296
output.push(
297
[suffix.trim(), "Contents/Resources", "app", "bin", "code"]
298
.iter()
299
.collect(),
300
);
301
state = State::LookingForName;
302
}
303
}
304
}
305
}
306
307
// Sort shorter paths to the front, preferring "more global" installs, and
308
// incidentally preferring local installs over Parallels 'installs'.
309
output.sort_by_key(|a| a.as_os_str().len());
310
311
Ok(output)
312
}
313
314
#[cfg(windows)]
315
fn detect_installed_program(_log: &log::Logger) -> io::Result<Vec<PathBuf>> {
316
use crate::constants::{APPLICATION_NAME, WIN32_APP_IDS};
317
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
318
use winreg::RegKey;
319
320
let mut output: Vec<PathBuf> = vec![];
321
let app_ids = match WIN32_APP_IDS.as_ref() {
322
Some(ids) => ids,
323
None => return Ok(output),
324
};
325
326
let scopes = [
327
(
328
HKEY_LOCAL_MACHINE,
329
"SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
330
),
331
(
332
HKEY_LOCAL_MACHINE,
333
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
334
),
335
(
336
HKEY_CURRENT_USER,
337
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
338
),
339
];
340
341
for (scope, key) in scopes {
342
let cur_ver = match RegKey::predef(scope).open_subkey(key) {
343
Ok(k) => k,
344
Err(_) => continue,
345
};
346
347
for key in cur_ver.enum_keys().flatten() {
348
if app_ids.iter().any(|id| key.contains(id)) {
349
let sk = cur_ver.open_subkey(&key)?;
350
if let Ok(location) = sk.get_value::<String, _>("InstallLocation") {
351
output.push(
352
[
353
location.as_str(),
354
"bin",
355
&format!("{}.cmd", APPLICATION_NAME),
356
]
357
.iter()
358
.collect(),
359
)
360
}
361
}
362
}
363
}
364
365
Ok(output)
366
}
367
368
// Looks for the given binary name in the PATH, returning all candidate matches.
369
// Based on https://github.dev/microsoft/vscode-js-debug/blob/7594d05518df6700df51771895fcad0ddc7f92f9/src/common/pathUtils.ts#L15
370
#[cfg(target_os = "linux")]
371
fn detect_installed_program(log: &log::Logger) -> io::Result<Vec<PathBuf>> {
372
use crate::constants::APPLICATION_NAME;
373
374
let path = match std::env::var("PATH") {
375
Ok(p) => p,
376
Err(e) => {
377
info!(log, "PATH is empty ({}), skipping detection", e);
378
return Ok(vec![]);
379
}
380
};
381
382
let current_exe = std::env::current_exe().expect("expected to read current exe");
383
let mut output = vec![];
384
for dir in path.split(':') {
385
let target: PathBuf = [dir, APPLICATION_NAME].iter().collect();
386
match std::fs::canonicalize(&target) {
387
Ok(m) if m == current_exe => continue,
388
Ok(_) => {}
389
Err(_) => continue,
390
};
391
392
// note: intentionally store the non-canonicalized version, since if it's a
393
// symlink, (1) it's probably desired to use it and (2) resolving the link
394
// breaks snap installations.
395
output.push(target);
396
}
397
398
Ok(output)
399
}
400
401
const DESKTOP_CLI_RELATIVE_PATH: &str = if cfg!(target_os = "macos") {
402
"Contents/Resources/app/bin/code"
403
} else if cfg!(target_os = "windows") {
404
"bin/code.cmd,bin/code-insiders.cmd,bin/code-exploration.cmd"
405
} else {
406
"bin/code,bin/code-insiders,bin/code-exploration"
407
};
408
409
#[cfg(test)]
410
mod tests {
411
use std::{
412
fs::{create_dir_all, File},
413
io::Write,
414
};
415
416
use super::*;
417
418
fn make_fake_vscode_install(path: &Path) {
419
let bin = DESKTOP_CLI_RELATIVE_PATH
420
.split(',')
421
.next()
422
.expect("expected exe path");
423
424
let binary_file_path = path.join(bin);
425
let parent_dir_path = binary_file_path.parent().expect("expected parent path");
426
427
create_dir_all(parent_dir_path).expect("expected to create parent dir");
428
429
let mut binary_file = File::create(binary_file_path).expect("expected to make file");
430
binary_file
431
.write_all(b"")
432
.expect("expected to write binary");
433
}
434
435
fn make_multiple_vscode_install() -> tempfile::TempDir {
436
let dir = tempfile::tempdir().expect("expected to make temp dir");
437
make_fake_vscode_install(&dir.path().join("desktop/stable"));
438
make_fake_vscode_install(&dir.path().join("desktop/1.68.2"));
439
dir
440
}
441
442
#[test]
443
fn test_detect_installed_program() {
444
// developers can run this test and debug output manually; VS Code will not
445
// be installed in CI, so the test only makes sure it doesn't error out
446
let result = detect_installed_program(&log::Logger::test());
447
println!("result: {result:?}");
448
assert!(result.is_ok());
449
}
450
451
#[tokio::test]
452
async fn test_set_preferred_version() {
453
let dir = make_multiple_vscode_install();
454
let lp = LauncherPaths::new_without_replacements(dir.path().to_owned());
455
let vm1 = CodeVersionManager::new(log::Logger::test(), &lp, Platform::LinuxARM64);
456
457
assert_eq!(vm1.get_preferred_version(), RequestedVersion::Default);
458
vm1.set_preferred_version(
459
RequestedVersion::Commit("foobar".to_string()),
460
dir.path().join("desktop/stable"),
461
)
462
.await
463
.expect("expected to store");
464
vm1.set_preferred_version(
465
RequestedVersion::Commit("foobar2".to_string()),
466
dir.path().join("desktop/stable"),
467
)
468
.await
469
.expect("expected to store");
470
471
assert_eq!(
472
vm1.get_preferred_version(),
473
RequestedVersion::Commit("foobar2".to_string()),
474
);
475
476
let vm2 = CodeVersionManager::new(log::Logger::test(), &lp, Platform::LinuxARM64);
477
assert_eq!(
478
vm2.get_preferred_version(),
479
RequestedVersion::Commit("foobar2".to_string()),
480
);
481
}
482
483
#[tokio::test]
484
async fn test_gets_entrypoint() {
485
let dir = make_multiple_vscode_install();
486
487
assert!(CodeVersionManager::get_entrypoint_for_install_dir(
488
&dir.path().join("desktop").join("stable")
489
)
490
.await
491
.is_some());
492
493
assert!(
494
CodeVersionManager::get_entrypoint_for_install_dir(&dir.path().join("invalid"))
495
.await
496
.is_none()
497
);
498
}
499
500
#[tokio::test]
501
async fn test_gets_entrypoint_as_binary() {
502
let dir = tempfile::tempdir().expect("expected to make temp dir");
503
504
#[cfg(windows)]
505
let binary_file_path = {
506
let path = dir.path().join("code.cmd");
507
File::create(&path).expect("expected to create file");
508
path
509
};
510
511
#[cfg(unix)]
512
let binary_file_path = {
513
use std::fs;
514
use std::os::unix::fs::PermissionsExt;
515
516
let path = dir.path().join("code");
517
{
518
let mut f = File::create(&path).expect("expected to create file");
519
f.write_all(b"#!/bin/sh")
520
.expect("expected to write to file");
521
}
522
fs::set_permissions(&path, fs::Permissions::from_mode(0o777))
523
.expect("expected to set permissions");
524
path
525
};
526
527
assert_eq!(
528
CodeVersionManager::get_entrypoint_for_install_dir(&binary_file_path).await,
529
Some(binary_file_path)
530
);
531
}
532
}
533
534