1/*
2 * detect-r.ts
3 *
4 * Copyright (C) 2021 by RStudio, PBC
5 *
6 * Unless you have received this program directly from RStudio pursuant
7 * to the terms of a commercial license agreement with RStudio, then
8 * this program is licensed to you under the terms of version 3 of the
9 * GNU Affero General Public License. This program is distributed WITHOUT
10 * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT,
11 * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the
12 * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details.
13 *
14 */
15
16import path from 'path';
17
18import { execSync, spawnSync } from 'child_process';
19import { dialog } from 'electron';
20import { existsSync } from 'fs';
21import { EOL } from 'os';
22
23import { Environment, getenv, setenv, setVars } from '../core/environment';
24import { Expected, ok, err } from '../core/expected';
25import { logger } from '../core/logger';
26import { Err, success } from '../core/err';
27import { ChooseRModalWindow } from '..//ui/widgets/choose-r';
28
29let kLdLibraryPathVariable : string;
30if (process.platform === 'darwin') {
31  kLdLibraryPathVariable = 'DYLD_FALLBACK_LIBRARY_PATH';
32} else {
33  kLdLibraryPathVariable = 'LD_LIBRARY_PATH';
34}
35
36interface REnvironment {
37  rScriptPath: string,
38  version: string,
39  envVars: Environment,
40  ldLibraryPath: string
41}
42
43function showRNotFoundError(error?: Error): void {
44  const message = error?.message ?? 'Could not locate an R installation on the system.';
45  dialog.showErrorBox('R not found', message);
46}
47
48function executeCommand(command: string): Expected<string> {
49
50  try {
51    const output = execSync(command, { encoding: 'utf-8' });
52    return ok(output.trim());
53  } catch (error) {
54    return err(error);
55  }
56
57}
58
59export async function promptUserForR(): Promise<Expected<string | null>> {
60
61  // nothing to do if RSTUDIO_WHICH_R is set
62  const rstudioWhichR = getenv('RSTUDIO_WHICH_R');
63  if (rstudioWhichR) {
64    return ok(rstudioWhichR);
65  }
66
67  // discover available R installations
68  const rInstalls = findRInstallationsWin32();
69  if (rInstalls.length === 0) {
70    return err();
71  }
72
73  // ask the user what version of R they'd like to use
74  const dialog = new ChooseRModalWindow(rInstalls);
75  const [path, error] = await dialog.showModal();
76  if (error) {
77    return err(error);
78  }
79
80  // if path is null, the operation was cancelled
81  if (path == null) {
82    return ok(null);
83  }
84
85  // set RSTUDIO_WHICH_R to signal which version of R to be used
86  setenv('RSTUDIO_WHICH_R', path);
87  return ok(path);
88
89}
90
91/**
92 * Detect R and prepare environment for launching the rsession process.
93 *
94 * This entails setting environment variables relevant to R on startup
95 * // (for example, R_HOME) and other platform-specific work required
96 * for R to launch.
97 */
98export function prepareEnvironment(): Err {
99
100  try {
101    return prepareEnvironmentImpl();
102  } catch (error) {
103    logger().logError(error);
104    return error;
105  }
106
107}
108
109function prepareEnvironmentImpl(): Err {
110
111  // attempt to detect R environment
112  const [rEnvironment, error] = detectREnvironment();
113  if (error) {
114    showRNotFoundError(error);
115    return error;
116  }
117
118  // set environment variables from R
119  setVars(rEnvironment.envVars);
120
121  // on Linux + macOS, forward LD_LIBRARY_PATH and friends
122  if (process.platform !== 'win32') {
123    process.env[kLdLibraryPathVariable] = rEnvironment.ldLibraryPath;
124  }
125
126  // on Windows, ensure R is on the PATH so that companion DLLs
127  // in the same directory can be resolved
128  const scriptPath = rEnvironment.rScriptPath;
129  if (process.platform === 'win32') {
130    const binDir = path.dirname(scriptPath);
131    process.env.PATH = `${binDir};${process.env.PATH}`;
132  }
133
134  return success();
135
136}
137
138function detectREnvironment(): Expected<REnvironment> {
139
140  // scan for R
141  const [R, scanError] = scanForR();
142  if (scanError) {
143    showRNotFoundError();
144    return err(scanError);
145  }
146
147  // generate small script for querying information about R
148  const rQueryScript = String.raw`writeLines(c(
149  format(getRversion()),
150  R.home(),
151  R.home("doc"),
152  R.home("include"),
153  R.home("share"),
154  Sys.getenv("${kLdLibraryPathVariable}")
155))`;
156
157  const result = spawnSync(R, ['--vanilla', '-s'], {
158    encoding: 'utf-8',
159    input: rQueryScript,
160  });
161
162  if (result.error) {
163    return err(result.error);
164  }
165
166  // unwrap query results
167  const [
168    rVersion,
169    rHome,
170    rDocDir,
171    rIncludeDir,
172    rShareDir,
173    rLdLibraryPath,
174  ] = result.stdout.split(EOL);
175
176  // put it all together
177  return ok({
178    rScriptPath: R,
179    version: rVersion,
180    envVars: {
181      R_HOME:        rHome,
182      R_DOC_DIR:     rDocDir,
183      R_INCLUDE_DIR: rIncludeDir,
184      R_SHARE_DIR:   rShareDir,
185    },
186    ldLibraryPath: rLdLibraryPath,
187  });
188
189}
190
191function scanForR(): Expected<string> {
192
193  // if the RSTUDIO_WHICH_R environment variable is set, use that
194  const rstudioWhichR = getenv('RSTUDIO_WHICH_R');
195  if (rstudioWhichR) {
196    logger().logDebug(`Using ${rstudioWhichR} (found by RSTUDIO_WHICH_R environment variable)`);
197    return ok(rstudioWhichR);
198  }
199
200  // otherwise, use platform-specific lookup strategies
201  if (process.platform === 'win32') {
202    return scanForRWin32();
203  } else {
204    return scanForRPosix();
205  }
206
207}
208
209function scanForRPosix(): Expected<string> {
210
211  // first, look for R on the PATH
212  const [rLocation, error] = executeCommand('/usr/bin/which R');
213  if (!error && rLocation) {
214    logger().logDebug(`Using ${rLocation} (found by /usr/bin/which/R)`);
215    return ok(rLocation);
216  }
217
218  // otherwise, look in some hard-coded locations
219  const defaultLocations = [
220    '/opt/local/bin/R',
221    '/usr/local/bin/R',
222    '/usr/bin/R',
223  ];
224
225  // also check framework directory for macOS
226  if (process.platform === 'darwin') {
227    defaultLocations.push('/Library/Frameworks/R.framework/Resources/bin/R');
228  }
229
230  for (const location of defaultLocations) {
231    if (existsSync(location)) {
232      logger().logDebug(`Using ${rLocation} (found by searching known locations)`);
233      return ok(location);
234    }
235  }
236
237  // nothing found
238  return err();
239
240}
241
242function findRInstallationsWin32() {
243
244  const rInstallations : string[] = [];
245
246  // list all installed versions from registry
247  const keyName = 'HKEY_LOCAL_MACHINE\\SOFTWARE\\R-Core';
248  const regQueryCommand = `reg query ${keyName} /s /v InstallPath`;
249  const [output, error] = executeCommand(regQueryCommand);
250  if (error) {
251    logger().logError(error);
252    return rInstallations;
253  }
254
255  // parse the actual path from the output
256  const lines = output.split(EOL);
257  for (const line of lines) {
258    const match = /^\s*InstallPath\s*REG_SZ\s*(.*)$/.exec(line);
259    if (match != null) {
260      const rInstallation = match[1];
261      if (isValidInstallationWin32(rInstallation)) {
262        rInstallations.push(rInstallation);
263      }
264    }
265  }
266
267  return rInstallations;
268
269}
270
271function isValidInstallationWin32(installPath: string): boolean {
272  const rBinPath = path.normalize(`${installPath}/bin/R.exe`);
273  return existsSync(rBinPath);
274}
275
276function findDefaultInstallPathWin32(version: string): string {
277
278  // query registry for R install path
279  const keyName = `HKEY_LOCAL_MACHINE\\SOFTWARE\\R-Core\\${version}`;
280  const regQueryCommand = `reg query ${keyName} /v InstallPath`;
281  const [output, error] = executeCommand(regQueryCommand);
282  if (error) {
283    logger().logError(error);
284    return '';
285  }
286
287  // parse the actual path from the output
288  const lines = output.split(EOL);
289  for (const line of lines) {
290    const match = /^\s*InstallPath\s*REG_SZ\s*(.*)$/.exec(line);
291    if (match != null) {
292      const rLocation = match[1];
293      return rLocation;
294    }
295  }
296
297  return '';
298
299}
300
301function scanForRWin32(): Expected<string> {
302
303  // if the RSTUDIO_WHICH_R environment variable is set, use that
304  const rstudioWhichR = getenv('RSTUDIO_WHICH_R');
305  if (rstudioWhichR) {
306    logger().logDebug(`Using R ${rstudioWhichR} (found by RSTUDIO_WHICH_R environment variable)`);
307    return ok(rstudioWhichR);
308  }
309
310  // look for a 64-bit version of R
311  if (process.arch !== 'x32') {
312    const x64InstallPath = findDefaultInstallPathWin32('R64');
313    if (x64InstallPath && existsSync(x64InstallPath)) {
314      const rPath = `${x64InstallPath}/bin/x64/R.exe`;
315      logger().logDebug(`Using R ${rPath} (found via registry)`);
316      return ok(rPath);
317    }
318  }
319
320  // look for a 32-bit version of R
321  const i386InstallPath = findDefaultInstallPathWin32('R');
322  if (i386InstallPath && existsSync(i386InstallPath)) {
323    const rPath = `${i386InstallPath}/bin/i386/R.exe`;
324    logger().logDebug(`Using R ${rPath} (found via registry)`);
325    return ok(rPath);
326  }
327
328  // nothing found; return empty filepath
329  logger().logDebug('Failed to discover R');
330  return err();
331
332}
333
334export function findDefault32Bit(): string {
335  return findDefaultInstallPathWin32('R');
336}
337
338export function findDefault64Bit(): string {
339  return findDefaultInstallPathWin32('R64');
340}
341