Reference Source

lib/launcher/chromeFinder.js

/*
 Squidwarc  Copyright (C) 2017-present  John Berlin <n0tan3rd@gmail.com>

 This program is free software: you can redistribute it and/or modify
 it under the terms of the GNU General Public License as published by
 the Free Software Foundation, either version 3 of the License, or
 (at your option) any later version.

 Squidwarc is distributed in the hope that it will be useful,
 but WITHOUT ANY WARRANTY; without even the implied warranty of
 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 GNU General Public License for more details.

 You should have received a copy of the GNU General Public License
 along with this Squidwarc.  If not, see <http://www.gnu.org/licenses/>
 */

const cp = require('child_process')
const path = require('path')
const fs = require('fs-extra')

/**
 * @type {RegExp}
 */
const nlre = /\r?\n/
/**
 * @type {RegExp}
 */
const desktopArgRE = /(^[^ ]+).*/

/**
 * @desc Executes the supplied command
 * @param {string} someCommand
 * @param {boolean} [rejectOnError = false]
 * @returns {Promise<string>}
 */
function exec (someCommand, rejectOnError = false) {
  return new Promise((resolve, reject) => {
    cp.exec(someCommand, { encoding: 'utf8' }, (error, stdout, stderr) => {
      if (error && rejectOnError) reject(error)
      resolve(stdout.trim())
    })
  })
}

/**
 * @desc Executes the which command for the supplied executable name
 * @param {string} executable
 */
function which (executable) {
  return exec(`which ${executable}`)
}

/**
 * @desc Executes the ls command for the supplied path looking for .desktop files for Chrome or Chromium
 * @param {string} desktopPath
 * @returns {Promise<string[]>}
 */
function chromeDesktops (desktopPath) {
  // eslint-disable-next-line
  return exec(`ls ${desktopPath} | grep -E "\/.*\/(google|chrome|chromium)-.*"`).then(
    results => results.split(nlre)
  )
}

/**
 * @desc Extracts the Chrome or Chromium executable path from the .desktop file
 * @param {string} desktopPath
 * @returns {Promise<string[]>}
 */
async function desktopExePath (desktopPath) {
  let maybeResults
  // eslint-disable-next-line
  const patternPipe = `"^Exec=\/.*\/(google|chrome|chromium)-.*" ${desktopPath} | awk -F '=' '{print $2}'`
  try {
    maybeResults = await exec(`grep -ER ${patternPipe}`, true)
  } catch (e) {
    maybeResults = await exec(`grep -Er ${patternPipe}`)
  }
  const seen = new Set()
  let keep
  return maybeResults
    .split(nlre)
    .map(execPath => execPath.replace(desktopArgRE, '$1'))
    .filter(exePath => {
      keep = !seen.has(exePath)
      seen.add(exePath)
      return keep
    })
}

/**
 * @desc Tests (T|F) to see if the execPath is executable by this process
 * @param {string} execPath - The executable path to test
 * @returns {Promise<boolean>}
 */
async function bingo (execPath) {
  if (!execPath || execPath === '') return false
  try {
    await fs.access(execPath, fs.constants.X_OK)
    return true
  } catch (e) {
    return false
  }
}

/**
 * @desc Utility class that provides functionality for finding an suitable chrome executable
 */
class ChromeFinder {
  /**
   * @desc Finds an acceptable Chrome or Chromium executable.
   * If the env key 'CHROME_PATH' is defined that is returned by default
   * @returns {Promise<string>}
   */
  static async findChrome () {
    if (await bingo(process.env.CHROME_PATH)) {
      return process.env.CHROME_PATH
    }
    let plat = process.platform
    if (plat === 'linux') {
      return ChromeFinder.findChromeLinux()
    } else if (plat === 'darwin') {
      return ChromeFinder.findChromeDarwin()
    } else if (plat === 'win32') {
      return ChromeFinder.findChromeWindows()
    } else {
      throw new Error(`Unsupported platform ${plat}`)
    }
  }

  /**
   * @desc Finds an acceptable Chrome or Chromium executable on Linux
   * If one is not found throws
   * @throws Error - If an acceptable executable was not found
   * @returns {Promise<string>}
   */
  static async findChromeLinux () {
    const execs = [
      'google-chrome-unstable',
      'google-chrome-beta',
      'google-chrome-stable',
      'chromium-browser',
      'chromium'
    ]
    let i = 0
    let len = execs.length
    let commandResults
    // check which exec first
    while (i < len) {
      commandResults = await which(execs[i])
      if (await bingo(commandResults)) {
        return commandResults
      }
      i += 1
    }
    // which executable did not result in an exe so we must now check desktop files
    const desktops = [
      '/usr/share/applications/*.desktop',
      '~/.local/share/applications/*.desktop'
    ]
    len = desktops.length
    let len2
    let j = 0
    i = 0
    let found = []
    while (i < len) {
      commandResults = await chromeDesktops(desktops[i])
      len2 = commandResults.length
      while (j < len2) {
        found = found.concat(await desktopExePath(commandResults[j]))
        j += 1
      }
      i += 1
    }
    const desiredExes = [
      { regex: /google-chrome-unstable$/, weight: 52 },
      { regex: /google-chrome-beta$/, weight: 51 },
      { regex: /google-chrome-stable$/, weight: 50 },
      { regex: /google-chrome$/, weight: 49 },
      { regex: /chrome-wrapper$/, weight: 48 },
      { regex: /chromium-browser$/, weight: 47 },
      { regex: /chromium$/, weight: 46 }
    ]
    let sortedExes = found
      .map(exep => {
        for (const desired of desiredExes) {
          if (desired.regex.test(exep)) {
            return { exep, weight: desired.weight }
          }
        }
        return { exep, weight: 10 }
      })
      .sort((a, b) => b.weight - a.weight)
      .map(pair => pair.exep)
    if (sortedExes.length > 0) {
      return sortedExes[0]
    }
    throw new Error('No Chrome Installations Found')
  }

