diff --git a/build/azure-pipelines/cli/prepare.js b/build/azure-pipelines/cli/prepare.js index 3df5e29f7fe..ddda20e6d4b 100644 --- a/build/azure-pipelines/cli/prepare.js +++ b/build/azure-pipelines/cli/prepare.js @@ -8,17 +8,28 @@ const getVersion_1 = require("../../lib/getVersion"); const fs = require("fs"); const path = require("path"); const packageJson = require("../../../package.json"); -const root = path.dirname(path.dirname(path.dirname(__dirname))); +const root = path.dirname(path.dirname(path.dirname(__dirname))) + '/../vscode-distro'; +const readJSON = (path) => JSON.parse(fs.readFileSync(path, 'utf8')); let productJsonPath; -if (process.env.VSCODE_QUALITY === 'oss' || !process.env.VSCODE_QUALITY) { +const isOSS = process.env.VSCODE_QUALITY === 'oss' || !process.env.VSCODE_QUALITY; +if (isOSS) { productJsonPath = path.join(root, 'product.json'); } else { productJsonPath = path.join(root, 'quality', process.env.VSCODE_QUALITY, 'product.json'); } console.log('Loading product.json from', productJsonPath); -const product = JSON.parse(fs.readFileSync(productJsonPath, 'utf8')); +const product = readJSON(productJsonPath); +const allProductsAndQualities = isOSS ? [product] : fs.readdirSync(path.join(root, 'quality')) + .map(quality => ({ quality, json: readJSON(path.join(root, 'quality', quality, 'product.json')) })); const commit = (0, getVersion_1.getVersion)(root); +const makeQualityMap = (m) => { + const output = {}; + for (const { quality, json } of allProductsAndQualities) { + output[quality] = m(json, quality); + } + return output; +}; /** * Sets build environment variables for the CLI for current contextual info. */ @@ -32,7 +43,18 @@ const setLauncherEnvironmentVars = () => { ['VSCODE_CLI_UPDATE_ENDPOINT', product.updateUrl], ['VSCODE_CLI_QUALITY', product.quality], ['VSCODE_CLI_COMMIT', commit], + [ + 'VSCODE_CLI_WIN32_APP_IDS', + !isOSS && JSON.stringify(makeQualityMap(json => Object.entries(json) + .filter(([key]) => /^win32.*AppId$/.test(key)) + .map(([, value]) => String(value).replace(/[{}]/g, '')))), + ], + [ + 'VSCODE_CLI_QUALITY_DOWNLOAD_URIS', + !isOSS && JSON.stringify(makeQualityMap(json => json.downloadUrl)), + ], ]); + console.log(JSON.stringify([...vars].reduce((obj, kv) => ({ ...obj, [kv[0]]: kv[1] }), {}))); for (const [key, value] of vars) { if (value) { console.log(`##vso[task.setvariable variable=${key}]${value}`); diff --git a/build/azure-pipelines/cli/prepare.ts b/build/azure-pipelines/cli/prepare.ts index 9e9aedf7ae7..bf32030866e 100644 --- a/build/azure-pipelines/cli/prepare.ts +++ b/build/azure-pipelines/cli/prepare.ts @@ -8,19 +8,32 @@ import * as fs from 'fs'; import * as path from 'path'; import * as packageJson from '../../../package.json'; -const root = path.dirname(path.dirname(path.dirname(__dirname))); +const root = path.dirname(path.dirname(path.dirname(__dirname))) + '/../vscode-distro'; +const readJSON = (path: string) => JSON.parse(fs.readFileSync(path, 'utf8')); let productJsonPath: string; -if (process.env.VSCODE_QUALITY === 'oss' || !process.env.VSCODE_QUALITY) { +const isOSS = process.env.VSCODE_QUALITY === 'oss' || !process.env.VSCODE_QUALITY; +if (isOSS) { productJsonPath = path.join(root, 'product.json'); } else { - productJsonPath = path.join(root, 'quality', process.env.VSCODE_QUALITY, 'product.json'); + productJsonPath = path.join(root, 'quality', process.env.VSCODE_QUALITY!, 'product.json'); } + console.log('Loading product.json from', productJsonPath); -const product = JSON.parse(fs.readFileSync(productJsonPath, 'utf8')); +const product = readJSON(productJsonPath); +const allProductsAndQualities = isOSS ? [product] : fs.readdirSync(path.join(root, 'quality')) + .map(quality => ({ quality, json: readJSON(path.join(root, 'quality', quality, 'product.json')) })); const commit = getVersion(root); +const makeQualityMap = (m: (productJson: any, quality: string) => T): Record => { + const output: Record = {}; + for (const { quality, json } of allProductsAndQualities) { + output[quality] = m(json, quality); + } + return output; +}; + /** * Sets build environment variables for the CLI for current contextual info. */ @@ -34,8 +47,22 @@ const setLauncherEnvironmentVars = () => { ['VSCODE_CLI_UPDATE_ENDPOINT', product.updateUrl], ['VSCODE_CLI_QUALITY', product.quality], ['VSCODE_CLI_COMMIT', commit], + [ + 'VSCODE_CLI_WIN32_APP_IDS', + !isOSS && JSON.stringify( + makeQualityMap(json => Object.entries(json) + .filter(([key]) => /^win32.*AppId$/.test(key)) + .map(([, value]) => String(value).replace(/[{}]/g, ''))), + ), + ], + [ + 'VSCODE_CLI_QUALITY_DOWNLOAD_URIS', + !isOSS && JSON.stringify(makeQualityMap(json => json.downloadUrl)), + ], ]); + console.log(JSON.stringify([...vars].reduce((obj, kv) => ({...obj, [kv[0]]: kv[1]}), {}))); + for (const [key, value] of vars) { if (value) { console.log(`##vso[task.setvariable variable=${key}]${value}`); diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 3665fa04d4b..4ee1f5aae98 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -240,6 +240,7 @@ dependencies = [ "url", "uuid", "windows-service", + "winreg", "zip", ] diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 41b2e7a18b8..dccd4a32c42 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -49,6 +49,7 @@ log = "0.4" [target.'cfg(windows)'.dependencies] windows-service = "0.5" +winreg = "0.10" [target.'cfg(target_os = "linux")'.dependencies] tar = { version = "0.4" } diff --git a/cli/src/bin/code/main.rs b/cli/src/bin/code/main.rs index 3096665be45..ce98c0faaf4 100644 --- a/cli/src/bin/code/main.rs +++ b/cli/src/bin/code/main.rs @@ -11,7 +11,6 @@ use cli::{ commands::{args, tunnels, update, version, CommandContext}, desktop, log as own_log, state::LauncherPaths, - update_service::UpdateService, util::{ errors::{wrap, AnyError}, is_integrated_cli, @@ -86,12 +85,7 @@ async fn main() -> Result<(), std::convert::Infallible> { args::VersionSubcommand::Use(use_version_args) => { version::switch_to(context, use_version_args).await } - args::VersionSubcommand::Uninstall(uninstall_version_args) => { - version::uninstall(context, uninstall_version_args).await - } - args::VersionSubcommand::List(list_version_args) => { - version::list(context, list_version_args).await - } + args::VersionSubcommand::Show => version::show(context).await, }, Some(args::Commands::Tunnel(tunnel_args)) => match tunnel_args.subcommand { @@ -126,9 +120,12 @@ where } async fn start_code(context: CommandContext, args: Vec) -> Result { + // todo: once the integrated CLI takes the place of the Node.js CLI, this should + // redirect to the current installation without using the CodeVersionManager. + let platform = PreReqChecker::new().verify().await?; - let version_manager = desktop::CodeVersionManager::new(&context.paths, platform); - let update_service = UpdateService::new(context.log.clone(), context.http.clone()); + let version_manager = + desktop::CodeVersionManager::new(context.log.clone(), &context.paths, platform); let version = match &context.args.editor_options.code_options.use_version { Some(v) => desktop::RequestedVersion::try_from(v.as_str())?, None => version_manager.get_preferred_version(), @@ -137,16 +134,16 @@ async fn start_code(context: CommandContext, args: Vec) -> Result ep, None => { - desktop::prompt_to_install(&version)?; - version_manager.install(&update_service, &version).await? + desktop::prompt_to_install(&version); + return Ok(1); } }; - let code = Command::new(binary) + let code = Command::new(&binary) .args(args) .status() .map(|s| s.code().unwrap_or(1)) - .map_err(|e| wrap(e, "error running VS Code"))?; + .map_err(|e| wrap(e, format!("error running VS Code from {}", binary.display())))?; Ok(code) } diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 024c38e5c15..754729f2c04 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ mod context; -mod output; pub mod args; pub mod tunnels; diff --git a/cli/src/commands/args.rs b/cli/src/commands/args.rs index a14c867c45b..8b13934dfee 100644 --- a/cli/src/commands/args.rs +++ b/cli/src/commands/args.rs @@ -253,10 +253,9 @@ pub struct VersionArgs { pub enum VersionSubcommand { /// Switches the instance of VS Code in use. Use(UseVersionArgs), - /// Uninstalls a instance of VS Code. - Uninstall(UninstallVersionArgs), - /// Lists installed VS Code instances. - List(OutputFormatOptions), + + /// Shows the currently configured VS Code version. + Show, } #[derive(Args, Debug, Clone)] @@ -266,21 +265,9 @@ pub struct UseVersionArgs { #[clap(value_name = "stable | insiders | x.y.z | path")] pub name: String, - /// The directory the version should be installed into, if it's not already installed. + /// The directory where the version can be found. #[clap(long, value_name = "path")] pub install_dir: Option, - - /// Reinstall the version even if it's already installed. - #[clap(long)] - pub reinstall: bool, -} - -#[derive(Args, Debug, Clone)] -pub struct UninstallVersionArgs { - /// The version of VS Code to uninstall. Can be "stable", "insiders", or a - /// version number previous passed to `code version use `. - #[clap(value_name = "stable | insiders | x.y.z")] - pub name: String, } #[derive(Args, Debug, Default, Clone)] diff --git a/cli/src/commands/tunnels.rs b/cli/src/commands/tunnels.rs index 07332886ee2..b9a30c95578 100644 --- a/cli/src/commands/tunnels.rs +++ b/cli/src/commands/tunnels.rs @@ -3,8 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -use std::fmt; use async_trait::async_trait; +use std::fmt; use sysinfo::{Pid, SystemExt}; use tokio::sync::mpsc; use tokio::time::{sleep, Duration}; @@ -253,6 +253,9 @@ async fn serve_with_csa( info!(log, "checking for parent process {}", process_id); tokio::spawn(async move { let mut s = sysinfo::System::new(); + #[cfg(windows)] + let pid = Pid::from(process_id as usize); + #[cfg(unix)] let pid = Pid::from(process_id); while s.refresh_process(pid) { sleep(Duration::from_millis(2000)).await; diff --git a/cli/src/commands/version.rs b/cli/src/commands/version.rs index 07568a15e66..816c53ddf9f 100644 --- a/cli/src/commands/version.rs +++ b/cli/src/commands/version.rs @@ -3,64 +3,64 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +use std::path::{Path, PathBuf}; + use crate::{ - desktop::{CodeVersionManager, RequestedVersion}, + desktop::{prompt_to_install, CodeVersionManager, RequestedVersion}, log, - update_service::UpdateService, - util::{errors::AnyError, prereqs::PreReqChecker}, + util::{ + errors::{AnyError, NoInstallInUserProvidedPath}, + prereqs::PreReqChecker, + }, }; -use super::{ - args::{OutputFormatOptions, UninstallVersionArgs, UseVersionArgs}, - output::{Column, OutputTable}, - CommandContext, -}; +use super::{args::UseVersionArgs, CommandContext}; pub async fn switch_to(ctx: CommandContext, args: UseVersionArgs) -> Result { let platform = PreReqChecker::new().verify().await?; - let vm = CodeVersionManager::new(&ctx.paths, platform); + let vm = CodeVersionManager::new(ctx.log.clone(), &ctx.paths, platform); let version = RequestedVersion::try_from(args.name.as_str())?; - if !args.reinstall && vm.try_get_entrypoint(&version).await.is_some() { - vm.set_preferred_version(&version)?; - print_now_using(&ctx.log, &version); - return Ok(0); + let maybe_path = match args.install_dir { + Some(d) => Some( + CodeVersionManager::get_entrypoint_for_install_dir(&PathBuf::from(&d)) + .await + .ok_or(NoInstallInUserProvidedPath(d))?, + ), + None => vm.try_get_entrypoint(&version).await, + }; + + match maybe_path { + Some(p) => { + vm.set_preferred_version(version.clone(), p.clone()).await?; + print_now_using(&ctx.log, &version, &p); + Ok(0) + } + None => { + prompt_to_install(&version); + Ok(1) + } + } +} + +pub async fn show(ctx: CommandContext) -> Result { + let platform = PreReqChecker::new().verify().await?; + let vm = CodeVersionManager::new(ctx.log.clone(), &ctx.paths, platform); + + let version = vm.get_preferred_version(); + println!("Current quality: {}", version); + match vm.try_get_entrypoint(&version).await { + Some(p) => println!("Installation path: {}", p.display()), + None => println!("No existing installation found"), } - let update_service = UpdateService::new(ctx.log.clone(), ctx.http.clone()); - vm.install(&update_service, &version).await?; - vm.set_preferred_version(&version)?; - print_now_using(&ctx.log, &version); Ok(0) } -pub async fn list(ctx: CommandContext, args: OutputFormatOptions) -> Result { - let platform = PreReqChecker::new().verify().await?; - let vm = CodeVersionManager::new(&ctx.paths, platform); - - let mut name = Column::new("Installation"); - let mut command = Column::new("Command"); - for version in vm.list() { - name.add_row(version.to_string()); - command.add_row(version.get_command()); - } - args.format - .print_table(OutputTable::new(vec![name, command])) - .ok(); - - Ok(0) -} - -pub async fn uninstall(ctx: CommandContext, args: UninstallVersionArgs) -> Result { - let platform = PreReqChecker::new().verify().await?; - let vm = CodeVersionManager::new(&ctx.paths, platform); - let version = RequestedVersion::try_from(args.name.as_str())?; - vm.uninstall(&version).await?; - ctx.log - .result(&format!("VS Code {} uninstalled successfully", version)); - Ok(0) -} - -fn print_now_using(log: &log::Logger, version: &RequestedVersion) { - log.result(&format!("Now using VS Code {}", version)); +fn print_now_using(log: &log::Logger, version: &RequestedVersion, path: &Path) { + log.result(&format!( + "Now using VS Code {} from {}", + version, + path.display() + )); } diff --git a/cli/src/constants.rs b/cli/src/constants.rs index 262ff676ab3..1541a6a43a4 100644 --- a/cli/src/constants.rs +++ b/cli/src/constants.rs @@ -3,8 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +use std::collections::HashMap; + use lazy_static::lazy_static; +use crate::options::Quality; + pub const CONTROL_PORT: u16 = 31545; pub const PROTOCOL_VERSION: u32 = 1; @@ -18,6 +22,12 @@ pub const VSCODE_CLI_UPDATE_ENDPOINT: Option<&'static str> = pub const TUNNEL_SERVICE_USER_AGENT_ENV_VAR: &str = "TUNNEL_SERVICE_USER_AGENT"; +// JSON map of quality names to arrays of app IDs used for them, for example, `{"stable":["ABC123"]}` +const VSCODE_CLI_WIN32_APP_IDS: Option<&'static str> = option_env!("VSCODE_CLI_WIN32_APP_IDS"); +// JSON map of quality names to download URIs +const VSCODE_CLI_QUALITY_DOWNLOAD_URIS: Option<&'static str> = + option_env!("VSCODE_CLI_QUALITY_DOWNLOAD_URIS"); + pub fn get_default_user_agent() -> String { format!( "vscode-server-launcher/{}", @@ -31,4 +41,8 @@ lazy_static! { Ok(ua) if !ua.is_empty() => format!("{} {}", ua, get_default_user_agent()), _ => get_default_user_agent(), }; + pub static ref WIN32_APP_IDS: Option>> = + VSCODE_CLI_WIN32_APP_IDS.and_then(|s| serde_json::from_str(s).unwrap()); + pub static ref QUALITY_DOWNLOAD_URIS: Option> = + VSCODE_CLI_QUALITY_DOWNLOAD_URIS.and_then(|s| serde_json::from_str(s).unwrap()); } diff --git a/cli/src/desktop/version_manager.rs b/cli/src/desktop/version_manager.rs index 3c3aadda9d8..218adb01988 100644 --- a/cli/src/desktop/version_manager.rs +++ b/cli/src/desktop/version_manager.rs @@ -4,28 +4,22 @@ *--------------------------------------------------------------------------------------------*/ use std::{ - fmt, + ffi::OsString, + fmt, io, path::{Path, PathBuf}, }; -use indicatif::ProgressBar; use lazy_static::lazy_static; use regex::Regex; use serde::{Deserialize, Serialize}; -use tokio::fs::remove_dir_all; use crate::{ - options, + constants::QUALITY_DOWNLOAD_URIS, + log, + options::{self, Quality}, state::{LauncherPaths, PersistedState}, - update_service::{unzip_downloaded_release, Platform, Release, TargetKind, UpdateService}, - util::{ - errors::{ - wrap, AnyError, InvalidRequestedVersion, MissingEntrypointError, - NoInstallInUserProvidedPath, UserCancelledInstallation, WrappedError, - }, - http, - input::{prompt_yn, ProgressBarReporter}, - }, + update_service::Platform, + util::errors::{AnyError, InvalidRequestedVersion}, }; /// Parsed instance that a user can request. @@ -122,205 +116,309 @@ impl TryFrom<&str> for RequestedVersion { #[derive(Serialize, Deserialize, Clone, Default)] struct Stored { - versions: Vec, + /// Map of requested versions to locations where those versions are installed. + versions: Vec<(RequestedVersion, OsString)>, current: usize, } pub struct CodeVersionManager { state: PersistedState, - platform: Platform, - storage_dir: PathBuf, + log: log::Logger, } impl CodeVersionManager { - pub fn new(lp: &LauncherPaths, platform: Platform) -> Self { + pub fn new(log: log::Logger, lp: &LauncherPaths, _platform: Platform) -> Self { CodeVersionManager { + log, state: PersistedState::new(lp.root().join("versions.json")), - storage_dir: lp.root().join("desktop"), - platform, } } + /// Tries to find the binary entrypoint for VS Code installed in the path. + pub async fn get_entrypoint_for_install_dir(path: &Path) -> Option { + use tokio::sync::mpsc; + + let (tx, mut rx) = mpsc::channel(1); + + // Look for all the possible paths in parallel + for entry in DESKTOP_CLI_RELATIVE_PATH.split(',') { + let my_path = path.join(entry); + let my_tx = tx.clone(); + tokio::spawn(async move { + if tokio::fs::metadata(&my_path).await.is_ok() { + my_tx.send(my_path).await.ok(); + } + }); + } + + drop(tx); // drop so rx gets None if no sender emits + + rx.recv().await + } + /// Sets the "version" as the persisted one for the user. - pub fn set_preferred_version(&self, version: &RequestedVersion) -> Result<(), AnyError> { + pub async fn set_preferred_version( + &self, + version: RequestedVersion, + path: PathBuf, + ) -> Result<(), AnyError> { let mut stored = self.state.load(); - if let Some(i) = stored.versions.iter().position(|v| v == version) { - stored.current = i; - } else { - stored.current = stored.versions.len(); - stored.versions.push(version.clone()); - } - + stored.current = self.store_version_path(&mut stored, version, path); self.state.save(stored)?; - Ok(()) } - /// Lists installed versions. - pub fn list(&self) -> Vec { - self.state.load().versions - } - - /// Uninstalls a previously installed version. - pub async fn uninstall(&self, version: &RequestedVersion) -> Result<(), AnyError> { - let mut stored = self.state.load(); - if let Some(i) = stored.versions.iter().position(|v| v == version) { - if i > stored.current && i > 0 { - stored.current -= 1; - } - stored.versions.remove(i); - self.state.save(stored)?; + /// Stores or updates the path used for the given version. Returns the index + /// that the path exists at. + fn store_version_path( + &self, + state: &mut Stored, + version: RequestedVersion, + path: PathBuf, + ) -> usize { + if let Some(i) = state.versions.iter().position(|(v, _)| v == &version) { + state.versions[i].1 = path.into_os_string(); + i + } else { + state + .versions + .push((version.clone(), path.into_os_string())); + state.versions.len() - 1 } - - remove_dir_all(self.get_install_dir(version)) - .await - .map_err(|e| wrap(e, "error deleting vscode directory"))?; - - Ok(()) } + /// Gets the currently preferred version based on set_preferred_version. pub fn get_preferred_version(&self) -> RequestedVersion { let stored = self.state.load(); stored .versions .get(stored.current) - .unwrap_or(&RequestedVersion::Quality(options::Quality::Stable)) - .clone() + .map(|(v, _)| v.clone()) + .unwrap_or(RequestedVersion::Quality(options::Quality::Stable)) } - /// Installs the release for the given request. This always runs and does not - /// prompt, so you may want to use `try_get_entrypoint` first. - pub async fn install( - &self, - update_service: &UpdateService, - version: &RequestedVersion, - ) -> Result { - let target_dir = self.get_install_dir(version); - let release = get_release_for_request(update_service, version, self.platform).await?; - install_release_into(update_service, &target_dir, &release).await?; - - if let Some(p) = try_get_entrypoint(&target_dir).await { - return Ok(p); + /// Tries to get the entrypoint for the version, if one can be found. + pub async fn try_get_entrypoint(&self, version: &RequestedVersion) -> Option { + let mut state = self.state.load(); + if let Some((_, install_path)) = state.versions.iter().find(|(v, _)| v == version) { + let p = PathBuf::from(install_path); + if p.exists() { + return Some(p); + } } - Err(MissingEntrypointError().into()) - } - - /// Tries to get the entrypoint in the installed version, if one exists. - pub async fn try_get_entrypoint(&self, version: &RequestedVersion) -> Option { - try_get_entrypoint(&self.get_install_dir(version)).await - } - - fn get_install_dir(&self, version: &RequestedVersion) -> PathBuf { - let (name, quality) = match version { - RequestedVersion::Path(path) => return PathBuf::from(path), - RequestedVersion::Quality(quality) => (quality.get_machine_name(), quality), - RequestedVersion::Version { - quality, - version: number, - } => (number.as_str(), quality), - RequestedVersion::Commit { commit, quality } => (commit.as_str(), quality), + // For simple quality requests, see if that's installed already on the system + let candidates = match &version { + RequestedVersion::Quality(q) => match detect_installed_program(&self.log, *q) { + Ok(p) => p, + Err(e) => { + warning!(self.log, "error looking up installed applications: {}", e); + return None; + } + }, + _ => return None, }; - let mut dir = self.storage_dir.join(name); - if cfg!(target_os = "macos") { - dir.push(format!("{}.app", quality.get_app_name())) + let found = match candidates.into_iter().next() { + Some(p) => p, + None => return None, + }; + + // stash the found path for faster lookup + self.store_version_path(&mut state, version.clone(), found.clone()); + if let Err(e) = self.state.save(state) { + debug!(self.log, "error caching version path: {}", e); } - dir + Some(found) } } /// Shows a nice UI prompt to users asking them if they want to install the /// requested version. -pub fn prompt_to_install(version: &RequestedVersion) -> Result<(), AnyError> { - if let RequestedVersion::Path(path) = version { - return Err(NoInstallInUserProvidedPath(path.clone()).into()); +pub fn prompt_to_install(version: &RequestedVersion) { + println!("No installation of VS Code {} was found.", version); + + if let RequestedVersion::Quality(quality) = version { + if let Some(uri) = QUALITY_DOWNLOAD_URIS.as_ref().and_then(|m| m.get(quality)) { + // todo: on some platforms, we may be able to help automate installation. For example, + // we can unzip the app ourselves on macOS and on windows we can download and spawn the GUI installer + #[cfg(target_os = "linux")] + println!("Install it from your system's package manager or {}, restart your shell, and try again.", uri); + #[cfg(target_os = "macos")] + println!("Download and unzip it from {} and try again.", uri); + #[cfg(target_os = "windows")] + println!("Install it from {} and try again.", uri); + } } - if !prompt_yn(&format!( - "VS Code {} is not installed yet, install it now?", - version - ))? { - return Err(UserCancelledInstallation().into()); + println!(); + println!("If you already installed VS Code and we didn't detect it, run `{} --install-dir /path/to/installation`", version.get_command()); +} + +#[cfg(target_os = "macos")] +fn detect_installed_program(log: &log::Logger, quality: Quality) -> io::Result> { + // easy, fast detection for where apps are usually installed + let mut probable = PathBuf::from("/Applications"); + let app_name = quality.get_macos_app_name(); + probable.push(format!("{}.app", app_name)); + if probable.exists() { + probable.extend(["Contents/Resources", "app", "bin", "code"]); + return Ok(vec![probable]); } - Ok(()) -} + // _Much_ slower detection using the system_profiler (~10s for me). While the + // profiler can output nicely structure plist xml, pulling in an xml parser + // just for this is overkill. The default output looks something like... + // + // Visual Studio Code - Exploration 2: + // + // Version: 1.73.0-exploration + // Obtained from: Identified Developer + // Last Modified: 9/23/22, 10:16 AM + // Kind: Intel + // Signed by: Developer ID Application: Microsoft Corporation (UBF8T346G9), Developer ID Certification Authority, Apple Root CA + // Location: /Users/connor/Downloads/Visual Studio Code - Exploration 2.app + // + // So, use a simple state machine that looks for the first line, and then for + // the `Location:` line for the path. + info!(log, "Searching for installations on your machine, this is done once and will take about 10 seconds..."); -async fn get_release_for_request( - update_service: &UpdateService, - request: &RequestedVersion, - platform: Platform, -) -> Result { - match request { - RequestedVersion::Version { - quality, - version: number, - } => update_service - .get_release_by_semver_version(platform, TargetKind::Archive, *quality, number) - .await - .map_err(|e| wrap(e, "Could not get release")), - RequestedVersion::Commit { commit, quality } => Ok(Release { - platform, - commit: commit.clone(), - quality: *quality, - name: "".to_string(), - target: TargetKind::Archive, - }), - RequestedVersion::Quality(quality) => update_service - .get_latest_commit(platform, TargetKind::Archive, *quality) - .await - .map_err(|e| wrap(e, "Could not get release")), - _ => panic!("cannot get release info for a path"), + let stdout = std::process::Command::new("system_profiler") + .args(["SPApplicationsDataType", "-detailLevel", "mini"]) + .output()? + .stdout; + + enum State { + LookingForName, + LookingForLocation, } -} -async fn install_release_into( - update_service: &UpdateService, - path: &Path, - release: &Release, -) -> Result<(), AnyError> { - let tempdir = - tempfile::tempdir().map_err(|e| wrap(e, "error creating temporary download dir"))?; - let save_path = tempdir.path().join("vscode"); - - let stream = update_service.get_download_stream(release).await?; - let pb = ProgressBar::new(1); - pb.set_message("Downloading..."); - let progress = ProgressBarReporter::from(pb); - http::download_into_file(&save_path, progress, stream).await?; - - let pb = ProgressBar::new(1); - pb.set_message("Unzipping..."); - let progress = ProgressBarReporter::from(pb); - unzip_downloaded_release(&save_path, path, progress)?; - - drop(tempdir); - - Ok(()) -} - -/// Tries to find the binary entrypoint for VS Code installed in the path. -async fn try_get_entrypoint(path: &Path) -> Option { - use tokio::sync::mpsc; - - let (tx, mut rx) = mpsc::channel(1); - - // Look for all the possible paths in parallel - for entry in DESKTOP_CLI_RELATIVE_PATH.split(',') { - let my_path = path.join(entry); - let my_tx = tx.clone(); - tokio::spawn(async move { - if tokio::fs::metadata(&my_path).await.is_ok() { - my_tx.send(my_path).await.ok(); + let mut state = State::LookingForName; + let mut output: Vec = vec![]; + const LOCATION_PREFIX: &str = "Location:"; + for mut line in String::from_utf8_lossy(&stdout).lines() { + line = line.trim(); + match state { + State::LookingForName => { + if line.starts_with(app_name) && line.ends_with(':') { + state = State::LookingForLocation; + } } - }); + State::LookingForLocation => { + if line.starts_with(LOCATION_PREFIX) { + output.push( + [ + &line[LOCATION_PREFIX.len()..].trim(), + "Contents/Resources", + "app", + "bin", + "code", + ] + .iter() + .collect(), + ); + state = State::LookingForName; + } + } + } } - drop(tx); // drop so rx gets None if no sender emits + // Sort shorter paths to the front, preferring "more global" installs, and + // incidentally preferring local installs over Parallels 'installs'. + output.sort_by(|a, b| a.as_os_str().len().cmp(&b.as_os_str().len())); - rx.recv().await + Ok(output) +} + +#[cfg(windows)] +fn detect_installed_program(_log: &log::Logger, quality: Quality) -> io::Result> { + use crate::constants::WIN32_APP_IDS; + use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE}; + use winreg::RegKey; + + let mut output: Vec = vec![]; + let app_ids = match WIN32_APP_IDS.as_ref().and_then(|m| m.get(&quality)) { + Some(ids) => ids, + None => return Ok(output), + }; + + let scopes = [ + ( + HKEY_LOCAL_MACHINE, + "SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall", + ), + ( + HKEY_LOCAL_MACHINE, + "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall", + ), + ( + HKEY_CURRENT_USER, + "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall", + ), + ]; + + for (scope, key) in scopes { + let cur_ver = match RegKey::predef(scope).open_subkey(key) { + Ok(k) => k, + Err(_) => continue, + }; + + for key in cur_ver.enum_keys().flatten() { + if app_ids.iter().any(|id| key.contains(id)) { + let sk = cur_ver.open_subkey(&key)?; + if let Ok(location) = sk.get_value::("InstallLocation") { + output.push( + [ + location.as_str(), + "bin", + match quality { + Quality::Exploration => "code-exploration.cmd", + Quality::Insiders => "code-insiders.cmd", + Quality::Stable => "code.cmd", + }, + ] + .iter() + .collect(), + ) + } + } + } + } + + Ok(output) +} + +// Looks for the given binary name in the PATH, returning all candidate matches. +// Based on https://github.dev/microsoft/vscode-js-debug/blob/7594d05518df6700df51771895fcad0ddc7f92f9/src/common/pathUtils.ts#L15 +#[cfg(target_os = "linux")] +fn detect_installed_program(log: &log::Logger, quality: Quality) -> io::Result> { + let path = match std::env::var("PATH") { + Ok(p) => p, + Err(e) => { + info!(log, "PATH is empty ({}), skipping detection", e); + return Ok(vec![]); + } + }; + + let name = quality.get_commandline_name(); + let current_exe = std::env::current_exe().expect("expected to read current exe"); + let mut output = vec![]; + for dir in path.split(':') { + let target: PathBuf = [dir, name].iter().collect(); + match std::fs::canonicalize(&target) { + Ok(m) if m == current_exe => continue, + Ok(_) => {}, + Err(_) => continue, + }; + + // note: intentionally store the non-canonicalized version, since if it's a + // symlink, (1) it's probably desired to use it and (2) resolving the link + // breaks snap installations. + output.push(target); + } + + Ok(output) } const DESKTOP_CLI_RELATIVE_PATH: &str = if cfg!(target_os = "macos") { @@ -347,7 +445,7 @@ mod tests { .expect("expected exe path"); let binary_file_path = if cfg!(target_os = "macos") { - path.join(format!("{}.app/{}", quality.get_app_name(), bin)) + path.join(format!("{}/{}", quality.get_macos_app_name(), bin)) } else { path.join(bin) }; @@ -369,6 +467,15 @@ mod tests { dir } + #[test] + fn test_detect_installed_program() { + // developers can run this test and debug output manually; VS Code will not + // be installed in CI, so the test only makes sure it doesn't error out + let result = detect_installed_program(&log::Logger::test(), Quality::Insiders); + println!("result: {:?}", result); + assert!(result.is_ok()); + } + #[test] fn test_requested_version_parses() { assert_eq!( @@ -424,45 +531,45 @@ mod tests { ); } - #[test] - fn test_set_preferred_version() { + #[tokio::test] + async fn test_set_preferred_version() { let dir = make_multiple_vscode_install(); let lp = LauncherPaths::new_without_replacements(dir.path().to_owned()); - let vm1 = CodeVersionManager::new(&lp, Platform::LinuxARM64); + let vm1 = CodeVersionManager::new(log::Logger::test(), &lp, Platform::LinuxARM64); assert_eq!( vm1.get_preferred_version(), RequestedVersion::Quality(options::Quality::Stable) ); - vm1.set_preferred_version(&RequestedVersion::Quality(options::Quality::Exploration)) - .expect("expected to store"); - vm1.set_preferred_version(&RequestedVersion::Quality(options::Quality::Insiders)) - .expect("expected to store"); + vm1.set_preferred_version( + RequestedVersion::Quality(options::Quality::Exploration), + dir.path().join("desktop/stable"), + ) + .await + .expect("expected to store"); + vm1.set_preferred_version( + RequestedVersion::Quality(options::Quality::Insiders), + dir.path().join("desktop/stable"), + ) + .await + .expect("expected to store"); assert_eq!( vm1.get_preferred_version(), RequestedVersion::Quality(options::Quality::Insiders) ); - let vm2 = CodeVersionManager::new(&lp, Platform::LinuxARM64); + let vm2 = CodeVersionManager::new(log::Logger::test(), &lp, Platform::LinuxARM64); assert_eq!( vm2.get_preferred_version(), RequestedVersion::Quality(options::Quality::Insiders) ); - - assert_eq!( - vm2.list(), - vec![ - RequestedVersion::Quality(options::Quality::Exploration), - RequestedVersion::Quality(options::Quality::Insiders) - ] - ); } #[tokio::test] async fn test_gets_entrypoint() { let dir = make_multiple_vscode_install(); let lp = LauncherPaths::new_without_replacements(dir.path().to_owned()); - let vm = CodeVersionManager::new(&lp, Platform::LinuxARM64); + let vm = CodeVersionManager::new(log::Logger::test(), &lp, Platform::LinuxARM64); assert!(vm .try_get_entrypoint(&RequestedVersion::Quality(options::Quality::Stable)) @@ -474,20 +581,4 @@ mod tests { .await .is_none()); } - - #[tokio::test] - async fn test_uninstall() { - let dir = make_multiple_vscode_install(); - let lp = LauncherPaths::new_without_replacements(dir.path().to_owned()); - let vm = CodeVersionManager::new(&lp, Platform::LinuxARM64); - - vm.uninstall(&RequestedVersion::Quality(options::Quality::Stable)) - .await - .expect("expected to uninsetall"); - - assert!(vm - .try_get_entrypoint(&RequestedVersion::Quality(options::Quality::Stable)) - .await - .is_none()); - } } diff --git a/cli/src/log.rs b/cli/src/log.rs index 296ccc5a3ae..d31e02fb3bf 100644 --- a/cli/src/log.rs +++ b/cli/src/log.rs @@ -5,8 +5,8 @@ use chrono::Local; use opentelemetry::{ - sdk::trace::Tracer, - trace::{SpanBuilder, Tracer as TraitTracer}, + sdk::trace::{Tracer, TracerProvider}, + trace::{SpanBuilder, Tracer as TraitTracer, TracerProvider as TracerProviderTrait}, }; use std::fmt; use std::{env, path::Path, sync::Arc}; @@ -186,6 +186,14 @@ impl LogSink for FileLogSink { } impl Logger { + pub fn test() -> Self { + Self { + tracer: TracerProvider::builder().build().tracer("codeclitest"), + sink: vec![], + prefix: None, + } + } + pub fn new(tracer: Tracer, level: Level) -> Self { Self { tracer, diff --git a/cli/src/options.rs b/cli/src/options.rs index 80e6c03d7c7..4493c244e8d 100644 --- a/cli/src/options.rs +++ b/cli/src/options.rs @@ -7,7 +7,7 @@ use std::fmt; use serde::{Deserialize, Serialize}; -#[derive(clap::ArgEnum, Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(clap::ArgEnum, Copy, Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] pub enum Quality { #[serde(rename = "stable")] Stable, @@ -36,14 +36,22 @@ impl Quality { } } - pub fn get_app_name(&self) -> &'static str { + pub fn get_macos_app_name(&self) -> &'static str { match self { - Quality::Insiders => "Visual Studio Code Insiders", - Quality::Exploration => "Visual Studio Code Exploration", + Quality::Insiders => "Visual Studio Code - Insiders", + Quality::Exploration => "Visual Studio Code - Exploration", Quality::Stable => "Visual Studio Code", } } + pub fn get_commandline_name(&self) -> &'static str { + match self { + Quality::Insiders => "code-insiders", + Quality::Exploration => "code-exploration", + Quality::Stable => "code", + } + } + #[cfg(target_os = "windows")] pub fn server_entrypoint(&self) -> &'static str { match self { diff --git a/cli/src/tunnels/service_windows.rs b/cli/src/tunnels/service_windows.rs index ee467bdccb9..4c2b1c8d950 100644 --- a/cli/src/tunnels/service_windows.rs +++ b/cli/src/tunnels/service_windows.rs @@ -6,7 +6,7 @@ use dialoguer::{theme::ColorfulTheme, Input, Password}; use lazy_static::lazy_static; use std::{ffi::OsString, sync::Mutex, thread, time::Duration}; -use tokio::sync::oneshot; +use tokio::sync::mpsc; use windows_service::{ define_windows_service, service::{ @@ -18,7 +18,7 @@ use windows_service::{ service_manager::{ServiceManager, ServiceManagerAccess}, }; -use crate::util::errors::{wrap, AnyError, WindowsNeedsElevation}; +use crate::{util::errors::{wrap, AnyError, WindowsNeedsElevation}, commands::tunnels::ShutdownSignal}; use crate::{ log::{self, FileLogSink}, state::LauncherPaths, @@ -203,7 +203,7 @@ fn service_main(_arguments: Vec) -> Result<(), AnyError> { let mut service = SERVICE_IMPL.lock().unwrap().take().unwrap(); // Create a channel to be able to poll a stop event from the service worker loop. - let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let (shutdown_tx, shutdown_rx) = mpsc::channel(1); let mut shutdown_tx = Some(shutdown_tx); // Define system service event handler that will be receiving service events. @@ -211,7 +211,7 @@ fn service_main(_arguments: Vec) -> Result<(), AnyError> { match control_event { ServiceControl::Interrogate => ServiceControlHandlerResult::NoError, ServiceControl::Stop => { - shutdown_tx.take().and_then(|tx| tx.send(()).ok()); + shutdown_tx.take().and_then(|tx| tx.blocking_send(ShutdownSignal::CtrlC).ok()); ServiceControlHandlerResult::NoError } diff --git a/cli/src/util/is_integrated.rs b/cli/src/util/is_integrated.rs index ac38e0cf107..2bc87f47962 100644 --- a/cli/src/util/is_integrated.rs +++ b/cli/src/util/is_integrated.rs @@ -20,7 +20,7 @@ pub fn is_integrated_cli() -> io::Result { None => return Ok(false), }; - let expected_file = if cfg!(target_os = "darwin") { + let expected_file = if cfg!(target_os = "macos") { "node_modules.asar" } else { "resources.pak"