# Migrate Studio → R Shiny: 08 Packages, launcher and deployment ## 8.1 Packages for R 4.2.3 | Package | Needed for | |---|---| | shiny 1.12.1 | the app (brings httpuv, htmltools, jsonlite, later, promises, and others) | | jsonlite, digest | the R engine and the dispatcher | | reticulate | Python engine and batch fixer (optional) | | readxl | the rule count printed at start-up (optional) | | testthat | tests (optional) | R 4.2.3 is an older release, and current CRAN builds of some dependencies may need a newer R. Install from a **dated snapshot** so every package is a version that supports R 4.2.3 and shiny 1.12.1. With Posit Package Manager or an internal CRAN mirror, pick a snapshot date on or after the shiny 1.12.1 release, then lock it with renv: ```r # Run once in the app folder, with R 4.2.3. options(repos = c(CRAN = "https://packagemanager.posit.co/cran/")) install.packages("renv") renv::init(bare = TRUE) install.packages(c("shiny", "jsonlite", "digest", "reticulate", "readxl", "testthat")) stopifnot(packageVersion("shiny") == "1.12.1") # otherwise pick a different snapshot date renv::snapshot() ``` If the snapshot gives a different shiny version: ```r remotes::install_version("shiny", version = "1.12.1") ``` Check `remotes` output for any dependency that refuses R 4.2.3. I could not confirm the dependency requirements of shiny 1.12.1 from here. Run the environment check below and the tests in 07 on the target machine before release. **Python (optional).** Python 3.9 or newer, standard library only, for the app; `openpyxl` for `mig_apply.py`. Point R at it with `MIGRATE_STUDIO_PYTHON=/path/to/python`. ### Environment check ```r #!/usr/bin/env Rscript # Rscript tools/check_env.R -- report whether this machine matches the target # (R 4.2.3, shiny 1.12.1) and which optional pieces are available. target <- list(R = "4.2.3", shiny = "1.12.1") row <- function(item, have, want = "", note = "") { cat(sprintf("%-12s %-12s %-10s %s\n", item, have, want, note)) } ver <- function(pkg) if (requireNamespace(pkg, quietly = TRUE)) as.character(utils::packageVersion(pkg)) else "missing" cat(sprintf("%-12s %-12s %-10s %s\n", "component", "installed", "target", "note")) row("R", as.character(getRversion()), target$R, if (getRversion() == target$R) "ok" else "differs from the validated version") row("shiny", ver("shiny"), target$shiny, if (ver("shiny") == target$shiny) "ok" else "differs from the validated version") for (pkg in c("jsonlite", "digest", "httpuv", "htmltools")) row(pkg, ver(pkg), "", "required") for (pkg in c("reticulate", "readxl", "testthat")) row(pkg, ver(pkg), "", "optional") if (requireNamespace("reticulate", quietly = TRUE)) { py <- Sys.getenv("MIGRATE_STUDIO_PYTHON", "") if (nzchar(py)) try(reticulate::use_python(py, required = TRUE), silent = TRUE) if (reticulate::py_available(initialize = TRUE)) { cfg <- reticulate::py_config() row("python", as.character(cfg$version), "3.9+", cfg$python) row("openpyxl", if (reticulate::py_module_available("openpyxl")) "found" else "missing", "", "needed only by mig_apply.py / --check") } else row("python", "none", "3.9+", "R engine will be used") } row("node", if (nzchar(Sys.which("node"))) "found" else "missing", "", "only for tools/run_js_tests.R") ``` ```sh Rscript tools/check_env.R ``` ## 8.2 Launcher `run_migrate_studio.R` is the R counterpart of `python migrate_studio.py`: - **Same checks:** it checks for required files and prints the rule book summary. - **Same ports:** it takes the first free port in 8820–8869 (an explicit `--port` is used as given, or refused if busy). - **Same address:** it opens `http://localhost:/`, which keeps the browser's remembered folder and local history from the Python app. ```r #!/usr/bin/env Rscript # Migrate Studio (Shiny edition) -- start here. The R twin of # `python migrate_studio.py`. # # Rscript run_migrate_studio.R # Rscript run_migrate_studio.R --port 9100 # Rscript run_migrate_studio.R --host 0.0.0.0 # Rscript run_migrate_studio.R --programs /studies/STUDY001 --programs /studies/STUDY002 # Rscript run_migrate_studio.R --no-open # Rscript run_migrate_studio.R --check # Rscript run_migrate_studio.R --engine r # skip Python entirely # Rscript run_migrate_studio.R --folder-mode server --server-roots /studies # # From an R console: source("run_migrate_studio.R") with the working directory # set to this folder, or call ms_run(port = 8820). MS_START_PORT <- 8820L # TLF Studio owns 8770-8819, as in the Python app MS_END_PORT <- 8869L MS_REQUIRED <- c("app.R", "R/config.R", "R/api_native.R", "R/py_bridge.R", "R/fs_server.R", "R/api_dispatch.R", "www/index.html", "www/css/migrate-studio.css", "www/css/theme.css", "www/js/mig-excel.js", "www/js/mig-rules.js", "www/js/mig-diff.js", "www/js/migrate-studio.js", "www/js/mig-shiny-bridge.js", "www/js/mig-server-fs.js", "www/js/vendor/fflate.min.js", "python/migrate_studio.py") ms_script_dir <- function() { args <- commandArgs(trailingOnly = FALSE) file_arg <- sub("^--file=", "", args[grepl("^--file=", args)]) if (length(file_arg)) return(dirname(normalizePath(file_arg[[1]]))) ofile <- tryCatch(sys.frames()[[1]]$ofile, error = function(e) NULL) if (!is.null(ofile)) return(dirname(normalizePath(ofile))) getwd() } ms_parse_args <- function(args) { out <- list(port = NULL, host = "127.0.0.1", programs = character(), open = TRUE, check = FALSE, engine = NULL, folder_mode = NULL, server_roots = character(), data = NULL) i <- 1L value <- function() { i <<- i + 1L; if (i > length(args)) stop("missing value for ", args[[i - 1L]]); args[[i]] } while (i <= length(args)) { switch(args[[i]], "--port" = out$port <- as.integer(value()), "--host" = out$host <- value(), "--programs" = out$programs <- c(out$programs, value()), "--no-open" = out$open <- FALSE, "--check" = out$check <- TRUE, "--engine" = out$engine <- value(), "--folder-mode" = out$folder_mode <- value(), "--server-roots" = out$server_roots <- c(out$server_roots, value()), stop("unknown argument: ", args[[i]])) i <- i + 1L } out } ms_port_available <- function(host, port) { tryCatch({ server <- httpuv::startServer(host, port, list(call = function(req) NULL)) httpuv::stopServer(server) TRUE }, error = function(e) FALSE) } ms_select_port <- function(host, wanted) { if (!is.null(wanted)) { if (ms_port_available(host, wanted)) return(wanted) message("ERROR: port ", wanted, " is already in use.") return(NULL) } for (port in MS_START_PORT:MS_END_PORT) { if (ms_port_available(host, port)) return(port) message("Port ", port, " is in use. Checking the next available port.") } message("ERROR: all ports ", MS_START_PORT, "-", MS_END_PORT, " are in use. ", "Close another Migrate Studio, or pass --port.") NULL } ms_check_layout <- function(app_dir) { missing <- MS_REQUIRED[!file.exists(file.path(app_dir, MS_REQUIRED))] if (!length(missing)) return(TRUE) message("Migrate Studio is missing files it needs:\n ", paste(missing, collapse = "\n "), "\nLooked in: ", app_dir) FALSE } ms_describe_rules <- function(app_dir) { rules <- file.path(app_dir, "rules/Migrate_Studio_Rules.xlsx") if (!file.exists(rules)) { message("No Migrate_Studio_Rules.xlsx in this folder. Migrate Studio will start ", "without a rule book; use Import Rules.") return(invisible(FALSE)) } if (requireNamespace("readxl", quietly = TRUE)) { sheets <- readxl::excel_sheets(rules) sheet <- if ("Rules" %in% sheets) "Rules" else sheets[[1]] grid <- readxl::read_excel(rules, sheet = sheet, col_names = FALSE, .name_repair = "minimal") filled <- rowSums(!is.na(grid) & trimws(as.matrix(grid)) != "") grid <- grid[filled > 0, , drop = FALSE]; filled <- filled[filled > 0] header <- which(filled >= 4)[1]; if (is.na(header)) header <- 1L count <- nrow(grid) - header message("Rule book: ", basename(rules), " - ", sheet, " sheet - ", count, " rule", if (count == 1) "" else "s") } else { message("Rule book: ", basename(rules)) } invisible(TRUE) } ms_run <- function(port = NULL, host = "127.0.0.1", programs = character(), open = TRUE, engine = NULL, folder_mode = NULL, server_roots = character(), data = NULL, app_dir = ms_script_dir()) { app_dir <- normalizePath(app_dir, winslash = "/") if (!ms_check_layout(app_dir)) return(invisible(2L)) sep <- .Platform$path.sep if (length(programs)) options(migrate_studio.programs = paste(programs, collapse = sep)) if (length(server_roots)) { options(migrate_studio.server_roots = paste(server_roots, collapse = sep)) } if (!is.null(engine)) options(migrate_studio.engine = engine) if (!is.null(folder_mode)) options(migrate_studio.folder_mode = folder_mode) if (!is.null(data)) options(migrate_studio.data_dir = data) options(migrate_studio.app_dir = app_dir) port <- ms_select_port(host, port) if (is.null(port)) return(invisible(2L)) # Same origin as the Python app (http://localhost:), so the browser's # remembered folder and local version history carry over. url_host <- if (host %in% c("127.0.0.1", "0.0.0.0")) "localhost" else host url <- sprintf("http://%s:%d/", url_host, port) message("\nMigrate Studio is serving ", app_dir) ms_describe_rules(app_dir) message("\n ", url, "\n\nPress Ctrl+C (Esc in RStudio) to stop.\n") shiny::runApp(app_dir, port = port, host = host, launch.browser = if (open) function(u) utils::browseURL(url) else FALSE) invisible(0L) } if (sys.nframe() == 0L || !interactive()) { args <- ms_parse_args(commandArgs(trailingOnly = TRUE)) app_dir <- ms_script_dir() if (isTRUE(args$check)) { ok <- file.exists(file.path(app_dir, "rules/Migrate_Studio_Rules.xlsx")) if (ok) ms_describe_rules(app_dir) else message("No rule book in ", app_dir) quit(status = if (ok) 0L else 1L) } status <- ms_run(port = args$port, host = args$host, programs = args$programs, open = args$open, engine = args$engine, folder_mode = args$folder_mode, server_roots = args$server_roots, data = args$data, app_dir = app_dir) quit(status = status) } ``` | Python | R | |---|---| | `python migrate_studio.py` | `Rscript run_migrate_studio.R` | | `--port 9100`, `--host 0.0.0.0`, `--programs PATH` (repeatable), `--no-open`, `--check` | same flags | | — | `--engine python\|r\|auto`, `--folder-mode browser\|server`, `--server-roots PATH` (repeatable), `--data-dir DIR`, `--import-legacy-history [PATH]` | In RStudio, set the working directory to the app folder, then: ```r source("run_migrate_studio.R") ms_run(programs = "C:/studies/STUDY001") ``` The app opens in the system browser. Use **Chrome or Edge**: the folder read/write API that browser mode depends on isn't available in Firefox or Safari (those fall back to read-only with downloads, as today). The RStudio Viewer pane isn't recommended. Stop the app with Esc in RStudio, Ctrl+C in a terminal, or File → Log out. A Windows shortcut can run: ``` "C:\Program Files\R\R-4.2.3\bin\Rscript.exe" "C:\Tools\migrate_studio_shiny\run_migrate_studio.R" --programs "D:\studies" ``` ## 8.3 Desktop deployment (replaces the Python app) 1. Build the folder as in 01–03 (and 05 if needed), or unzip the reference build. 2. Nothing needs copying if the Python desktop app already uses the shared per-user data folder. Otherwise run `Rscript run_migrate_studio.R --import-legacy-history ` once. The other data files are caches and rebuild themselves. 3. Stop the Python app. Start the R app **on the same port** as the Python app used (usually 8820). Then the remembered folder and in-browser version history carry over, because browsers keep those per address. 4. Run the checklist in 07.4, items 10–13. **Binding address.** `--host 0.0.0.0` lets other machines reach the app, as with the Python app. In browser mode, who-am-I and the backup calls still answer only the local machine, matching `migrate_studio.py`. ## 8.4 Posit Connect, Posit Workbench, Shiny Server Deploy the folder as a Shiny app with `app.R` as the entry point. Leave `samples/`, `tests/`, `tools/` and `data/` out of the bundle: ```r rsconnect::deployApp( appDir = ".", appFiles = c("app.R", "R", "www", "python", "rules/rules/Migrate_Studio_Rules.xlsx", "CHANGELOG.md"), appTitle = "Migrate Studio" ) ``` Settings (environment variables in the Connect "Vars" panel, or in the Shiny Server site config): | Variable | Value | Why | |---|---|---| | `MIGRATE_STUDIO_FOLDER_MODE` | `server` | programs are on the server (05) | | `MIGRATE_STUDIO_SERVER_ROOTS` | e.g. `/studies:/shared/sce` | allowlist | | `MIGRATE_STUDIO_DATA_DIR` | e.g. `/srv/migrate_studio/data` | outside the bundle, which is replaced on every redeploy; must be writable by the run-as account | | `MIGRATE_STUDIO_STOP_ON_LOGOUT` | `false` | Log out closes only that user's session | | `MIGRATE_STUDIO_ENGINE` | `python` or `r` | fixed, so the validated configuration is known | | `MIGRATE_STUDIO_PYTHON` | interpreter path | Python engine only | Points to decide: - **Run-as account.** Files are read and written by the account that runs the app. On Connect, set "Run as" to a service account with access to the study roots, or use "Run as current user" where Connect is configured for it (PAM). Open-source Shiny Server uses its `run_as` setting (default `shiny`). Backups and fixed programs are owned by that account; see 04. - **Data folder account.** With no explicit data folder, the OS default belongs to the account running R, not necessarily the person using the browser. | Deployment | Run-as account | What the OS default gives you | |---|---|---| | Desktop (`run_migrate_studio.R`) | signed-in user | a true per-user folder | | Posit Workbench user session | signed-in user | a per-user folder in that server home | | Connect with Run as current user | signed-in user | a per-user folder in that server home | | Connect with a service account, or Shiny Server `run_as shiny` | one shared account | one folder shared by every user, in that account's home | For shared-account deployments, set `MIGRATE_STUDIO_DATA_DIR` to a persistent managed folder. Server folder mode logs a warning when it is using the OS default. - **User identity.** When the server provides a signed-in user (`session$user`), it is recorded in the history instead of the process account. - **Processes.** `history.json` is written atomically, but separate R processes don't lock each other. Set Connect's *Max processes* to 1 for this app, or accept the small chance of two runs saved at the same moment overwriting one entry. - **Python on Connect.** For the Python engine, the server needs Python 3.9 or newer available to the app. `migrate_studio.py` uses only the standard library. - **HTTPS.** Connect and Workbench serve over HTTPS. Browser mode also works there, but R can't see the user's files, so backups are browser copies dated today (04). That is why server mode is recommended.