diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 517e1cef3..ce009dfb7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -48,6 +48,8 @@ jobs: - run: pnpm run build - name: Verify managed jgd preparation run: pnpm run test:prepare-jgd + - name: Verify managed rstudioapi preparation + run: pnpm run test:prepare-rstudioapi - name: Verify installed sess identity run: | source("R/sess_source.R") diff --git a/R/attach_sess.R b/R/attach_sess.R index 500c6dd40..54d107483 100644 --- a/R/attach_sess.R +++ b/R/attach_sess.R @@ -1,9 +1,26 @@ # Shared terminal and manual-attach preparation. Installation requires a # single-use grant from the extension process; .libPaths() is never changed. + +# Source the tool preparation helper into a fresh environment. Returns NULL +# after reporting the failure when it cannot be loaded. +vscode_r_load_prepare_helper <- function(helper, package) { + setup <- new.env(parent = environment(vscode_r_load_prepare_helper)) + loaded <- nzchar(helper) && file.exists(helper) && + isTRUE(tryCatch({ + sys.source(helper, envir = setup) + TRUE + }, error = function(error) { + message("vscode-R could not load managed ", package, " setup resources: ", + conditionMessage(error)) + FALSE + })) + if (loaded) setup else NULL +} + vscode_r_with_jgd <- function( plot_backend, connect, - jgd_helper = Sys.getenv("VSCODE_R_JGD_HELPER", unset = ""), + tool_prepare_helper = Sys.getenv("VSCODE_R_TOOL_PREPARE_HELPER", unset = ""), tool_root = Sys.getenv("VSCODE_R_TOOL_ROOT", unset = ""), tool_consent_directory = Sys.getenv("VSCODE_R_TOOL_CONSENT_DIRECTORY", unset = ""), tool_library_helper = Sys.getenv("VSCODE_R_TOOL_LIBRARY_HELPER", unset = ""), @@ -15,17 +32,8 @@ vscode_r_with_jgd <- function( if (!plot_backend %in% c("jgd", "auto")) { return(connect(allow_jgd = TRUE)) } - jgd_setup <- new.env(parent = environment()) - loaded <- nzchar(jgd_helper) && file.exists(jgd_helper) && - isTRUE(tryCatch({ - sys.source(jgd_helper, envir = jgd_setup) - TRUE - }, error = function(error) { - message("vscode-R could not load managed jgd setup resources: ", - conditionMessage(error)) - FALSE - })) - if (loaded && is.function(jgd_setup$vscode_r_connect_with_jgd)) { + jgd_setup <- vscode_r_load_prepare_helper(tool_prepare_helper, "jgd") + if (!is.null(jgd_setup) && is.function(jgd_setup$vscode_r_connect_with_jgd)) { return(jgd_setup$vscode_r_connect_with_jgd( plot_backend, connect, tool_root, tool_consent_directory, tool_library_helper, tool_consent_helper, can_prompt, minimum_version @@ -35,6 +43,33 @@ vscode_r_with_jgd <- function( connect(allow_jgd = FALSE) } +# Prepare rstudioapi before sess starts its emulation. Declining or failing only +# prints a message; sess emulation stays enabled and patches rstudioapi +# whenever it is loaded. Does nothing when emulation is disabled. +vscode_r_with_rstudioapi <- function( + use_rstudioapi, + tool_prepare_helper = Sys.getenv("VSCODE_R_TOOL_PREPARE_HELPER", unset = ""), + tool_root = Sys.getenv("VSCODE_R_TOOL_ROOT", unset = ""), + tool_consent_directory = Sys.getenv("VSCODE_R_TOOL_CONSENT_DIRECTORY", unset = ""), + tool_library_helper = Sys.getenv("VSCODE_R_TOOL_LIBRARY_HELPER", unset = ""), + tool_consent_helper = Sys.getenv("VSCODE_R_TOOL_CONSENT_HELPER", unset = ""), + can_prompt = identical(Sys.getenv("VSCODE_R_RSTUDIOAPI_CAN_PROMPT", unset = "TRUE"), "TRUE"), + minimum_version = Sys.getenv("VSCODE_R_RSTUDIOAPI_MINIMUM_VERSION", unset = "") +) { + if (identical(use_rstudioapi, FALSE)) { + return(invisible(FALSE)) + } + setup <- vscode_r_load_prepare_helper(tool_prepare_helper, "rstudioapi") + if (!is.null(setup) && is.function(setup$vscode_r_prepare_rstudioapi)) { + return(invisible(isTRUE(setup$vscode_r_prepare_rstudioapi( + use_rstudioapi, tool_root, tool_consent_directory, + tool_library_helper, tool_consent_helper, can_prompt, minimum_version + )))) + } + message("vscode-R could not load managed rstudioapi setup resources; continuing without it.") + invisible(FALSE) +} + vscode_r_prepare_sess <- function(pkg_path, managed_root, consent_dir, source_helper, installer_helper, timeout_seconds = 180, @@ -231,10 +266,16 @@ vscode_r_attach_sess <- function(endpoint, pkg_path, managed_root, consent_dir, tool_consent_helper = Sys.getenv( "VSCODE_R_TOOL_CONSENT_HELPER", unset = "" ), - jgd_helper = Sys.getenv("VSCODE_R_JGD_HELPER", unset = ""), + tool_prepare_helper = Sys.getenv( + "VSCODE_R_TOOL_PREPARE_HELPER", unset = "" + ), jgd_can_prompt = TRUE, jgd_minimum_version = Sys.getenv( "VSCODE_R_JGD_MINIMUM_VERSION", unset = "" + ), + rstudioapi_can_prompt = TRUE, + rstudioapi_minimum_version = Sys.getenv( + "VSCODE_R_RSTUDIOAPI_MINIMUM_VERSION", unset = "" )) { if (!is.null(attachment_id) && !(is.character(attachment_id) && length(attachment_id) == 1L && @@ -291,13 +332,18 @@ vscode_r_attach_sess <- function(endpoint, pkg_path, managed_root, consent_dir, args$attachment_id <- attachment_id args$auto_activate <- auto_activate } + vscode_r_with_rstudioapi( + use_rstudioapi, tool_prepare_helper, tool_root, tool_consent_directory, + tool_library_helper, tool_consent_helper, rstudioapi_can_prompt, + rstudioapi_minimum_version + ) vscode_r_with_jgd( plot_backend, function(allow_jgd = TRUE) { args$allow_jgd <- allow_jgd do.call(connect, args) }, - jgd_helper, tool_root, tool_consent_directory, + tool_prepare_helper, tool_root, tool_consent_directory, tool_library_helper, tool_consent_helper, jgd_can_prompt, jgd_minimum_version ) } diff --git a/R/prepare-jgd.R b/R/prepare-tools.R similarity index 50% rename from R/prepare-jgd.R rename to R/prepare-tools.R index ca5a8b80f..dc1e3aeae 100644 --- a/R/prepare-jgd.R +++ b/R/prepare-tools.R @@ -20,68 +20,127 @@ vscode_r_connect_with_jgd <- function( connect(allow_jgd = ready) } -# Prepare optional plot support without making sess startup depend on CRAN access -# or user consent. The managed library is loaded before sess checks the package. -vscode_r_prepare_jgd <- function( - plot_backend, - tool_root = Sys.getenv("VSCODE_R_TOOL_ROOT", unset = ""), - consent_directory = Sys.getenv("VSCODE_R_TOOL_CONSENT_DIRECTORY", unset = ""), - library_helper = Sys.getenv("VSCODE_R_TOOL_LIBRARY_HELPER", unset = ""), - consent_helper = Sys.getenv("VSCODE_R_TOOL_CONSENT_HELPER", unset = ""), - can_prompt = identical(Sys.getenv("VSCODE_R_JGD_CAN_PROMPT", unset = "TRUE"), "TRUE"), - minimum_version = Sys.getenv("VSCODE_R_JGD_MINIMUM_VERSION", unset = "") +# Select, install if approved, and eagerly load one tool package. Returns TRUE +# when a compatible copy is loaded, otherwise prints a short message and +# returns FALSE so the caller can continue with its fallback. `policy` is the +# consent policy sent to the extension ("always" or "once"); `fallback` and +# `support` only word the messages. An already loaded namespace is never replaced. +vscode_r_prepare_tool_package <- function( + package, + policy, + tool_root, + consent_directory, + library_helper, + consent_helper, + can_prompt, + minimum_version, + support, + fallback, + report_load_failure = TRUE ) { - if (!is.character(plot_backend) || length(plot_backend) != 1L || - is.na(plot_backend) || !plot_backend %in% c("jgd", "auto")) { - return(FALSE) - } if (!(is.logical(can_prompt) && length(can_prompt) == 1L && !is.na(can_prompt))) { stop("can_prompt must be a single non-NA logical value.", call. = FALSE) } helpers <- c(library_helper, consent_helper) if (any(!nzchar(helpers)) || any(!file.exists(helpers)) || !nzchar(tool_root)) { - message("vscode-R could not locate managed jgd setup resources; using the available plot fallback.") + message("vscode-R could not locate managed ", package, " setup resources; ", fallback, ".") return(FALSE) } tryCatch({ - tool_environment <- new.env(parent = environment(vscode_r_prepare_jgd)) + tool_environment <- new.env(parent = environment(vscode_r_prepare_tool_package)) for (helper in helpers) { sys.source(helper, envir = tool_environment) } if (!can_prompt) { # A compatible ordinary or managed copy remains usable without resolving a new plan. return(!is.null(tool_environment$tool_library_load( - "jgd", minimum_version, tool_root + package, minimum_version, tool_root ))) } prepared <- tool_environment$tool_library_prepare( - "jgd", + package, minimum_version, tool_root, consent_directory, - plot_backend, + policy, consent_timeout_seconds = 180, install_timeout_seconds = 300, resolution_timeout_seconds = 30 ) if (is.null(prepared)) { - message("vscode-R optional jgd installation was not approved; using the available plot fallback.") + message("vscode-R optional ", package, " installation was not approved; ", fallback, ".") return(FALSE) } namespace <- tool_environment$tool_library_load( - "jgd", minimum_version, tool_root + package, minimum_version, tool_root ) if (is.null(namespace)) { - message("vscode-R could not load a compatible jgd package; using the available plot fallback.") + if (report_load_failure) { + message("vscode-R could not load a compatible ", package, " package; ", fallback, ".") + } return(FALSE) } TRUE }, error = function(error) { - message("vscode-R could not prepare optional jgd plot support: ", conditionMessage(error)) + message("vscode-R could not prepare optional ", support, ": ", conditionMessage(error)) FALSE }, interrupt = function(error) { - message("vscode-R jgd preparation was interrupted; using the available plot fallback.") + message("vscode-R ", package, " preparation was interrupted; ", fallback, ".") FALSE }) } + +# Prepare optional plot support without making sess startup depend on CRAN access +# or user consent. The managed library is loaded before sess checks the package. +vscode_r_prepare_jgd <- function( + plot_backend, + tool_root = Sys.getenv("VSCODE_R_TOOL_ROOT", unset = ""), + consent_directory = Sys.getenv("VSCODE_R_TOOL_CONSENT_DIRECTORY", unset = ""), + library_helper = Sys.getenv("VSCODE_R_TOOL_LIBRARY_HELPER", unset = ""), + consent_helper = Sys.getenv("VSCODE_R_TOOL_CONSENT_HELPER", unset = ""), + can_prompt = identical(Sys.getenv("VSCODE_R_JGD_CAN_PROMPT", unset = "TRUE"), "TRUE"), + minimum_version = Sys.getenv("VSCODE_R_JGD_MINIMUM_VERSION", unset = "") +) { + if (!is.character(plot_backend) || length(plot_backend) != 1L || + is.na(plot_backend) || !plot_backend %in% c("jgd", "auto")) { + return(FALSE) + } + # An explicit jgd backend is offered installation on every request; auto only once. + vscode_r_prepare_tool_package( + "jgd", + if (identical(plot_backend, "jgd")) "always" else "once", + tool_root, consent_directory, library_helper, consent_helper, + can_prompt, minimum_version, + support = "jgd plot support", + fallback = "using the available plot fallback" + ) +} + +# Make rstudioapi available before sess starts its emulation, so that +# requireNamespace("rstudioapi") succeeds even in isolated project libraries. +# Emulation stays enabled whatever the outcome: sess patches rstudioapi when it +# is loaded now or later. Does nothing when emulation is disabled. +vscode_r_prepare_rstudioapi <- function( + use_rstudioapi, + tool_root = Sys.getenv("VSCODE_R_TOOL_ROOT", unset = ""), + consent_directory = Sys.getenv("VSCODE_R_TOOL_CONSENT_DIRECTORY", unset = ""), + library_helper = Sys.getenv("VSCODE_R_TOOL_LIBRARY_HELPER", unset = ""), + consent_helper = Sys.getenv("VSCODE_R_TOOL_CONSENT_HELPER", unset = ""), + can_prompt = identical(Sys.getenv("VSCODE_R_RSTUDIOAPI_CAN_PROMPT", unset = "TRUE"), "TRUE"), + minimum_version = Sys.getenv("VSCODE_R_RSTUDIOAPI_MINIMUM_VERSION", unset = "") +) { + # sess treats an unset (NA) value as enabled. + if (identical(use_rstudioapi, FALSE)) { + return(FALSE) + } + vscode_r_prepare_tool_package( + "rstudioapi", "once", + tool_root, consent_directory, library_helper, consent_helper, + can_prompt, minimum_version, + support = "rstudioapi support", + fallback = "continuing without it", + # An incompatible loaded namespace is already reported by the loader. + report_load_failure = FALSE + ) +} diff --git a/R/profile.R b/R/profile.R index 937402003..b8b4a6d9e 100644 --- a/R/profile.R +++ b/R/profile.R @@ -52,6 +52,7 @@ invisible(local({ plot_backend <- Sys.getenv("SESS_PLOT_BACKEND", "auto") connect <- get("connect", envir = ns, inherits = FALSE) use_rstudioapi <- as.logical(Sys.getenv("SESS_RSTUDIOAPI", "TRUE")) + vscode_r_with_rstudioapi(use_rstudioapi) result <- vscode_r_with_jgd(plot_backend, function(allow_jgd = TRUE) { connect(use_rstudioapi = use_rstudioapi, plot_backend = plot_backend, allow_jgd = allow_jgd) diff --git a/R/sess-package-install.R b/R/sess-package-install.R index 47b229f00..6dedb45f4 100644 --- a/R/sess-package-install.R +++ b/R/sess-package-install.R @@ -51,6 +51,9 @@ sess_install <- function(pkg_path, library, repos, interactive = FALSE) { deps <- if ("Imports" %in% colnames(description)) description[1L, "Imports"] else "" deps <- trimws(gsub("\\s*\\(.*\\)", "", unlist(strsplit(deps, ",")))) + # The private Interactive runtime loads rstudioapi itself. Ordinary terminals + # obtain it on demand from the extension's tool library instead. + if (interactive) deps <- c(deps, "rstudioapi") installed <- utils::installed.packages(lib.loc = unique(c(library, .libPaths()))) missing <- setdiff(deps[nzchar(deps)], rownames(installed)) if (length(missing)) { diff --git a/R/sess_source.R b/R/sess_source.R index 499470d16..0d3f7e50d 100644 --- a/R/sess_source.R +++ b/R/sess_source.R @@ -79,7 +79,7 @@ sess_load_namespace <- function(library, revision, normal_libraries = .libPaths( if ("processx" %in% imports) { loadNamespace("ps", lib.loc = support_paths) } - for (package in intersect(c("jsonlite", "later", "processx", "rstudioapi"), imports)) { + for (package in intersect(c("jsonlite", "later", "processx"), imports)) { loadNamespace(package, lib.loc = support_paths) } ns <- loadNamespace("sess", lib.loc = paths) diff --git a/R/tests/prepare-jgd.R b/R/tests/prepare-jgd.R index c06180a89..5381c366b 100644 --- a/R/tests/prepare-jgd.R +++ b/R/tests/prepare-jgd.R @@ -97,8 +97,8 @@ local({ }, add = TRUE) installed <- utils::installed.packages(lib.loc = .libPaths()) runtime_dependencies <- unique(c( - "processx", "later", "jsonlite", "rstudioapi", - unlist(tools::package_dependencies(c("processx", "later", "jsonlite", "rstudioapi"), + "processx", "later", "jsonlite", + unlist(tools::package_dependencies(c("processx", "later", "jsonlite"), db = installed, which = c("Depends", "Imports"), recursive = TRUE), use.names = FALSE) )) @@ -133,7 +133,7 @@ local({ "stopifnot(!ambient %in% normalizePath(.libPaths(), winslash='/'));", "source(file.path(getwd(), 'R', 'tool-library.R'));", "source(file.path(getwd(), 'R', 'tool-consent.R'));", - "source(file.path(getwd(), 'R', 'prepare-jgd.R'));", + "source(file.path(getwd(), 'R', 'prepare-tools.R'));", "source(file.path(getwd(), 'R', 'attach_sess.R'));", "options(repos=c(CRAN=repo), pkgType='source');", "consent <- file.path(root, 'consent'); dir.create(consent, showWarnings=FALSE);", @@ -170,11 +170,12 @@ local({ "}", "timeout_before <- getOption('timeout'); libraries_before <- .libPaths();", "answer <- vscode_r_attach_sess('endpoint', 'unused', file.path(root, 'tools'), consent,", - " 'unused', 'unused', 'jgd', timeout_seconds=0, tool_root=file.path(root, 'tools'),", + " 'unused', 'unused', 'jgd', timeout_seconds=0, use_rstudioapi=FALSE,", + " tool_root=file.path(root, 'tools'),", " tool_consent_directory=consent, tool_library_helper=tool_library_helper,", " tool_consent_helper=file.path(getwd(), 'R', 'tool-consent.R'),", - " jgd_helper=if (mode == 'helper-missing') file.path(root, 'missing-prepare-jgd.R') else", - " file.path(getwd(), 'R', 'prepare-jgd.R'),", + " tool_prepare_helper=if (mode == 'helper-missing') file.path(root, 'missing-prepare-tools.R') else", + " file.path(getwd(), 'R', 'prepare-tools.R'),", " jgd_can_prompt=!(mode %in% c('no-prompt', 'no-prompt-compatible', 'socket-empty')),", " jgd_minimum_version='0.1.0');", "stopifnot(isTRUE(answer), identical(Sys.getenv('JGD_SOCKET'), 'fixture-socket'));", @@ -230,7 +231,7 @@ local({ "stopifnot(!ambient %in% normalizePath(.libPaths(), winslash='/'));", "source(file.path(getwd(), 'R', 'tool-library.R'));", "source(file.path(getwd(), 'R', 'tool-consent.R'));", - "source(file.path(getwd(), 'R', 'prepare-jgd.R'));", + "source(file.path(getwd(), 'R', 'prepare-tools.R'));", "source(file.path(getwd(), 'R', 'attach_sess.R'));", "options(repos=c(CRAN=repo), pkgType='source', sess.quiet=TRUE);", "sess_ns <- loadNamespace('sess', lib.loc=sess_library);", @@ -263,7 +264,7 @@ local({ "answer <- vscode_r_with_jgd('jgd', function(allow_jgd=TRUE)", " connect_env$connect(endpoint=endpoint, use_rstudioapi=FALSE,", " plot_backend='jgd', allow_jgd=allow_jgd),", - " jgd_helper=file.path(getwd(), 'R', 'prepare-jgd.R'),", + " tool_prepare_helper=file.path(getwd(), 'R', 'prepare-tools.R'),", " tool_root=file.path(root, paste0(mode, '-tools')),", " tool_consent_directory=file.path(root, 'consent'),", " tool_library_helper=file.path(getwd(), 'R', 'tool-library.R'),", @@ -472,7 +473,7 @@ local({ " VSCODE_R_SESS_STARTUP_TOKEN='0123456789abcdef0123456789abcdef',", " VSCODE_R_SESS_STARTUP_ENDPOINT='fixture-endpoint',", paste0(" VSCODE_R_SESS_STARTUP_HELPER=", r_literal(file.path(getwd(), "R", "terminal-startup.R")), ","), - paste0(" VSCODE_R_JGD_HELPER=", r_literal(file.path(getwd(), "R", "prepare-jgd.R")), ","), + paste0(" VSCODE_R_TOOL_PREPARE_HELPER=", r_literal(file.path(getwd(), "R", "prepare-tools.R")), ","), paste0(" VSCODE_R_TOOL_ROOT=", r_literal(file.path(profile_root, "tools")), ","), paste0(" VSCODE_R_TOOL_CONSENT_DIRECTORY=", r_literal(profile_consent), ","), paste0(" VSCODE_R_TOOL_LIBRARY_HELPER=", r_literal(file.path(getwd(), "R", "tool-library.R")), ","), @@ -480,7 +481,7 @@ local({ " VSCODE_R_JGD_CAN_PROMPT='TRUE',", " VSCODE_R_JGD_MINIMUM_VERSION='0.1.0',", paste0(" R_PROFILE_USER_OLD=", r_literal(empty_user_profile), ","), - " SESS_PLOT_BACKEND='jgd', JGD_SOCKET='fixture-socket')", + " SESS_PLOT_BACKEND='jgd', SESS_RSTUDIOAPI='FALSE', JGD_SOCKET='fixture-socket')", paste0("options(repos=c(CRAN=", r_literal(repo), "), pkgType='source')"), "source(file.path(getwd(), 'R', 'profile.R'))" ), startup_profile) diff --git a/R/tests/prepare-rstudioapi.R b/R/tests/prepare-rstudioapi.R new file mode 100644 index 000000000..1bf403426 --- /dev/null +++ b/R/tests/prepare-rstudioapi.R @@ -0,0 +1,312 @@ +# Base-R integration coverage for optional rstudioapi preparation during attach +# and terminal startup. All packages are local fixtures served from a file repository. +local({ + root <- tempfile("prepare-rstudioapi-") + dir.create(root) + root <- normalizePath(root, winslash = "/", mustWork = TRUE) + on.exit(unlink(root, recursive = TRUE), add = TRUE) + r_literal <- function(value) paste0("\"", gsub("\\\"", "\\\\\\\"", value), "\"") + r_binary <- file.path(R.home("bin"), "R") + minimum_version <- "0.4.0" + ambient_library <- file.path(root, "ambient-user-library") + dir.create(ambient_library) + isolated_child_env <- function(label) { + user_library <- file.path(root, "isolated-libraries", label, "user") + site_library <- file.path(root, "isolated-libraries", label, "site") + dir.create(user_library, recursive = TRUE, showWarnings = FALSE) + dir.create(site_library, recursive = TRUE, showWarnings = FALSE) + c(R_LIBS_USER = user_library, R_LIBS_SITE = site_library, R_LIBS = "") + } + system2_with_child_environment <- function(environment, command, args, ...) { + keys <- names(environment) + previous <- Sys.getenv(keys, unset = NA_character_) + on.exit({ + unset <- keys[is.na(previous)] + if (length(unset)) Sys.unsetenv(unset) + restore <- previous[!is.na(previous)] + if (length(restore)) do.call(Sys.setenv, as.list(restore)) + }, add = TRUE) + do.call(Sys.setenv, as.list(environment)) + system2(command, args, ...) + } + + # Fixtures: rstudioapi 0.4.0 is published, 0.3.1 exists only as a local source + # directory below the minimum. jgd 0.1.0 is published for the combined startup. + fixture_package <- function(package, version, publish) { + directory <- file.path(root, "fixtures", paste0(package, "-", version)) + dir.create(file.path(directory, "R"), recursive = TRUE) + writeLines(c( + paste0("Package: ", package), paste0("Version: ", version), + paste0("Title: ", package, " fixture"), + paste0("Description: Local ", package, " fixture."), "License: MIT", + "Author: Test Author", "Maintainer: Test Author ", + "Encoding: UTF-8" + ), file.path(directory, "DESCRIPTION")) + writeLines("fixture_value <- function() 'fixture'", file.path(directory, "R", "api.R")) + writeLines("export(fixture_value)", file.path(directory, "NAMESPACE")) + if (publish) { + previous <- setwd(dirname(directory)) + on.exit(setwd(previous), add = TRUE) + output <- system2_with_child_environment( + isolated_child_env(paste0("build-", package, "-", version)), r_binary, + c("CMD", "build", "--no-build-vignettes", shQuote(basename(directory))), + stdout = TRUE, stderr = TRUE + ) + status <- attr(output, "status") + if (!is.null(status) && status != 0L) stop(paste(output, collapse = "\n")) + contrib <- file.path(root, "repo", "src", "contrib") + dir.create(contrib, recursive = TRUE, showWarnings = FALSE) + archive <- file.path(dirname(directory), paste0(package, "_", version, ".tar.gz")) + stopifnot(file.copy(archive, contrib)) + } + directory + } + fixture_package("rstudioapi", minimum_version, publish = TRUE) + fixture_package("rstudioapi", "0.3.1", publish = FALSE) + fixture_package("jgd", "0.1.0", publish = TRUE) + tools::write_PACKAGES(file.path(root, "repo", "src", "contrib"), type = "source") + repo <- paste0("file:///", sub("^/", "", normalizePath(file.path(root, "repo"), winslash = "/"))) + fixtures <- file.path(root, "fixtures") + + # The child records what sess would observe when connect() runs. + runner <- file.path(root, "run-attach.R") + writeLines(c( + "args <- commandArgs(TRUE); mode <- args[[1L]]; root <- args[[2L]]; repo <- args[[3L]];", + "result <- args[[4L]]; fixtures <- args[[5L]];", + "source(file.path(getwd(), 'R', 'tool-library.R'));", + "source(file.path(getwd(), 'R', 'tool-consent.R'));", + "source(file.path(getwd(), 'R', 'attach_sess.R'));", + "options(repos=c(CRAN=repo), pkgType='source');", + "if (mode == 'offline') options(repos=c(CRAN=file.path(root, 'missing-repo')));", + "work <- file.path(root, mode);", + "consent <- file.path(work, 'consent'); dir.create(consent);", + "install_fixture <- function(name, label) {", + " library <- file.path(work, label); dir.create(library);", + " utils::install.packages(file.path(fixtures, name), repos=NULL, type='source',", + " lib=library, quiet=TRUE);", + " .libPaths(c(library, .libPaths())); library", + "};", + "if (mode %in% c('no-prompt-compatible', 'loaded-compatible'))", + " loaded_from <- install_fixture('rstudioapi-0.4.0', 'ordinary-library');", + "if (mode == 'loaded-compatible') loadNamespace('rstudioapi', lib.loc=loaded_from);", + "if (mode == 'loaded-incompatible') {", + " loaded_from <- install_fixture('rstudioapi-0.3.1', 'old-library');", + " loadNamespace('rstudioapi', lib.loc=loaded_from);", + "}", + # Record metadata resolution without changing what the helper does. + "marker <- file.path(work, 'plan-called');", + "instrumented <- file.path(work, 'tool-library.R');", + "writeLines(c(readLines(file.path(getwd(), 'R', 'tool-library.R'), warn=FALSE),", + " 'tool_library_plan_original <- tool_library_plan',", + " paste0('tool_library_plan <- function(...) { file.create(',", + " deparse(marker), '); tool_library_plan_original(...) }')), instrumented);", + "seen <- new.env();", + "vscode_r_prepare_sess <- function(...) { ns <- new.env();", + " ns$connect <- function(allow_jgd=TRUE, ...) {", + " seen$called <- TRUE; seen$allow_jgd <- allow_jgd;", + " seen$rstudioapi <- 'rstudioapi' %in% loadedNamespaces();", + " seen$jgd <- 'jgd' %in% loadedNamespaces();", + " seen$path <- if (seen$rstudioapi)", + " normalizePath(getNamespaceInfo('rstudioapi', 'path'), winslash='/') else '';", + " seen$version <- if (seen$rstudioapi)", + " as.character(getNamespaceVersion(asNamespace('rstudioapi'))) else '';", + " seen$require <- requireNamespace('rstudioapi', quietly=TRUE);", + " TRUE", + " }; ns };", + "libraries_before <- .libPaths();", + "can_prompt <- !(mode %in% c('no-prompt', 'no-prompt-compatible'));", + "plot_backend <- switch(mode, both='auto', 'both-explicit'='jgd', 'standard');", + "answer <- vscode_r_attach_sess('endpoint', 'unused', file.path(work, 'managed'), consent,", + " 'unused', 'unused', plot_backend, timeout_seconds=0,", + " use_rstudioapi=mode != 'disabled',", + " tool_root=file.path(work, 'tools'), tool_consent_directory=consent,", + " tool_library_helper=instrumented,", + " tool_consent_helper=file.path(getwd(), 'R', 'tool-consent.R'),", + " tool_prepare_helper=file.path(getwd(), 'R', 'prepare-tools.R'),", + " jgd_can_prompt=can_prompt, jgd_minimum_version='0.1.0',", + " rstudioapi_can_prompt=can_prompt, rstudioapi_minimum_version='0.4.0');", + "stopifnot(isTRUE(answer), isTRUE(seen$called), identical(.libPaths(), libraries_before));", + "tool_path <- tool_library_path(file.path(work, 'tools'));", + "writeLines(c(as.character(seen$rstudioapi), seen$path, seen$version,", + " as.character(seen$require), as.character(seen$jgd), as.character(seen$allow_jgd),", + " as.character(file.exists(marker)), tool_path), result)" + ), runner) + + read_child_logs <- function(logs, timeout_seconds = 10) { + deadline <- Sys.time() + timeout_seconds + repeat { + output <- lapply(logs, function(path) { + suppressWarnings(tryCatch(readLines(path, warn = FALSE), + error = function(error) error)) + }) + failed <- vapply(output, inherits, FALSE, "error") + if (!any(failed)) return(unlist(output)) + if (Sys.time() >= deadline) { + stop(output[[which(failed)[[1L]]]]) + } + # Publishing a result does not close redirected stdout/stderr. Windows + # can deny read access until the asynchronous child releases them. + Sys.sleep(0.02) + } + } + + # Answer consent requests by package until the child publishes its result. + serve <- function(consent, result, answers, logs) { + seen <- list() + handled <- character() + deadline <- Sys.time() + 90 + repeat { + for (path in list.files(consent, pattern = "\\.request$", full.names = TRUE)) { + id <- sub("\\.request$", "", basename(path)) + if (id %in% handled) next + handled <- c(handled, id) + lines <- readLines(path, warn = FALSE) + seen[[length(seen) + 1L]] <- lines + answer <- answers[[lines[[6L]]]] + if (is.null(answer)) answer <- "decline" + temporary <- file.path(consent, paste0(id, ".response.tmp")) + writeLines(answer, temporary, useBytes = TRUE) + stopifnot(file.rename(temporary, file.path(consent, paste0(id, ".response")))) + } + if (file.exists(result)) return(seen) + if (Sys.time() >= deadline) { + output <- tryCatch(read_child_logs(logs, timeout_seconds = 0), + error = conditionMessage) + stop("Timed out waiting for the attach child: ", paste(output, collapse = "\n")) + } + Sys.sleep(0.02) + } + } + + run_mode <- function(mode, answers = list()) { + result <- file.path(root, paste0(mode, "-result")) + logs <- file.path(root, paste0(mode, c("-out", "-err"))) + dir.create(file.path(root, mode), showWarnings = FALSE) + args <- c("--vanilla", "--slave", paste0("--file=", runner), "--args", + mode, root, repo, result, fixtures) + system2_with_child_environment(isolated_child_env(mode), r_binary, shQuote(args), + wait = FALSE, stdout = logs[[1L]], stderr = logs[[2L]]) + requests <- serve(file.path(root, mode, "consent"), result, answers, logs) + output <- read_child_logs(logs) + list(values = readLines(result, warn = FALSE), requests = requests, + messages = grep("^vscode-R|incompatible", output, value = TRUE)) + } + request_summary <- function(request) request[c(4L, 6L, 7L)] + + approved <- run_mode("approved", list(rstudioapi = "approve")) + tool_package <- file.path(approved$values[[8L]], "rstudioapi") + stopifnot(length(approved$requests) == 1L, + identical(request_summary(approved$requests[[1L]]), + c("once", "rstudioapi", minimum_version)), + identical(approved$values[1:7], + c("TRUE", normalizePath(tool_package, winslash = "/"), minimum_version, + "TRUE", "FALSE", "TRUE", "TRUE")), + length(approved$messages) == 0L) + + declined <- run_mode("declined") + stopifnot(length(declined$requests) == 1L, + identical(declined$values[c(1L, 4L, 7L)], c("FALSE", "FALSE", "TRUE")), + length(declined$messages) == 1L, + grepl("rstudioapi installation was not approved", declined$messages)) + stopifnot(!dir.exists(file.path(declined$values[[8L]], "rstudioapi"))) + + offline <- run_mode("offline") + stopifnot(length(offline$requests) == 0L, + identical(offline$values[c(1L, 4L)], c("FALSE", "FALSE")), + length(offline$messages) == 1L, + grepl("could not prepare optional rstudioapi support", offline$messages)) + + # Emulation disabled: no request, no repository query, no message. + disabled <- run_mode("disabled", list(rstudioapi = "approve")) + stopifnot(length(disabled$requests) == 0L, + identical(disabled$values[c(1L, 4L, 7L)], c("FALSE", "FALSE", "FALSE")), + length(disabled$messages) == 0L) + + # Without a prompt only an existing compatible copy is used, silently and + # without contacting repositories. + no_prompt <- run_mode("no-prompt") + stopifnot(length(no_prompt$requests) == 0L, + identical(no_prompt$values[c(1L, 4L, 7L)], c("FALSE", "FALSE", "FALSE")), + length(no_prompt$messages) == 0L) + compatible <- run_mode("no-prompt-compatible") + stopifnot(length(compatible$requests) == 0L, + identical(compatible$values[c(1L, 3L, 4L, 7L)], c("TRUE", minimum_version, "TRUE", "FALSE")), + grepl("/ordinary-library/rstudioapi$", compatible$values[[2L]])) + + # A loaded namespace is never replaced, whatever its version. + loaded <- run_mode("loaded-compatible") + stopifnot(length(loaded$requests) == 0L, + identical(loaded$values[c(1L, 3L, 7L)], c("TRUE", minimum_version, "FALSE")), + grepl("/ordinary-library/rstudioapi$", loaded$values[[2L]])) + old <- run_mode("loaded-incompatible", list(rstudioapi = "approve")) + stopifnot(length(old$requests) == 0L, + identical(old$values[c(1L, 3L, 7L)], c("TRUE", "0.3.1", "FALSE")), + grepl("/old-library/rstudioapi$", old$values[[2L]]), + length(old$messages) == 1L) + + # jgd and rstudioapi in the same startup: two separate requests, each + # with its own policy, and the decision for one does not affect the other. + both <- run_mode("both", list(rstudioapi = "approve", jgd = "decline")) + summaries <- vapply(both$requests, function(request) paste(request_summary(request), collapse = " "), "") + stopifnot(identical(sort(summaries), sort(c("once rstudioapi 0.4.0", "once jgd 0.1.0"))), + identical(both$values[c(1L, 4L, 5L, 6L)], c("TRUE", "TRUE", "FALSE", "FALSE"))) + explicit <- run_mode("both-explicit", list(rstudioapi = "approve", jgd = "approve")) + summaries <- vapply(explicit$requests, function(request) paste(request_summary(request), collapse = " "), "") + stopifnot(identical(sort(summaries), sort(c("once rstudioapi 0.4.0", "always jgd 0.1.0"))), + identical(explicit$values[c(1L, 4L, 5L, 6L)], c("TRUE", "TRUE", "TRUE", "TRUE"))) + + # Terminal startup profile: emulation setting drives preparation. + profile_run <- function(label, emulation, answer) { + profile_root <- file.path(root, label) + dir.create(profile_root) + consent <- file.path(profile_root, "consent") + dir.create(consent) + connect_log <- file.path(profile_root, "connect-log") + stub_attach <- file.path(profile_root, "attach-helper.R") + placeholder <- file.path(profile_root, "placeholder.R") + file.create(placeholder) + writeLines(c( + "source(file.path(getwd(), 'R', 'attach_sess.R'))", + "vscode_r_prepare_sess <- function(...) {", + " ns <- new.env(); ns$connect <- function(use_rstudioapi=TRUE, ...) {", + paste0(" writeLines(c(as.character(use_rstudioapi), as.character('rstudioapi' %in% loadedNamespaces())), ", + r_literal(connect_log), ")"), + " TRUE", + "}; ns }" + ), stub_attach) + startup <- file.path(profile_root, "run-profile.R") + writeLines(c( + paste0("Sys.setenv(VSCODE_R_SESS_PKG_PATH=", r_literal(profile_root), ","), + paste0(" VSCODE_R_SESS_SOURCE_HELPER=", r_literal(placeholder), ","), + paste0(" VSCODE_R_SESS_ATTACH_HELPER=", r_literal(stub_attach), ","), + paste0(" VSCODE_R_SESS_INSTALLER_HELPER=", r_literal(placeholder), ","), + paste0(" VSCODE_R_SESS_ROOT=", r_literal(profile_root), ","), + paste0(" VSCODE_R_SESS_CONSENT_DIRECTORY=", r_literal(consent), ","), + paste0(" VSCODE_R_TOOL_PREPARE_HELPER=", r_literal(file.path(getwd(), "R", "prepare-tools.R")), ","), + paste0(" VSCODE_R_TOOL_ROOT=", r_literal(file.path(profile_root, "tools")), ","), + paste0(" VSCODE_R_TOOL_CONSENT_DIRECTORY=", r_literal(consent), ","), + paste0(" VSCODE_R_TOOL_LIBRARY_HELPER=", r_literal(file.path(getwd(), "R", "tool-library.R")), ","), + paste0(" VSCODE_R_TOOL_CONSENT_HELPER=", r_literal(file.path(getwd(), "R", "tool-consent.R")), ","), + paste0(" VSCODE_R_RSTUDIOAPI_MINIMUM_VERSION=", r_literal(minimum_version), ","), + " VSCODE_R_RSTUDIOAPI_CAN_PROMPT='TRUE',", + paste0(" R_PROFILE_USER_OLD=", r_literal(placeholder), ","), + paste0(" SESS_PLOT_BACKEND='standard', SESS_RSTUDIOAPI='", emulation, "')"), + paste0("options(repos=c(CRAN=", r_literal(repo), "), pkgType='source')"), + "source(file.path(getwd(), 'R', 'profile.R'))" + ), startup) + logs <- file.path(profile_root, c("stdout", "stderr")) + system2_with_child_environment(isolated_child_env(label), r_binary, + shQuote(c("--vanilla", "--slave", paste0("--file=", startup))), + wait = FALSE, stdout = logs[[1L]], stderr = logs[[2L]]) + requests <- serve(consent, connect_log, list(rstudioapi = answer), logs) + list(values = readLines(connect_log, warn = FALSE), requests = requests) + } + enabled <- profile_run("profile-enabled", "TRUE", "approve") + stopifnot(length(enabled$requests) == 1L, + identical(request_summary(enabled$requests[[1L]]), c("once", "rstudioapi", minimum_version)), + identical(enabled$values, c("TRUE", "TRUE"))) + suppressed <- profile_run("profile-disabled", "FALSE", "approve") + stopifnot(length(suppressed$requests) == 0L, identical(suppressed$values, c("FALSE", "FALSE"))) + + cat("rstudioapi preparation integration tests passed\n") +}) diff --git a/R/tests/sess_source.R b/R/tests/sess_source.R index 944bf42ad..54be57c24 100644 --- a/R/tests/sess_source.R +++ b/R/tests/sess_source.R @@ -199,6 +199,50 @@ stopifnot(identical(sess_loaded_source_revision(), stable)) stopifnot(identical(getExportedValue("sess", "connect")(), "normal-library-dependency")) stopifnot(identical(normalized_paths(.libPaths()), normalized_paths(ordinary_before))) +# The private Interactive runtime installs rstudioapi beside sess, which only +# suggests it. Ordinary installations must not request it. +local({ + package <- file.path(root, "installer-dependencies", "sess") + dir.create(package, recursive = TRUE) + writeLines(c("Package: sess", "Version: 1.0.0", "Imports: sessfixturedep", + "Config/vscode-R/source-revision: fixture"), + file.path(package, "DESCRIPTION")) + target <- file.path(root, "installer-dependencies", "library") + isolated <- file.path(root, "installer-dependencies", "empty") + for (directory in c(target, isolated)) dir.create(directory) + child <- file.path(root, "installer-dependencies.R") + writeLines(c( + "args <- commandArgs(TRUE)", + "installer <- new.env(parent = baseenv())", + "sys.source(args[[1L]], installer)", + "installer$sess_install_dependencies <- function(packages, ...) requested <<- packages", + "installer$sess_install_command <- function(...) TRUE", + "installer$sess_verify_package <- function(...) invisible(TRUE)", + "for (interactive in c(TRUE, FALSE)) {", + " requested <- character()", + " installer$sess_install(args[[2L]], args[[3L]], 'https://example.com',", + " interactive = interactive)", + " cat('requested', interactive, paste(requested, collapse = ','), '\\n', sep = ';')", + "}" + ), child) + keys <- c("R_LIBS", "R_LIBS_USER", "R_LIBS_SITE") + previous <- Sys.getenv(keys, unset = NA_character_) + on.exit({ + Sys.unsetenv(keys[is.na(previous)]) + if (any(!is.na(previous))) do.call(Sys.setenv, as.list(previous[!is.na(previous)])) + }, add = TRUE) + Sys.setenv(R_LIBS = "", R_LIBS_USER = isolated, R_LIBS_SITE = isolated) + output <- system2(r_binary, shQuote(c( + "--vanilla", "--silent", "--no-echo", "--no-save", "--no-restore", + paste0("--file=", child), "--args", + file.path(getwd(), "R", "sess-package-install.R"), package, target + )), stdout = TRUE, stderr = TRUE) + stopifnot(is.null(attr(output, "status"))) + stopifnot(identical(grep("^requested;", output, value = TRUE), + c("requested;TRUE;sessfixturedep,rstudioapi;", + "requested;FALSE;sessfixturedep;"))) +}) + Sys.unsetenv(c("VSCODE_R_SESS_PKG_PATH", "VSCODE_R_SESS_REPO", "VSCODE_R_SESS_LIBRARY", "VSCODE_R_SESS_INTERACTIVE")) if (is.na(previous_r_libs)) Sys.unsetenv("R_LIBS") else Sys.setenv(R_LIBS = previous_r_libs) diff --git a/R/tests/tool-consent.R b/R/tests/tool-consent.R index d2472fe53..364ff439b 100644 --- a/R/tests/tool-consent.R +++ b/R/tests/tool-consent.R @@ -30,30 +30,30 @@ local({ class = "vscode_r_tool_install_plan" ) - lines <- .tool_consent_lines(plan, strrep("a", 32L), "jgd") + lines <- .tool_consent_lines(plan, strrep("a", 32L), "always") stopifnot(length(lines) == 11L) stopifnot(identical(lines[c(1L, 2L, 4L, 5L)], - c("vscode-r-package-consent-v1", strrep("a", 32L), "jgd", "1"))) + c("vscode-r-package-consent-v1", strrep("a", 32L), "always", "1"))) stopifnot(identical(lines[6:11], c("jgd", "0.1.0", "0.1.0", repository, "source", "yes"))) invalid <- plan invalid$repository <- paste0(repository, "\nother.example") - stopifnot(inherits(tryCatch(.tool_consent_lines(invalid, strrep("b", 32L), "auto"), + stopifnot(inherits(tryCatch(.tool_consent_lines(invalid, strrep("b", 32L), "once"), error = identity), "error")) directory <- file.path(root, "consent") dir.create(directory) - approval <- .tool_publish_consent_request(plan, strrep("c", 32L), "auto", directory) + approval <- .tool_publish_consent_request(plan, strrep("c", 32L), "once", directory) stopifnot(identical(readLines(approval$request_path), - .tool_consent_lines(plan, strrep("c", 32L), "auto"))) + .tool_consent_lines(plan, strrep("c", 32L), "once"))) stopifnot(inherits(tryCatch(.tool_publish_consent_request(plan, strrep("c", 32L), - "auto", directory), + "once", directory), error = identity), "error")) writeLines("approve", approval$response_path) stopifnot(isTRUE(.tool_wait_for_consent(approval, timeout_seconds = 0))) stopifnot(!file.exists(approval$request_path), !file.exists(approval$response_path)) - changed <- .tool_publish_consent_request(plan, strrep("d", 32L), "auto", directory) + changed <- .tool_publish_consent_request(plan, strrep("d", 32L), "once", directory) changed_lines <- changed$lines changed_lines[8L] <- "0.2.0" writeLines(changed_lines, changed$request_path) @@ -61,18 +61,18 @@ local({ stopifnot(!.tool_wait_for_consent(changed, timeout_seconds = 0)) stopifnot(!file.exists(changed$request_path), !file.exists(changed$response_path)) - declined <- .tool_publish_consent_request(plan, strrep("e", 32L), "jgd", directory) + declined <- .tool_publish_consent_request(plan, strrep("e", 32L), "always", directory) writeLines("decline", declined$response_path) stopifnot(!.tool_wait_for_consent(declined, timeout_seconds = 0)) stopifnot(!file.exists(declined$request_path), !file.exists(declined$response_path)) - stopifnot(!.tool_request_consent(plan, "auto", directory, timeout_seconds = 0)) + stopifnot(!.tool_request_consent(plan, "once", directory, timeout_seconds = 0)) stopifnot(length(list.files(directory)) == 0L) - unavailable <- .tool_publish_consent_request(plan, strrep("f", 32L), "auto", directory) + unavailable <- .tool_publish_consent_request(plan, strrep("f", 32L), "once", directory) stopifnot(!.tool_wait_for_consent(unavailable, timeout_seconds = 0)) stopifnot(!file.exists(unavailable$request_path), !file.exists(unavailable$response_path)) - invalid_timeout <- .tool_publish_consent_request(plan, strrep("0", 32L), "auto", directory) + invalid_timeout <- .tool_publish_consent_request(plan, strrep("0", 32L), "once", directory) stopifnot(inherits(tryCatch(.tool_wait_for_consent(invalid_timeout, timeout_seconds = -1), error = identity), "error")) stopifnot(!file.exists(invalid_timeout$request_path), !file.exists(invalid_timeout$response_path)) @@ -88,10 +88,10 @@ local({ install_required = !ready, library = library) } prepare_environment$tool_library_plan <- function(...) if (ready) NULL else plan - prepare_environment$.tool_request_consent <- function(received, backend, directory, + prepare_environment$.tool_request_consent <- function(received, policy, directory, timeout_seconds) { prompt_calls <<- prompt_calls + 1L - stopifnot(identical(received, plan), backend %in% c("jgd", "auto"), + stopifnot(identical(received, plan), policy %in% c("always", "once"), identical(directory, "consent")) permission } @@ -105,16 +105,16 @@ local({ received_plan <- plan prepare <- tool_library_prepare environment(prepare) <- prepare_environment - stopifnot(is.null(prepare("jgd", "0.1.0", root, "consent", "auto", repos = character()))) + stopifnot(is.null(prepare("jgd", "0.1.0", root, "consent", "once", repos = character()))) stopifnot(prompt_calls == 1L, install_calls == 0L) permission <- FALSE - stopifnot(is.null(prepare("jgd", "0.1.0", root, "consent", "jgd", repos = character()))) + stopifnot(is.null(prepare("jgd", "0.1.0", root, "consent", "always", repos = character()))) stopifnot(prompt_calls == 2L, install_calls == 0L) permission <- TRUE - prepare("jgd", "0.1.0", root, "consent", "auto", repos = character()) + prepare("jgd", "0.1.0", root, "consent", "once", repos = character()) stopifnot(prompt_calls == 3L, install_calls == 1L) ready <- TRUE - prepare("jgd", "0.1.0", root, "consent", "auto", repos = character()) + prepare("jgd", "0.1.0", root, "consent", "once", repos = character()) stopifnot(prompt_calls == 3L, install_calls == 1L) # Exercise prepare through its real file request and response path. @@ -122,17 +122,17 @@ local({ consent_directory <- file.path(root, "prepare consent") dir.create(consent_directory) original_wait <- .tool_wait_for_consent - prepare_environment$.tool_request_consent <- function(received, backend, + prepare_environment$.tool_request_consent <- function(received, policy, directory, timeout_seconds) { request <- .tool_publish_consent_request(received, strrep("1", 32L), - backend, directory) + policy, directory) prepare_environment$.tool_wait_for_consent(request, timeout_seconds) } prepare_environment$.tool_wait_for_consent <- function(request, timeout_seconds) { writeLines("approve", request$response_path) original_wait(request, timeout_seconds = 0) } - prepared <- prepare("jgd", "0.1.0", root, consent_directory, "jgd", + prepared <- prepare("jgd", "0.1.0", root, consent_directory, "always", repos = character()) stopifnot(identical(prepared$version, plan$version), prompt_calls == 3L, install_calls == 2L, diff --git a/R/tests/tool-library.R b/R/tests/tool-library.R index c720e4816..3b9345c17 100644 --- a/R/tests/tool-library.R +++ b/R/tests/tool-library.R @@ -93,6 +93,8 @@ local({ stopifnot(tool_library_check("jgd", "1.0.0", root_path)$install_required) stopifnot(inherits(tryCatch(tool_library_check("arbitrary", "1.0.0", root_path), error = identity), "error")) + stopifnot(identical(unname(.vscode_r_tool_packages), c("jgd", "rstudioapi"))) + stopifnot(isTRUE(.tool_validate_request("rstudioapi", "0.4.0"))) for (invalid in c("", "1.foo", "https://example.com/pkg")) { stopifnot(inherits(tryCatch(tool_library_check("jgd", invalid, root_path), error = identity), "error")) @@ -135,14 +137,14 @@ local({ stopifnot(inherits(too_old, "error"), !dir.exists(file.path(managed, "jgd"))) frozen_plan <- tool_library_plan("jgd", "1.0.0", root_path, repos = c(CRAN = repo)) stopifnot(identical(frozen_plan$version, "1.2.0")) - consent_lines <- .tool_consent_lines(frozen_plan, strrep("a", 32L), "auto") + consent_lines <- .tool_consent_lines(frozen_plan, strrep("a", 32L), "once") stopifnot(identical(consent_lines[1:5], c("vscode-r-package-consent-v1", strrep("a", 32L), paste(R.version$platform, paste(R.version$major, strsplit(R.version$minor, ".", fixed = TRUE)[[1L]][1L], sep = "."), sep = "|"), - "auto", "1"))) + "once", "1"))) stopifnot(identical(consent_lines[6:11], c("jgd", "1.0.0", "1.2.0", frozen_plan$repository, frozen_plan$package_type, if (frozen_plan$source_build) "yes" else "no"))) diff --git a/R/tool-consent.R b/R/tool-consent.R index 6c2e33a4d..48ffe32d3 100644 --- a/R/tool-consent.R +++ b/R/tool-consent.R @@ -2,11 +2,11 @@ .tool_consent_max_bytes <- 16384L .tool_consent_request_version <- "vscode-r-package-consent-v1" -.tool_consent_lines <- function(plan, id, backend) { +.tool_consent_lines <- function(plan, id, policy) { if (!inherits(plan, "vscode_r_tool_install_plan") || !(is.character(id) && length(id) == 1L && grepl("^[A-Za-z0-9_-]{1,64}$", id)) || - !(is.character(backend) && length(backend) == 1L && backend %in% c("jgd", "auto"))) { + !(is.character(policy) && length(policy) == 1L && policy %in% c("always", "once"))) { stop("Invalid managed tool consent request.", call. = FALSE) } runtime_minor <- strsplit(R.version$minor, ".", fixed = TRUE)[[1L]][1L] @@ -20,7 +20,7 @@ stop("The managed tool installation plan cannot be represented in a consent request.", call. = FALSE) } - lines <- c(.tool_consent_request_version, id, runtime, backend, "1", package_fields) + lines <- c(.tool_consent_request_version, id, runtime, policy, "1", package_fields) request <- paste0(paste(lines, collapse = "\n"), "\n") if (nchar(request, type = "bytes") > .tool_consent_max_bytes) { stop("The managed tool consent request is too large.", call. = FALSE) @@ -28,7 +28,7 @@ lines } -.tool_publish_consent_request <- function(plan, id, backend, directory) { +.tool_publish_consent_request <- function(plan, id, policy, directory) { if (!(is.character(directory) && length(directory) == 1L && !is.na(directory) && nzchar(directory))) { stop("The managed tool consent directory is unavailable.", call. = FALSE) @@ -38,7 +38,7 @@ stop("The managed tool consent directory is unavailable.", call. = FALSE) } if (.Platform$OS.type == "unix") Sys.chmod(directory, "0700") - lines <- .tool_consent_lines(plan, id, backend) + lines <- .tool_consent_lines(plan, id, policy) request_path <- file.path(directory, paste0(id, ".request")) response_path <- file.path(directory, paste0(id, ".response")) if (file.exists(request_path) || file.exists(response_path)) { @@ -88,7 +88,7 @@ } } -.tool_request_consent <- function(plan, backend, directory, timeout_seconds = 180) { +.tool_request_consent <- function(plan, policy, directory, timeout_seconds = 180) { if (!(is.character(directory) && length(directory) == 1L && !is.na(directory) && nzchar(directory) && dir.exists(directory))) { stop("The extension's tool consent service is unavailable. Restart VS Code and try again.", @@ -106,14 +106,15 @@ response_path <- file.path(directory, paste0(id, ".response")) if (!file.exists(request_path) && !file.exists(response_path)) break } - request <- .tool_publish_consent_request(plan, id, backend, directory) + request <- .tool_publish_consent_request(plan, id, policy, directory) .tool_wait_for_consent(request, timeout_seconds) } # Request consent only when no compatible namespace is already available. # On approval the frozen repository result is passed directly to installation. +# `policy` is "always" or "once"; the extension applies it to its prompt. tool_library_prepare <- function(package, minimum_version, root, consent_directory, - backend, repos = getOption("repos"), + policy, repos = getOption("repos"), consent_timeout_seconds = 180, install_timeout_seconds = 300, resolution_timeout_seconds = 30) { @@ -131,7 +132,7 @@ tool_library_prepare <- function(package, minimum_version, root, consent_directo tool_library_plan(package, minimum_version, root, repos) }) if (is.null(plan)) return(invisible(tool_library_check(package, minimum_version, root))) - approved <- .tool_request_consent(plan, backend, consent_directory, + approved <- .tool_request_consent(plan, policy, consent_directory, timeout_seconds = consent_timeout_seconds) if (!isTRUE(approved)) return(invisible(NULL)) tool_library_install(package, minimum_version, root, repos = plan$repositories, diff --git a/R/tool-library.R b/R/tool-library.R index 4936aff3d..e4b043827 100644 --- a/R/tool-library.R +++ b/R/tool-library.R @@ -1,7 +1,7 @@ # R-side support for packages managed by vscode-R as optional tools. # The allowlist is intentionally fixed here; callers may select a minimum version # but cannot use this installer to fetch arbitrary package names or URLs. -.vscode_r_tool_packages <- c(jgd = "jgd") +.vscode_r_tool_packages <- c(jgd = "jgd", rstudioapi = "rstudioapi") tool_library_path <- function(root) { .tool_validate_root(root) diff --git a/package.json b/package.json index 2524ea83b..e89747d0b 100644 --- a/package.json +++ b/package.json @@ -2101,7 +2101,7 @@ "r.session.emulateRStudioAPI": { "type": "boolean", "default": true, - "markdownDescription": "Emulate the RStudio API for addin support and other {rstudioapi} calls. Requires `#r.sessionWatcher#` to be set to `true`." + "markdownDescription": "Emulate the RStudio API for addin support and other {rstudioapi} calls. Requires `#r.sessionWatcher#` to be set to `true`. If {rstudioapi} is not installed, vscode-R can offer to install it into a vscode-R-managed library." }, "r.session.data.pageSize": { "type": "integer", @@ -2613,6 +2613,7 @@ "test:tool-library": "Rscript --vanilla R/tests/tool-library.R", "test:tool-consent": "Rscript --vanilla R/tests/tool-consent.R", "test:prepare-jgd": "Rscript --vanilla R/tests/prepare-jgd.R", + "test:prepare-rstudioapi": "Rscript --vanilla R/tests/prepare-rstudioapi.R", "test:interactive": "pnpm run pretest && pnpm run test:node", "test:interactive:examples": "pnpm run compile && tsc && node src/test/examples/run.cjs" }, diff --git a/sess/DESCRIPTION b/sess/DESCRIPTION index 44b47bb49..135f09106 100644 --- a/sess/DESCRIPTION +++ b/sess/DESCRIPTION @@ -28,8 +28,7 @@ Imports: later, jsonlite, utils, - methods, - rstudioapi + methods Suggests: bit64, data.table, @@ -38,6 +37,7 @@ Suggests: systemfonts, httpgd, jgd, + rstudioapi, svglite, tinytest NeedsCompilation: no diff --git a/sess/inst/tinytest/test-rstudioapi-optional.R b/sess/inst/tinytest/test-rstudioapi-optional.R new file mode 100644 index 000000000..058f5610b --- /dev/null +++ b/sess/inst/tinytest/test-rstudioapi-optional.R @@ -0,0 +1,81 @@ +# sess only suggests rstudioapi: it must load and start its emulation without the +# package, and patch it whenever it is loaded later. +local({ + description <- utils::packageDescription("sess", fields = c("Imports", "Suggests")) + expect_false(grepl("rstudioapi", description$Imports, fixed = TRUE)) + expect_true(grepl("rstudioapi", description$Suggests, fixed = TRUE)) + + root <- tempfile("sess-rstudioapi-") + dir.create(root) + on.exit(unlink(root, recursive = TRUE), add = TRUE) + r_command <- function(program) file.path(R.home("bin"), program) + + # A directory without a valid installation shadows any real rstudioapi. + absent_library <- file.path(root, "absent-library") + dir.create(file.path(absent_library, "rstudioapi"), recursive = TRUE) + writeLines(c("Package: rstudioapi", "Version: 0.0.0"), + file.path(absent_library, "rstudioapi", "DESCRIPTION")) + + # A minimal installed package named rstudioapi, loaded only on demand. + stub_source <- file.path(root, "rstudioapi") + dir.create(file.path(stub_source, "R"), recursive = TRUE) + writeLines(c("Package: rstudioapi", "Version: 0.4.0", "Title: Fixture", + "Description: Local rstudioapi fixture.", "License: MIT", + "Author: Test Author", "Maintainer: Test Author "), + file.path(stub_source, "DESCRIPTION")) + writeLines("isAvailable <- function(version_needed = NULL, child_ok = FALSE) 'original'", + file.path(stub_source, "R", "api.R")) + writeLines("export(isAvailable)", file.path(stub_source, "NAMESPACE")) + stub_library <- file.path(root, "stub-library") + dir.create(stub_library) + installed <- suppressWarnings(system2( + r_command("R"), + c("CMD", "INSTALL", paste0("--library=", shQuote(stub_library)), shQuote(stub_source)), + stdout = TRUE, stderr = TRUE + )) + expect_null(attr(installed, "status")) + + script <- file.path(root, "child.R") + writeLines(c( + "args <- commandArgs(TRUE); scenario <- args[[1L]]; result <- args[[2L]]", + "hook <- packageEvent('rstudioapi', 'onLoad')", + "loadNamespace('sess')", + "lines <- c(available=as.character(requireNamespace('rstudioapi', quietly=TRUE)))", + "if (scenario == 'stub') unloadNamespace('rstudioapi')", + "lines[['loaded_with_sess']] <- as.character('rstudioapi' %in% loadedNamespaces())", + "sess_env <- sess:::.sess_env; sess_env$latest_plot_path <- tempfile(fileext='.png')", + "sess:::runtime_start(use_rstudioapi=TRUE, plot_backend='standard')", + "lines[['loaded_after_start']] <- as.character('rstudioapi' %in% loadedNamespaces())", + "lines[['hook']] <- as.character(length(getHook(hook)))", + "if (scenario == 'stub') {", + " loadNamespace('rstudioapi')", + " lines[['patched']] <- as.character(isTRUE(rstudioapi::isAvailable()))", + "}", + "sess:::runtime_stop()", + "lines[['hook_after_stop']] <- as.character(length(getHook(hook)))", + "writeLines(paste(names(lines), lines, sep='='), result)" + ), script) + + run_child <- function(scenario, library) { + result <- file.path(root, paste0(scenario, "-result")) + previous <- Sys.getenv("R_LIBS", unset = NA_character_) + on.exit(if (is.na(previous)) Sys.unsetenv("R_LIBS") else Sys.setenv(R_LIBS = previous)) + sess_library <- dirname(system.file(package = "sess")) + Sys.setenv(R_LIBS = paste(unique(c(library, sess_library, .libPaths())), + collapse = .Platform$path.sep)) + output <- system2(r_command("Rscript"), + c("--vanilla", shQuote(script), scenario, shQuote(result)), + stdout = TRUE, stderr = TRUE) + expect_null(attr(output, "status"), info = paste(output, collapse = "\n")) + if (file.exists(result)) readLines(result) else character() + } + + absent <- run_child("absent", absent_library) + expect_equal(absent, c("available=FALSE", "loaded_with_sess=FALSE", + "loaded_after_start=FALSE", "hook=1", "hook_after_stop=0")) + + present <- run_child("stub", stub_library) + expect_equal(present, c("available=TRUE", "loaded_with_sess=FALSE", + "loaded_after_start=FALSE", "hook=1", "patched=TRUE", + "hook_after_stop=0")) +}) diff --git a/src/jgdVersion.ts b/src/jgdVersion.ts deleted file mode 100644 index 300f684cc..000000000 --- a/src/jgdVersion.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Shared by consent eligibility and R package preparation. -export const JGD_MINIMUM_VERSION = '0.1.0'; diff --git a/src/rTerminal.ts b/src/rTerminal.ts index c0438cdae..3208ea2e7 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import * as crypto from 'node:crypto'; import { getMigratedSetting } from './configuration'; -import { JGD_MINIMUM_VERSION } from './jgdVersion'; +import { TOOL_MINIMUM_VERSIONS } from './toolVersions'; import { isDeepStrictEqual } from 'util'; import * as vscode from 'vscode'; @@ -19,7 +19,8 @@ import { getGlobalPipePath, getSessConsentDirectory, getToolConsentDirectory, - canPromptForToolConsent, + canPromptForJgdInstall, + canPromptForRStudioApiInstall, isTerminalClosed, removeTerminalDiscoveryFile, updateTerminalSessionDiscoveryFile, @@ -316,7 +317,8 @@ export async function makeTerminalOptions(resource?: vscode.Uri): Promise 0 && attachToolConsentService?.canPrompt(backend) === true; + const jgdCanPrompt = toolConsentDirectory.length > 0 && canPromptForJgdInstall(backend); + const rstudioapiCanPrompt = toolConsentDirectory.length > 0 && canPromptForRStudioApiInstall(); return [ 'local({', ' tryCatch({', @@ -1577,8 +1577,9 @@ function buildAttachSessionScript( ` tool_consent_dir <- ${asRStringLiteral(toolConsentDirectory.replace(/\\/g, '/'))}`, ` tool_library_helper <- ${asRStringLiteral(extensionContext.asAbsolutePath(path.join('R', 'tool-library.R')).replace(/\\/g, '/'))}`, ` tool_consent_helper <- ${asRStringLiteral(extensionContext.asAbsolutePath(path.join('R', 'tool-consent.R')).replace(/\\/g, '/'))}`, - ` jgd_helper <- ${asRStringLiteral(extensionContext.asAbsolutePath(path.join('R', 'prepare-jgd.R')).replace(/\\/g, '/'))}`, + ` tool_prepare_helper <- ${asRStringLiteral(extensionContext.asAbsolutePath(path.join('R', 'prepare-tools.R')).replace(/\\/g, '/'))}`, ` jgd_can_prompt <- ${jgdCanPrompt ? 'TRUE' : 'FALSE'}`, + ` rstudioapi_can_prompt <- ${rstudioapiCanPrompt ? 'TRUE' : 'FALSE'}`, ` startup_helper <- ${asRStringLiteral(extensionContext.asAbsolutePath(path.join('R', 'terminal-startup.R')).replace(/\\/g, '/'))}`, ` attach_helper <- ${asRStringLiteral(extensionContext.asAbsolutePath(path.join('R', 'attach_sess.R')).replace(/\\/g, '/'))}`, ...(backend === 'native' @@ -1590,7 +1591,7 @@ function buildAttachSessionScript( ]), ' source(startup_helper, local = TRUE)', ' source(attach_helper, local = TRUE)', - ` vscode_r_attach_sess(endpoint, sess_src, managed_root, consent_dir, source_helper, installer_helper, ${asRStringLiteral(backend)}, tool_root = tool_root, tool_consent_directory = tool_consent_dir, tool_library_helper = tool_library_helper, tool_consent_helper = tool_consent_helper, jgd_helper = jgd_helper, jgd_can_prompt = jgd_can_prompt, jgd_minimum_version = ${asRStringLiteral(JGD_MINIMUM_VERSION)}, use_rstudioapi = ${config().get('session.emulateRStudioAPI') ? 'TRUE' : 'FALSE'}${attachment ? `, attachment_id = ${asRStringLiteral(attachment.attachmentId)}, auto_activate = ${attachment.autoActivate ? 'TRUE' : 'FALSE'}` : ''})`, + ` vscode_r_attach_sess(endpoint, sess_src, managed_root, consent_dir, source_helper, installer_helper, ${asRStringLiteral(backend)}, tool_root = tool_root, tool_consent_directory = tool_consent_dir, tool_library_helper = tool_library_helper, tool_consent_helper = tool_consent_helper, tool_prepare_helper = tool_prepare_helper, jgd_can_prompt = jgd_can_prompt, jgd_minimum_version = ${asRStringLiteral(TOOL_MINIMUM_VERSIONS.jgd)}, rstudioapi_can_prompt = rstudioapi_can_prompt, rstudioapi_minimum_version = ${asRStringLiteral(TOOL_MINIMUM_VERSIONS.rstudioapi)}, use_rstudioapi = ${config().get('session.emulateRStudioAPI') ? 'TRUE' : 'FALSE'}${attachment ? `, attachment_id = ${asRStringLiteral(attachment.attachmentId)}, auto_activate = ${attachment.autoActivate ? 'TRUE' : 'FALSE'}` : ''})`, ' })', ' invisible(isTRUE(attached))', ' }, error = function(error) {', @@ -1766,9 +1767,15 @@ export async function getToolConsentDirectory(): Promise { return ensureAttachToolConsentService(); } -/** Whether the running consent service may display a prompt for this backend. */ -export function canPromptForToolConsent(backend: string): boolean { - return attachToolConsentService?.canPrompt(backend) === true; +/** Whether the running consent service may offer to install jgd for this plot backend. */ +export function canPromptForJgdInstall(backend: string): boolean { + const policy = backend === 'jgd' ? 'always' : backend === 'auto' ? 'once' : undefined; + return policy !== undefined && attachToolConsentService?.canPrompt(['jgd'], policy) === true; +} + +/** Whether the running consent service may offer to install rstudioapi. */ +export function canPromptForRStudioApiInstall(): boolean { + return attachToolConsentService?.canPrompt(['rstudioapi'], 'once') === true; } function validateAttachmentEndpoint(endpoint: unknown): asserts endpoint is string { diff --git a/src/test/examples/sess-installer.R b/src/test/examples/sess-installer.R index d696ce649..ebd13f49b 100644 --- a/src/test/examples/sess-installer.R +++ b/src/test/examples/sess-installer.R @@ -8,6 +8,8 @@ local({ description <- read.dcf(file.path(package, "DESCRIPTION")) deps <- if ("Imports" %in% colnames(description)) description[1L, "Imports"] else "" deps <- trimws(gsub("\\s*\\(.*\\)", "", unlist(strsplit(deps, ",")))) + # The Interactive installation also requests rstudioapi, which sess only suggests. + deps <- c(deps, "rstudioapi") installed <- utils::installed.packages(lib.loc = .libPaths()) missing <- setdiff(deps[nzchar(deps)], rownames(installed)) stopifnot(!length(missing)) diff --git a/src/test/node/interactiveLibraries.test.ts b/src/test/node/interactiveLibraries.test.ts index 7024a2d25..6a46dc105 100644 --- a/src/test/node/interactiveLibraries.test.ts +++ b/src/test/node/interactiveLibraries.test.ts @@ -79,7 +79,8 @@ const run = promisify(execFile); private <- ${rString(runtime.library)} user <- ${rString(userLibrary)} database <- installed.packages(lib.loc=unique(c(private,.libPaths()))) -dependencies <- unique(c("ps", unlist(tools::package_dependencies( +# The Interactive setup installs rstudioapi beside sess although sess only suggests it. +dependencies <- unique(c("ps", "rstudioapi", unlist(tools::package_dependencies( c("sess","ps"), database, which=c("Depends","Imports","LinkingTo"), recursive=TRUE)))) for (package in dependencies) { if (!is.na(database[package,"Priority"]) || dir.exists(file.path(private,package))) next diff --git a/src/test/node/toolConsent.test.ts b/src/test/node/toolConsent.test.ts index a8554ad9e..22522b278 100644 --- a/src/test/node/toolConsent.test.ts +++ b/src/test/node/toolConsent.test.ts @@ -7,17 +7,26 @@ import { ToolConsentChoice, ToolConsentRequest, ToolConsentService, + ToolConsentSessionState, } from '../../toolConsent'; -function requestContent(id: string, backend: 'jgd' | 'auto', version = '1.2.0'): string { +type Policy = 'always' | 'once'; + +function requestContent( + id: string, + policy: Policy, + version = '1.2.0', + packageName = 'jgd', + minimumVersion = '0.1.0', +): string { return [ 'vscode-r-package-consent-v1', id, 'linux-x86_64|4.5', - backend, + policy, '1', - 'jgd', - '0.1.0', + packageName, + minimumVersion, version, 'https://cloud.r-project.org', 'source', @@ -40,20 +49,20 @@ async function waitForFile(file: string): Promise { suite('managed package consent bridge', () => { let directory: string; - let autoPromptEnabled: boolean; + let dismissed: Set; let failPreferenceSave: boolean; let choice: ToolConsentChoice; let prompts: ToolConsentRequest[]; - let sessionState: { autoPrompted: boolean }; + let sessionState: ToolConsentSessionState; const services: ToolConsentService[] = []; setup(async () => { directory = await fs.mkdtemp(path.join(os.tmpdir(), 'vscode-r-package-consent-')); - autoPromptEnabled = true; + dismissed = new Set(); failPreferenceSave = false; choice = 'install'; prompts = []; - sessionState = { autoPrompted: false }; + sessionState = { promptedPackages: new Set() }; }); teardown(async () => { @@ -68,13 +77,13 @@ suite('managed package consent bridge', () => { const result = new ToolConsentService({ directory: requestDirectory, isEnabled: () => true, - isAutoPromptEnabled: () => autoPromptEnabled, + isPromptEnabled: (name) => !dismissed.has(name), sessionState, - rememberAutoPromptDismissal: () => { + rememberPromptDismissal: (name) => { if (failPreferenceSave) { return Promise.reject(new Error('global state unavailable')); } - autoPromptEnabled = false; + dismissed.add(name); return Promise.resolve(); }, prompt: async (request) => { @@ -89,23 +98,25 @@ suite('managed package consent bridge', () => { async function writeRequest( id: string, - backend: 'jgd' | 'auto', + policy: Policy, version?: string, requestDirectory = directory, + packageName?: string, ): Promise { const request = path.join(requestDirectory, `${id}.request`); - await fs.writeFile(request, requestContent(id, backend, version)); + await fs.writeFile(request, requestContent(id, policy, version, packageName)); return request; } test('explicit jgd asks on each request and does not alter automatic prompt preference', async () => { const broker = service(); await broker.start(); - assert.strictEqual(broker.canPrompt('jgd'), true); - assert.strictEqual(broker.canPrompt('auto'), true); - assert.strictEqual(broker.canPrompt('httpgd'), false); + assert.strictEqual(broker.canPrompt(['jgd'], 'always'), true); + assert.strictEqual(broker.canPrompt(['jgd'], 'once'), true); + assert.strictEqual(broker.canPrompt(['unlisted'], 'always'), false); + assert.strictEqual(broker.canPrompt([], 'always'), false); for (const id of ['1'.repeat(32), '2'.repeat(32)]) { - await writeRequest(id, 'jgd'); + await writeRequest(id, 'always'); assert.strictEqual( await waitForFile(path.join(directory, `${id}.response`)), 'approve\n', @@ -114,7 +125,7 @@ suite('managed package consent bridge', () => { await fs.rm(path.join(directory, `${id}.response`), { force: true }); } assert.strictEqual(prompts.length, 2); - assert.strictEqual(autoPromptEnabled, true); + assert.strictEqual(dismissed.size, 0); assert.deepStrictEqual(prompts[0].packages, [ { name: 'jgd', @@ -131,9 +142,9 @@ suite('managed package consent bridge', () => { const id = 'b'.repeat(32); const request = parseToolConsentRequest( id, - requestContent(id, 'jgd').replace(/\n/g, '\r\n'), + requestContent(id, 'always').replace(/\n/g, '\r\n'), ); - assert.strictEqual(request.backend, 'jgd'); + assert.strictEqual(request.policy, 'always'); assert.strictEqual(request.packages[0].sourceBuild, true); }); @@ -142,23 +153,23 @@ suite('managed package consent bridge', () => { await first.start(); const firstId = '3'.repeat(32); choice = 'notNow'; - await writeRequest(firstId, 'auto'); + await writeRequest(firstId, 'once'); assert.strictEqual( await waitForFile(path.join(directory, `${firstId}.response`)), 'decline\n', ); - assert.strictEqual(autoPromptEnabled, true); - assert.strictEqual(sessionState.autoPrompted, true); - assert.strictEqual(first.canPrompt('auto'), false); + assert.strictEqual(dismissed.size, 0); + assert.deepStrictEqual([...sessionState.promptedPackages], ['jgd']); + assert.strictEqual(first.canPrompt(['jgd'], 'once'), false); await first.stop(); const secondDirectory = path.join(directory, 'another-service'); await fs.mkdir(secondDirectory); const secondService = service(undefined, secondDirectory); await secondService.start(); - assert.strictEqual(secondService.canPrompt('auto'), false); + assert.strictEqual(secondService.canPrompt(['jgd'], 'once'), false); const secondId = '4'.repeat(32); - await writeRequest(secondId, 'auto', undefined, secondDirectory); + await writeRequest(secondId, 'once', undefined, secondDirectory); assert.strictEqual( await waitForFile(path.join(secondDirectory, `${secondId}.response`)), 'decline\n', @@ -167,15 +178,15 @@ suite('managed package consent bridge', () => { await secondService.stop(); // A fresh state represents a new VS Code extension-host session. - sessionState = { autoPrompted: false }; + sessionState = { promptedPackages: new Set() }; const newHostDirectory = path.join(directory, 'new-host'); await fs.mkdir(newHostDirectory); const nextHost = service(undefined, newHostDirectory); await nextHost.start(); - assert.strictEqual(nextHost.canPrompt('auto'), true); + assert.strictEqual(nextHost.canPrompt(['jgd'], 'once'), true); choice = 'install'; const thirdId = '5'.repeat(32); - await writeRequest(thirdId, 'auto', undefined, newHostDirectory); + await writeRequest(thirdId, 'once', undefined, newHostDirectory); assert.strictEqual( await waitForFile(path.join(newHostDirectory, `${thirdId}.response`)), 'approve\n', @@ -188,19 +199,19 @@ suite('managed package consent bridge', () => { await broker.start(); choice = 'dontAskAgain'; const firstId = '6'.repeat(32); - assert.strictEqual(broker.canPrompt('auto'), true); - await writeRequest(firstId, 'auto'); + assert.strictEqual(broker.canPrompt(['jgd'], 'once'), true); + await writeRequest(firstId, 'once'); assert.strictEqual( await waitForFile(path.join(directory, `${firstId}.response`)), 'decline\n', ); - assert.strictEqual(autoPromptEnabled, false); - assert.strictEqual(broker.canPrompt('auto'), false); - assert.strictEqual(broker.canPrompt('jgd'), true); + assert.deepStrictEqual([...dismissed], ['jgd']); + assert.strictEqual(broker.canPrompt(['jgd'], 'once'), false); + assert.strictEqual(broker.canPrompt(['jgd'], 'always'), true); choice = 'install'; const explicitId = '7'.repeat(32); - await writeRequest(explicitId, 'jgd'); + await writeRequest(explicitId, 'always'); assert.strictEqual( await waitForFile(path.join(directory, `${explicitId}.response`)), 'approve\n', @@ -208,13 +219,13 @@ suite('managed package consent bridge', () => { assert.strictEqual(prompts.length, 2); await broker.stop(); - sessionState = { autoPrompted: false }; + sessionState = { promptedPackages: new Set() }; const nextHostDirectory = path.join(directory, 'next-host'); await fs.mkdir(nextHostDirectory); const nextHost = service(undefined, nextHostDirectory); await nextHost.start(); const suppressedId = '8'.repeat(32); - await writeRequest(suppressedId, 'auto', undefined, nextHostDirectory); + await writeRequest(suppressedId, 'once', undefined, nextHostDirectory); assert.strictEqual( await waitForFile(path.join(nextHostDirectory, `${suppressedId}.response`)), 'decline\n', @@ -232,8 +243,8 @@ suite('managed package consent bridge', () => { ); const firstId = '7'.repeat(32); const secondId = '8'.repeat(32); - await writeRequest(firstId, 'auto'); - await writeRequest(secondId, 'auto'); + await writeRequest(firstId, 'once'); + await writeRequest(secondId, 'once'); await broker.start(); const deadline = Date.now() + 3000; while (prompts.length === 0 && Date.now() < deadline) { @@ -248,7 +259,7 @@ suite('managed package consent bridge', () => { ); await fs.writeFile( path.join(directory, `${promptedId}.request`), - requestContent(promptedId, 'auto', '1.3.0'), + requestContent(promptedId, 'once', '1.3.0'), ); finishPrompt('install'); assert.strictEqual( @@ -263,29 +274,29 @@ suite('managed package consent bridge', () => { const broker = service(); await broker.start(); const id = '9'.repeat(32); - await writeRequest(id, 'auto'); + await writeRequest(id, 'once'); assert.strictEqual(await waitForFile(path.join(directory, `${id}.response`)), 'decline\n'); assert.strictEqual(prompts.length, 1); - assert.strictEqual(autoPromptEnabled, true); + assert.strictEqual(dismissed.size, 0); }); test('Not now, dismiss, and install do not change persistent suppression', async () => { for (const [index, result] of (['notNow', 'dismiss', 'install'] as const).entries()) { - autoPromptEnabled = true; + dismissed.clear(); choice = result; - sessionState = { autoPrompted: false }; + sessionState = { promptedPackages: new Set() }; const hostDirectory = path.join(directory, `host-${index}`); await fs.mkdir(hostDirectory); const broker = service(undefined, hostDirectory); await broker.start(); const id = String(index + 1).repeat(32); - await writeRequest(id, 'auto', undefined, hostDirectory); + await writeRequest(id, 'once', undefined, hostDirectory); const expected = result === 'install' ? 'approve\n' : 'decline\n'; assert.strictEqual( await waitForFile(path.join(hostDirectory, `${id}.response`)), expected, ); - assert.strictEqual(autoPromptEnabled, true); + assert.strictEqual(dismissed.size, 0); } }); @@ -299,9 +310,9 @@ suite('managed package consent bridge', () => { const broker = new ToolConsentService({ directory, isEnabled: () => true, - isAutoPromptEnabled: () => true, + isPromptEnabled: () => true, sessionState, - rememberAutoPromptDismissal: () => { + rememberPromptDismissal: () => { dismissals++; return Promise.resolve(); }, @@ -317,7 +328,7 @@ suite('managed package consent bridge', () => { services.push(broker); await broker.start(); const id = 'a'.repeat(32); - await writeRequest(id, 'auto'); + await writeRequest(id, 'once'); await started; await broker.stop(); finishPrompt('install'); @@ -325,4 +336,81 @@ suite('managed package consent bridge', () => { assert.strictEqual(prompts.length, 1); assert.strictEqual(dismissals, 0); }); + + async function answerFor( + id: string, + policy: Policy, + packageName: string, + requestDirectory = directory, + ): Promise { + await writeRequest(id, policy, undefined, requestDirectory, packageName); + return waitForFile(path.join(requestDirectory, `${id}.response`)); + } + + test('automatic prompts are tracked per package within one host session', async () => { + const broker = service(); + await broker.start(); + choice = 'notNow'; + assert.strictEqual(broker.canPrompt(['rstudioapi'], 'once'), true); + assert.strictEqual(await answerFor('c'.repeat(32), 'once', 'jgd'), 'decline\n'); + // Offering jgd must not use up the offer for rstudioapi in the same startup. + assert.strictEqual(broker.canPrompt(['jgd'], 'once'), false); + assert.strictEqual(broker.canPrompt(['rstudioapi'], 'once'), true); + choice = 'install'; + assert.strictEqual(await answerFor('d'.repeat(32), 'once', 'rstudioapi'), 'approve\n'); + assert.deepStrictEqual( + prompts.map((request) => request.packages[0].name), + ['jgd', 'rstudioapi'], + ); + // Each package is offered once per host session. + assert.strictEqual(await answerFor('e'.repeat(32), 'once', 'rstudioapi'), 'decline\n'); + assert.strictEqual(await answerFor('f'.repeat(32), 'once', 'jgd'), 'decline\n'); + assert.strictEqual(prompts.length, 2); + assert.deepStrictEqual([...sessionState.promptedPackages].sort(), ['jgd', 'rstudioapi']); + }); + + test("Don't ask again is remembered only for its own package", async () => { + const broker = service(); + await broker.start(); + choice = 'dontAskAgain'; + assert.strictEqual(await answerFor('1'.repeat(32), 'once', 'rstudioapi'), 'decline\n'); + assert.deepStrictEqual([...dismissed], ['rstudioapi']); + assert.strictEqual(broker.canPrompt(['rstudioapi'], 'once'), false); + assert.strictEqual(broker.canPrompt(['jgd'], 'once'), true); + + // A new host session still honors the remembered choice for rstudioapi only. + sessionState = { promptedPackages: new Set() }; + const nextHostDirectory = path.join(directory, 'next-host'); + await fs.mkdir(nextHostDirectory); + const nextHost = service(undefined, nextHostDirectory); + await nextHost.start(); + assert.strictEqual(nextHost.canPrompt(['rstudioapi'], 'once'), false); + assert.strictEqual(nextHost.canPrompt(['jgd'], 'once'), true); + assert.strictEqual( + await answerFor('2'.repeat(32), 'once', 'rstudioapi', nextHostDirectory), + 'decline\n', + ); + assert.strictEqual(prompts.length, 1); + choice = 'install'; + assert.strictEqual( + await answerFor('3'.repeat(32), 'once', 'jgd', nextHostDirectory), + 'approve\n', + ); + assert.strictEqual(prompts.length, 2); + }); + + test('packages outside the allowlist are declined without a prompt', async () => { + const broker = service(); + await broker.start(); + assert.strictEqual(await answerFor('4'.repeat(32), 'always', 'unlisted'), 'decline\n'); + assert.strictEqual(prompts.length, 0); + }); + + test('the prompt request carries the package and policy', async () => { + const broker = service(); + await broker.start(); + assert.strictEqual(await answerFor('5'.repeat(32), 'once', 'rstudioapi'), 'approve\n'); + assert.strictEqual(prompts[0].policy, 'once'); + assert.strictEqual(prompts[0].packages[0].name, 'rstudioapi'); + }); }); diff --git a/src/test/suite/session.test.ts b/src/test/suite/session.test.ts index e4dcb068a..960e599f5 100644 --- a/src/test/suite/session.test.ts +++ b/src/test/suite/session.test.ts @@ -1,4 +1,4 @@ -import { JGD_MINIMUM_VERSION } from '../../jgdVersion'; +import { TOOL_MINIMUM_VERSIONS } from '../../toolVersions'; import * as vscode from 'vscode'; import * as sinon from 'sinon'; import * as assert from 'assert'; @@ -995,12 +995,21 @@ suite('Session Communication', () => { assert.match(source, /tool_consent_directory = tool_consent_dir/); assert.match(source, /tool_library_helper = tool_library_helper/); assert.match(source, /tool_consent_helper = tool_consent_helper/); - assert.match(source, /prepare-jgd\.R/); + assert.match(source, /prepare-tools\.R/); assert.match(source, /jgd_can_prompt <- (?:TRUE|FALSE)/); - assert.match(source, /jgd_helper = jgd_helper/); + assert.match(source, /rstudioapi_can_prompt <- (?:TRUE|FALSE)/); + assert.match(source, /tool_prepare_helper = tool_prepare_helper/); assert.match(source, /jgd_can_prompt = jgd_can_prompt/); + assert.match(source, /rstudioapi_can_prompt = rstudioapi_can_prompt/); assert.ok( - source.includes(`jgd_minimum_version = ${JSON.stringify(JGD_MINIMUM_VERSION)}`), + source.includes( + `jgd_minimum_version = ${JSON.stringify(TOOL_MINIMUM_VERSIONS.jgd)}`, + ), + ); + assert.ok( + source.includes( + `rstudioapi_minimum_version = ${JSON.stringify(TOOL_MINIMUM_VERSIONS.rstudioapi)}`, + ), ); const backgroundSource = await fsp.readFile(backgroundPreparation.scriptPath, 'utf8'); assert.ok( @@ -1381,10 +1390,21 @@ suite('Session Communication', () => { assert.ok(preparation); const source = await fsp.readFile(preparation!.scriptPath, 'utf8'); assert.match(source, /tool_consent_dir <- ""/); + assert.match(source, /jgd_can_prompt <- FALSE/); + assert.match(source, /rstudioapi_can_prompt <- FALSE/); assert.match(source, /vscode_r_attach_sess\(/); preparation!.dispose(); }); + test('jgd install offers are limited to the jgd and auto plot backends', async () => { + await session.getToolConsentDirectory(); + // An explicit jgd backend is offered every time, so it does not depend on prior prompts. + assert.strictEqual(session.canPromptForJgdInstall('jgd'), true); + for (const backend of ['httpgd', 'standard', 'native']) { + assert.strictEqual(session.canPromptForJgdInstall(backend), false); + } + }); + test('sess and tool consent use separate directories and both are cleaned up on shutdown', async () => { const sessDirectory = await session.getSessConsentDirectory(); const toolDirectory = await session.getToolConsentDirectory(); diff --git a/src/test/suite/terminal.test.ts b/src/test/suite/terminal.test.ts index 2c92b3550..f01debbad 100644 --- a/src/test/suite/terminal.test.ts +++ b/src/test/suite/terminal.test.ts @@ -1,4 +1,4 @@ -import { JGD_MINIMUM_VERSION } from '../../jgdVersion'; +import { TOOL_MINIMUM_VERSIONS } from '../../toolVersions'; import * as vscode from 'vscode'; import * as sinon from 'sinon'; import * as assert from 'assert'; @@ -858,9 +858,21 @@ suite('R Terminal', () => { path.join('R', 'tool-consent.R'), ), ); - assert.ok(options.env?.['VSCODE_R_JGD_HELPER']?.endsWith(path.join('R', 'prepare-jgd.R'))); - assert.strictEqual(options.env?.['VSCODE_R_JGD_MINIMUM_VERSION'], JGD_MINIMUM_VERSION); + assert.ok( + options.env?.['VSCODE_R_TOOL_PREPARE_HELPER']?.endsWith( + path.join('R', 'prepare-tools.R'), + ), + ); + assert.strictEqual( + options.env?.['VSCODE_R_JGD_MINIMUM_VERSION'], + TOOL_MINIMUM_VERSIONS.jgd, + ); assert.strictEqual(options.env?.['VSCODE_R_JGD_CAN_PROMPT'], 'FALSE'); + assert.strictEqual( + options.env?.['VSCODE_R_RSTUDIOAPI_MINIMUM_VERSION'], + TOOL_MINIMUM_VERSIONS.rstudioapi, + ); + assert.strictEqual(options.env?.['VSCODE_R_RSTUDIOAPI_CAN_PROMPT'], 'FALSE'); sinon.assert.notCalled(getRpath); sinon.assert.notCalled(queryR); sinon.assert.notCalled(installTask); diff --git a/src/test/suite/toolConsentAdapter.test.ts b/src/test/suite/toolConsentAdapter.test.ts index 67ad3a149..21df5ed19 100644 --- a/src/test/suite/toolConsentAdapter.test.ts +++ b/src/test/suite/toolConsentAdapter.test.ts @@ -1,18 +1,18 @@ import * as assert from 'assert'; -import { JGD_MINIMUM_VERSION } from '../../jgdVersion'; import * as fs from 'node:fs/promises'; import * as os from 'os'; import * as path from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; import { createToolConsentService } from '../../toolConsentAdapter'; +import { TOOL_MINIMUM_VERSIONS } from '../../toolVersions'; suite('Managed package consent persistence', () => { test('dismissal applies to the same minimum version and a changed minimum prompts again', async () => { const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'vscode-r-tool-adapter-')); const sandbox = sinon.createSandbox(); const values = new Map([ - ['jgdInstallPromptDismissedMinimumVersion', JGD_MINIMUM_VERSION], + ['jgdInstallPromptDismissedMinimumVersion', TOOL_MINIMUM_VERSIONS.jgd], ]); const globalState: vscode.Memento = { keys: () => [...values.keys()], @@ -29,25 +29,25 @@ suite('Managed package consent persistence', () => { const broker = createToolConsentService(directory, () => true, globalState, 5); try { await broker.start(); - assert.strictEqual(broker.canPrompt('auto'), false); - assert.strictEqual(broker.canPrompt('jgd'), true); + assert.strictEqual(broker.canPrompt(['jgd'], 'once'), false); + assert.strictEqual(broker.canPrompt(['jgd'], 'always'), true); // A prior minimum's dismissal permits an offer for the current minimum. values.set('jgdInstallPromptDismissedMinimumVersion', '0.0.1'); - assert.strictEqual(broker.canPrompt('auto'), true); - for (const [index, backend] of ['auto', 'jgd'].entries()) { + assert.strictEqual(broker.canPrompt(['jgd'], 'once'), true); + for (const [index, policy] of (['once', 'always'] as const).entries()) { const id = String(index + 1).repeat(32); const content = [ 'vscode-r-package-consent-v1', id, 'linux-x86_64|4.5', - backend, + policy, '1', 'jgd', - JGD_MINIMUM_VERSION, + TOOL_MINIMUM_VERSIONS.jgd, '1.2.0', 'https://cloud.r-project.org', - backend === 'auto' ? 'source' : 'win.binary', - backend === 'auto' ? 'yes' : 'no', + policy === 'once' ? 'source' : 'win.binary', + policy === 'once' ? 'yes' : 'no', '', ].join('\n'); await fs.writeFile(path.join(directory, id + '.request'), content); @@ -57,7 +57,7 @@ suite('Managed package consent persistence', () => { try { assert.strictEqual( await fs.readFile(response, 'utf8'), - backend === 'auto' ? 'decline\n' : 'approve\n', + policy === 'once' ? 'decline\n' : 'approve\n', ); break; } catch (error) { @@ -72,10 +72,10 @@ suite('Managed package consent persistence', () => { } } assert.deepStrictEqual(update.args, [ - ['jgdInstallPromptDismissedMinimumVersion', JGD_MINIMUM_VERSION], + ['jgdInstallPromptDismissedMinimumVersion', TOOL_MINIMUM_VERSIONS.jgd], ]); - assert.strictEqual(broker.canPrompt('auto'), false); - assert.strictEqual(broker.canPrompt('jgd'), true); + assert.strictEqual(broker.canPrompt(['jgd'], 'once'), false); + assert.strictEqual(broker.canPrompt(['jgd'], 'always'), true); assert.strictEqual(prompt.callCount, 2); assert.ok(prompt.firstCall.args[0].includes('(source; minimum ')); assert.ok(prompt.secondCall.args[0].includes('(binary; minimum ')); @@ -85,4 +85,70 @@ suite('Managed package consent persistence', () => { await fs.rm(directory, { recursive: true, force: true }); } }); + + test('rstudioapi has its own dismissal key and prompt text', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'vscode-r-tool-adapter-')); + const sandbox = sinon.createSandbox(); + const values = new Map([ + ['jgdInstallPromptDismissedMinimumVersion', TOOL_MINIMUM_VERSIONS.jgd], + ]); + const globalState: vscode.Memento = { + keys: () => [...values.keys()], + get: (key: string, fallback?: T) => (values.get(key) as T | undefined) ?? fallback, + update: (key: string, value: unknown) => { + values.set(key, value); + return Promise.resolve(); + }, + }; + const update = sandbox.spy(globalState, 'update'); + const prompt = sandbox.stub(vscode.window, 'showWarningMessage'); + prompt.onFirstCall().resolves("Don't ask again" as never); + const broker = createToolConsentService(directory, () => true, globalState, 5); + try { + await broker.start(); + // The remembered jgd choice does not hide the rstudioapi offer. + assert.strictEqual(broker.canPrompt(['jgd'], 'once'), false); + assert.strictEqual(broker.canPrompt(['rstudioapi'], 'once'), true); + const id = '3'.repeat(32); + await fs.writeFile( + path.join(directory, id + '.request'), + [ + 'vscode-r-package-consent-v1', + id, + 'linux-x86_64|4.5', + 'once', + '1', + 'rstudioapi', + TOOL_MINIMUM_VERSIONS.rstudioapi, + '0.17.1', + 'https://cloud.r-project.org', + 'source', + 'no', + '', + ].join('\n'), + ); + const response = path.join(directory, id + '.response'); + const deadline = Date.now() + 3000; + while (!(await fs.stat(response).catch(() => undefined)) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.strictEqual(await fs.readFile(response, 'utf8'), 'decline\n'); + assert.deepStrictEqual(update.args, [ + [ + 'rstudioapiInstallPromptDismissedMinimumVersion', + TOOL_MINIMUM_VERSIONS.rstudioapi, + ], + ]); + assert.strictEqual(broker.canPrompt(['rstudioapi'], 'once'), false); + assert.strictEqual(prompt.callCount, 1); + const text = prompt.firstCall.args[0] as string; + assert.ok(text.includes('rstudioapi 0.17.1')); + assert.ok(text.includes('RStudio API emulation is optional')); + assert.ok(!/plot/i.test(text)); + } finally { + await broker.stop(); + sandbox.restore(); + await fs.rm(directory, { recursive: true, force: true }); + } + }); }); diff --git a/src/toolConsent.ts b/src/toolConsent.ts index 02ef238fa..bdbd9b769 100644 --- a/src/toolConsent.ts +++ b/src/toolConsent.ts @@ -1,10 +1,16 @@ import { ConsentResponse, FileConsentService } from './fileConsent'; +import { isToolPackageName } from './toolVersions'; -export type ToolConsentBackend = 'jgd' | 'auto'; +/** + * `always` asks on every request; `once` asks at most once per host session for each package + * and honors a remembered "Don't ask again". + */ +export type ToolConsentPolicy = 'always' | 'once'; export type ToolConsentChoice = 'install' | 'notNow' | 'dontAskAgain' | 'dismiss'; export interface ToolConsentSessionState { - autoPrompted: boolean; + /** Packages already offered under the `once` policy in this extension host. */ + promptedPackages: Set; } export interface ToolPackagePlan { @@ -19,16 +25,16 @@ export interface ToolPackagePlan { export interface ToolConsentRequest { id: string; runtime: string; - backend: ToolConsentBackend; + policy: ToolConsentPolicy; packages: ToolPackagePlan[]; } export interface ToolConsentServiceOptions { directory: string; isEnabled: () => boolean; - isAutoPromptEnabled: () => boolean; + isPromptEnabled: (packageName: string) => boolean; sessionState: ToolConsentSessionState; - rememberAutoPromptDismissal: () => Promise; + rememberPromptDismissal: (packageName: string) => Promise; prompt: (request: ToolConsentRequest) => Promise; intervalMs?: number; } @@ -54,11 +60,11 @@ export function parseToolConsentRequest(id: string, content: string): ToolConsen throw new Error('Malformed package consent request.'); } const runtime = lines[2]; - const backend = lines[3]; + const policy = lines[3]; const count = Number(lines[4]); if ( !RUNTIME.test(runtime) || - (backend !== 'jgd' && backend !== 'auto') || + (policy !== 'always' && policy !== 'once') || !/^(?:[1-9]|1[0-6])$/.test(lines[4]) || count < 1 || count > MAX_PACKAGES || @@ -94,7 +100,7 @@ export function parseToolConsentRequest(id: string, content: string): ToolConsen sourceBuild: sourceBuild === 'yes', }); } - return { id, runtime, backend, packages }; + return { id, runtime, policy, packages }; } /** Bridges a fixed R package plan to a one-time VS Code decision. Use a directory dedicated to this service. */ @@ -124,39 +130,37 @@ export class ToolConsentService { return this.bridge.stop(); } - canPrompt(backend: string): boolean { - if (!this.running || !this.options.isEnabled()) { + /** Whether a request for these packages under this policy could currently be shown. */ + canPrompt(packageNames: readonly string[], policy: ToolConsentPolicy): boolean { + if (!this.running || !this.options.isEnabled() || packageNames.length === 0) { return false; } - if (backend === 'jgd') { - return true; - } - return ( - backend === 'auto' && - this.options.isAutoPromptEnabled() && - !this.options.sessionState.autoPrompted - ); + return packageNames.every((name) => { + if (!isToolPackageName(name)) { + return false; + } + return ( + policy === 'always' || + (this.options.isPromptEnabled(name) && + !this.options.sessionState.promptedPackages.has(name)) + ); + }); } private async processRequest( request: ToolConsentRequest, respond: (response: ConsentResponse) => Promise, ): Promise { - if (!this.running || !this.options.isEnabled()) { - await respond('decline'); - return; - } - const automatic = request.backend === 'auto'; - if (!this.canPrompt(request.backend)) { + const names = request.packages.map((item) => item.name); + const once = request.policy === 'once'; + if (!this.canPrompt(names, request.policy)) { await respond('decline'); return; } - if (automatic) { - this.options.sessionState.autoPrompted = true; - } - if (!this.running || !this.options.isEnabled()) { - await respond('decline'); - return; + if (once) { + for (const name of names) { + this.options.sessionState.promptedPackages.add(name); + } } let choice: ToolConsentChoice = 'dismiss'; try { @@ -168,9 +172,11 @@ export class ToolConsentService { await respond('decline'); return; } - if (automatic && choice === 'dontAskAgain') { + if (once && choice === 'dontAskAgain') { try { - await this.options.rememberAutoPromptDismissal(); + for (const name of names) { + await this.options.rememberPromptDismissal(name); + } } catch { await respond('decline'); return; diff --git a/src/toolConsentAdapter.ts b/src/toolConsentAdapter.ts index bd120034e..816f01620 100644 --- a/src/toolConsentAdapter.ts +++ b/src/toolConsentAdapter.ts @@ -1,15 +1,33 @@ import * as vscode from 'vscode'; -import { JGD_MINIMUM_VERSION } from './jgdVersion'; import { ToolConsentChoice, ToolConsentRequest, ToolConsentService, ToolConsentSessionState, } from './toolConsent'; +import { isToolPackageName, TOOL_MINIMUM_VERSIONS, ToolPackageName } from './toolVersions'; -const JGD_INSTALL_PROMPT_DISMISSED_MINIMUM_VERSION = 'jgdInstallPromptDismissedMinimumVersion'; +// One global-state key per package, for example jgdInstallPromptDismissedMinimumVersion. +function dismissedMinimumVersionKey(packageName: string): string { + return `${packageName}InstallPromptDismissedMinimumVersion`; +} // Shared by services in this extension host; a new host starts with a fresh state. -const hostSessionState: ToolConsentSessionState = { autoPrompted: false }; +const hostSessionState: ToolConsentSessionState = { promptedPackages: new Set() }; + +const OPTIONAL_NOTICES: Record = { + jgd: 'Automatic plot backend setup is optional; declining uses the available fallback.', + rstudioapi: 'RStudio API emulation is optional; declining skips installing rstudioapi.', +}; + +function optionalNotice(request: ToolConsentRequest): string { + if (request.policy !== 'once') { + return ''; + } + const notices = request.packages.flatMap((item) => + isToolPackageName(item.name) ? [OPTIONAL_NOTICES[item.name]] : [], + ); + return notices.length > 0 ? `\n\n${notices.join(' ')}` : ''; +} function formatPlan(request: ToolConsentRequest): string { const [platform, version] = request.runtime.split('|'); @@ -20,11 +38,7 @@ function formatPlan(request: ToolConsentRequest): string { const buildNotice = request.packages.some((item) => item.sourceBuild) ? '\n\nA source build may be required and may need system build tools.' : ''; - const modeNotice = - request.backend === 'auto' - ? '\n\nAutomatic plot backend setup is optional; declining uses the available fallback.' - : ''; - return `Install these R packages for R ${version} (${platform})?\n\n${rows.join('\n')}${buildNotice}${modeNotice}\n\nPackages are installed in a vscode-R-managed library. Existing user and project libraries are not changed.`; + return `Install these R packages for R ${version} (${platform})?\n\n${rows.join('\n')}${buildNotice}${optionalNotice(request)}\n\nPackages are installed in a vscode-R-managed library. Existing user and project libraries are not changed.`; } export function createToolConsentService( @@ -36,14 +50,18 @@ export function createToolConsentService( return new ToolConsentService({ directory, isEnabled, - isAutoPromptEnabled: () => - globalState.get(JGD_INSTALL_PROMPT_DISMISSED_MINIMUM_VERSION) !== - JGD_MINIMUM_VERSION, + isPromptEnabled: (packageName) => + isToolPackageName(packageName) && + globalState.get(dismissedMinimumVersionKey(packageName)) !== + TOOL_MINIMUM_VERSIONS[packageName], sessionState: hostSessionState, - rememberAutoPromptDismissal: async () => { + rememberPromptDismissal: async (packageName) => { + if (!isToolPackageName(packageName)) { + throw new Error(`Unknown managed tool package: ${packageName}`); + } await globalState.update( - JGD_INSTALL_PROMPT_DISMISSED_MINIMUM_VERSION, - JGD_MINIMUM_VERSION, + dismissedMinimumVersionKey(packageName), + TOOL_MINIMUM_VERSIONS[packageName], ); }, intervalMs, @@ -52,7 +70,7 @@ export function createToolConsentService( const notNow = 'Not now'; const dontAskAgain = "Don't ask again"; const choice = - request.backend === 'auto' + request.policy === 'once' ? await vscode.window.showWarningMessage( formatPlan(request), install, diff --git a/src/toolVersions.ts b/src/toolVersions.ts new file mode 100644 index 000000000..40d89128d --- /dev/null +++ b/src/toolVersions.ts @@ -0,0 +1,13 @@ +// Minimum versions of the CRAN packages vscode-R can install into its managed tool library. +// Shared by consent eligibility and R package preparation. +export const TOOL_MINIMUM_VERSIONS = { + jgd: '0.1.0', + // The first release that exports the document position and range helpers used by sess. + rstudioapi: '0.4.0', +} as const; + +export type ToolPackageName = keyof typeof TOOL_MINIMUM_VERSIONS; + +export function isToolPackageName(name: string): name is ToolPackageName { + return Object.prototype.hasOwnProperty.call(TOOL_MINIMUM_VERSIONS, name); +}