  /**
   * @desc Finds an acceptable Chrome or Chromium executable on MacOS
   * If one is not found throws
   * @throws Error - If an acceptable executable was not found
   * @returns {Promise<string>}
   */
  static async findChromeDarwin () {
    // shamelessly borrowed from chrome-launcher (https://github.com/GoogleChrome/chrome-launcher/blob/master/chrome-finder.ts)
    const suffixes = [
      '/Contents/MacOS/Google Chrome Canary',
      '/Contents/MacOS/Google Chrome'
    ]

    const LSREGISTER =
      '/System/Library/Frameworks/CoreServices.framework' +
      '/Versions/A/Frameworks/LaunchServices.framework' +
      '/Versions/A/Support/lsregister'
    const nlre = /\r?\n/
    const installations = []

    let commandResults = await exec(
      `${LSREGISTER} -dump | grep -i 'google chrome\\( canary\\)\\?.app$' | awk '{$1="" print $0}'`
    )
    let i = 0
    let split = commandResults.split(nlre)
    let len = split.length
    let execPath
    let inst

    while (i < len) {
      inst = split[i]
      execPath = path.join(inst.trim(), suffixes[0])
      if (await bingo(execPath)) {
        installations.push(execPath)
      }
      execPath = path.join(inst.trim(), suffixes[1])
      if (await bingo(execPath)) {
        installations.push(execPath)
      }
      i += 1
    }

    // Retains one per line to maintain readability.
    // clang-format off
    const priorities = [
      { regex: new RegExp(`^${process.env.HOME}/Applications/.*Chrome.app`), weight: 50 },
      {
        regex: new RegExp(`^${process.env.HOME}/Applications/.*Chrome Canary.app`),
        weight: 51
      },
      { regex: /^\/Applications\/.*Chrome.app/, weight: 100 },
      { regex: /^\/Applications\/.*Chrome Canary.app/, weight: 101 },
      { regex: /^\/Volumes\/.*Chrome.app/, weight: -2 },
      { regex: /^\/Volumes\/.*Chrome Canary.app/, weight: -1 }
    ]

    if (process.env.CHROME_PATH) {
      priorities.unshift({ regex: new RegExp(`${process.env.CHROME_PATH}`), weight: 151 })
    }
    const defaultPriority = 10
    let sortedExes = installations
      // assign priorities
      .map(inst => {
        for (const pair of priorities) {
          if (pair.regex.test(inst)) {
            return { path: inst, weight: pair.weight }
          }
        }
        return { path: inst, weight: defaultPriority }
      })
      // sort based on priorities
      .sort((a, b) => b.weight - a.weight)
      // remove priority flag
      .map(pair => pair.path)[0]
    if (sortedExes.length > 0) {
      return sortedExes[0]
    }
    throw new Error('No Chrome Installations Found')
  }

  /**
   * @desc Finds an acceptable Chrome or Chromium executable on Windows
   * If one is not found throws
   * @throws Error - If an acceptable executable was not found
   * @returns {Promise<string>}
   */
  static async findChromeWindows () {
    // shamelessly borrowed from chrome-launcher (https://github.com/GoogleChrome/chrome-launcher/blob/master/chrome-finder.ts)
    const installations = []
    const suffixes = [
      `${path.sep}Google${path.sep}Chrome SxS${path.sep}Application${path.sep}chrome.exe`,
      `${path.sep}Google${path.sep}Chrome${path.sep}Application${path.sep}chrome.exe`
    ]
    const prefixes = [
      process.env.LOCALAPPDATA,
      process.env.PROGRAMFILES,
      process.env['PROGRAMFILES(X86)']
    ].filter(Boolean)

    if (process.env.CHROME_PATH && (await bingo(process.env.CHROME_PATH))) {
      installations.push(process.env.CHROME_PATH)
    }

    let i = 0
    let j = 0
    let len = prefixes.length
    let len2 = suffixes.length
    let chromePath
    while (i < len) {
      while (j < len2) {
        chromePath = path.join(prefixes[i], suffixes[j])
        if (await bingo(chromePath)) {
          installations.push(chromePath)
        }
        j += 1
      }
      i += 1
    }
    if (installations.length > 0) {
      return installations[0]
    }
    throw new Error('No Chrome Installations Found')
  }
}

/**
 * @type {ChromeFinder}
 */
module.exports = ChromeFinder