1use 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#[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 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 pub async fn get_entrypoint_for_install_dir(path: &Path) -> Option<PathBuf> {
112 use tokio::sync::mpsc;
113
114 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 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); rx.recv().await
142 }
143
144 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 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 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 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 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 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
222pub 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 #[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 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 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 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#[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 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 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}