mainyaa/gulp-electron

View on GitHub
index.coffee

Summary

Maintainability
Test Coverage

fs = require 'fs-extra'
grs = require 'grs'
path = require 'path'
async = require 'async'
Promise = require 'bluebird'
mv = require 'mv'
mvAsync = Promise.promisify mv
rm = require 'rimraf'
rmAsync = Promise.promisify rm
util = require 'gulp-util'
asar = require 'asar'
chalk = require 'chalk'
Decompress = require 'decompress-zip'
PluginError = util.PluginError
through = require 'through2'
childProcess = require 'child_process'
ProgressBar = require 'progress'
File = require 'vinyl'
plist = require 'plist'
rcedit = require 'rcedit'


PLUGIN_NAME = 'gulp-electron'

module.exports = electron = (options) ->
  # Options should be like
  #  cache
  #  src
  #  packageJson
  #  release
  #  platforms: ['darwin', 'win32', 'linux']
  #  apm
  #  rebuild
  #  asar
  #  packaging
  #  symbols
  #  version
  #  repo
  PLUGIN_NAME = 'gulp-electron'
  options = (options or {})

  if not options.release or not options.version or
      not options.src or not options.cache
    throw new PluginError PLUGIN_NAME, 'Miss version or release path.'
  if path.resolve(options.src) is path.resolve(".")
    throw new PluginError PLUGIN_NAME, 'src path can not root path.'

  packageJson = options.packageJson
  if typeof options.packageJson is 'string'
    packageJson = require(packageJson)
  options.platforms ?= ['darwin']
  options.apm ?= getApmPath()
  options.symbols ?= false
  options.rebuild ?= false
  options.asar ?= false
  options.asarUnpack ?= false
  options.asarUnpackDir ?= false
  options.packaging ?= true
  options.ext ?= 'zip'

  options.platforms = [options.platforms] if typeof options.platforms is 'string'

  bufferContents = (file, enc, cb) ->
    src = file
    cb()

  endStream = (callback) ->
    push = @push
    platforms = ['darwin',
    'win32',
    'linux',
    'darwin-x64',
    'linux-ia32',
    'linux-x64',
    'win32-ia32',
    'win32-x64',
    'linux-arm']

    Promise.map options.platforms, (platform) ->
      platform = 'darwin' if platform is 'osx'
      platform = 'win32' if platform is 'win'

      if platforms.indexOf(platform) < 0
        throw new PluginError PLUGIN_NAME, "Not support platform #{platform}"

      options.ext ?= "zip"
      # ex: electron-v0.24.0-darwin-x64.zip
      pkgZip = pkg = "#{packageJson.name}-#{packageJson.version}-#{platform}"
      pkgZip += '-symbols' if options.symbols
      pkgZip += ".#{options.ext}"

      cacheZip = cache = "electron-#{options.version}-#{platform}"
      cacheZip += '-symbols' if options.symbols
      cacheZip += ".#{options.ext}"
      getUserHome = ->
        process.env.HOME or process.env.USERPROFILE
      if not path.isAbsolute(options.cache)
        if options.cache.match(/^\~/)
          options.cache = path.join getUserHome(), options.cache.replace(/^\~\//, "")
        else
          options.cache = path.resolve options.cache
      # ex: ./cache/v0.24.0/electron-v0.24.0-darwin-x64.zip
      cachePath = path.resolve options.cache, options.version
      cacheFile = path.resolve cachePath, cacheZip
      # ex: ./cache/v0.24.0/electron-v0.24.0-darwin-x64
      cacheedPath = path.resolve cachePath, cache
      # ex: ./release/v0.24.0/
      if not path.isAbsolute(options.release)
        if options.release.match(/^\~/)
          options.release = path.join getUserHome(), options.release.replace(/^\~\//, "")
        else
          options.release = path.resolve options.release
      pkgZipDir = path.resolve options.release, options.version
      pkgZipPath = path.resolve pkgZipDir
      pkgZipFilePath = path.resolve pkgZipDir, pkgZip
      # ex: ./release/v0.24.0/darwin-x64/
      platformDir = path.join pkgZipDir, platform
      platformPath = path.resolve platformDir

      targetApp = ""
      defaultAppName = "Electron"
      suffix = ""
      _src = path.join 'resources', 'app'
      if platform.indexOf('darwin') >= 0
        suffix = ".app"
        electronFile = "Electron" + suffix
        targetZip = packageJson.name + suffix
        _src = path.join packageJson.name + suffix, 'Contents', 'Resources', 'app'
      else if platform.indexOf('win') >= 0
        suffix = ".exe"
        electronFile = "electron" + suffix
        targetZip = "."
      else
        electronFile = "electron"
        targetZip = "."
      # ex: ./release/v0.24.0/darwin-x64/Electron
      electronFileDir = path.join platformDir, electronFile
      electronFilePath = path.resolve electronFileDir
      binName = packageJson.name + suffix
      targetAppDir = path.join platformDir , binName
      targetAppPath = path.join targetAppDir
      _src = path.join 'resources', 'app'
      if platform.indexOf('darwin') >= 0
        _src = path.join binName, 'Contents', 'Resources', 'app'
      # ex: ./release/v0.24.0/darwin-x64/Electron/Contents/resources/app
      targetDir = path.join packageJson.name, _src
      targetDirPath = path.resolve platformDir, _src
      targetAsarPath = path.resolve platformDir, _src + ".asar"

      contentsPlistDir = path.join targetAppPath, 'Contents', 'Info.plist'
      identity = ""
      if options.platformResources?.darwin?.identity? and isFile options.platformResources.darwin.identity
        identity = fs.readFileSync(options.platformResources.darwin.identity, 'utf8').trim()
        ###
      signingCmd =
        # http://sevenzip.sourceforge.jp/chm/cmdline/commands/extract.htm
        darwin: [
            cmd: 'codesign'
            args: ['--deep', '--force', '--verbose', '--sign', identity, path.join(targetAppDir ,'Contents', 'Frameworks', 'Electron\\ Framework.framework')]
          ,
            cmd: 'codesign'
            args: ['--deep', '--force', '--verbose', '--sign', identity, path.join(targetAppDir ,'Contents', 'Frameworks', 'Electron\\ Helper EH.app')]
          ,
            cmd: 'codesign'
            args: ['--deep', '--force', '--verbose', '--sign', identity, path.join(targetAppDir ,'Contents', 'Frameworks', 'Electron\\ Helper NP.app')]
          ,
            cmd: 'codesign'
            args: ['--deep', '--force', '--verbose', '--sign', identity, path.join(targetAppDir ,'Contents', 'Frameworks', 'Electron\\ Helper.app')]
          ,
            cmd: 'codesign'
            args: ['--deep', '--force', '--verbose', '--sign', identity, path.join(targetAppDir ,'Contents', 'Frameworks', 'ReactiveCocoa.framework')]
          ,
            cmd: 'codesign'
            args: ['--deep', '--force', '--verbose', '--sign', identity, path.join(targetAppDir ,'Contents', 'Frameworks', 'Squirrel.framework')]
          ,
            cmd: 'codesign'
            args: ['--deep', '--force', '--verbose', '--sign', identity, path.join(targetAppDir,'Contents', 'Frameworks', 'Mantle.framework')]
          ,
            cmd: 'codesign'
            args: ['--deep', '--force', '--verbose', '--sign', identity, targetAppDir]
        ]
        ###
      unpackagingCmd =
        # http://sevenzip.sourceforge.jp/chm/cmdline/commands/extract.htm
        win32:
          cmd: '7z'
          args: ['x', cacheFile, '-o' + cacheedPath]
        darwin:
          cmd: 'unzip'
          args: ['-q', '-o', cacheFile, '-d', cacheedPath]
        linux:
          cmd: 'unzip'
          args: ['-o', cacheFile, '-d', cacheedPath]
      packagingCmd =
        # http://www.appveyor.com/docs/packaging-artifacts#packaging-multiple-files-in-different-locations-into-a-single-archive
        win32:
          cmd: '7z',
          args: ['a', path.join('..', pkgZip), targetZip],
          opts: {cwd: platformPath}
        darwin:
          cmd: 'ditto'
          args: [ '-c', '-k', '--sequesterRsrc', '--keepParent' , targetZip, path.join('..', pkgZip)]
          opts: {cwd: platformPath}
        linux:
          cmd: 'zip'
          args: ['-9', '-y', '-r', path.join('..', pkgZip) , targetZip]
          opts: {cwd: platformPath}

      new Promise (resolve,reject) ->
        Promise.resolve().then ->
          # If not downloaded then download the special package.
          download cacheFile, cachePath, options.version, cacheZip, options.token
        .then ->
          # If not unziped then unzip the zip file.
          # Check if there already have an version file.
          unzip cacheFile, cacheedPath, unpackagingCmd[process.platform]
        .then ->
          distributeBase platformPath, cacheedPath, electronFilePath, targetAppPath
        .then ->
          if not options.rebuild
            return Promise.resolve()
          util.log PLUGIN_NAME, "Rebuilding modules"
          rebuild cmd: options.apm, args: ['rebuild']
        .then ->
          util.log PLUGIN_NAME, "distributeApp #{targetAppDir}"
          if not path.isAbsolute(options.src)
            if options.src.match(/^\~/)
              options.src = path.join getUserHome(), options.src.replace(/^\~\//, "")
            else
              options.src = path.resolve options.src

          distributeApp options.src, targetDirPath
        .then ->
          if platform.indexOf('darwin') is -1 or not options.platformResources?.darwin?
            return Promise.resolve()
          util.log PLUGIN_NAME, "distributePlist #{targetAppPath}"
          distributePlist options.platformResources.darwin, packageJson.name, targetAppPath
        .then ->
          if platform.indexOf('darwin') is -1 or not options.platformResources?.darwin?
            return Promise.resolve()
          util.log PLUGIN_NAME, "distributeMacIcon #{targetAppDir}"
          distributeMacIcon options.platformResources.darwin.icon, targetAppPath
        .then ->
          if platform.indexOf('win32') is -1 or not options.platformResources?.win?
            return Promise.resolve()
          util.log PLUGIN_NAME, "distributeWinIcon #{targetAppDir}"
          distributeWinIcon options.platformResources.win, targetAppPath
        .then ->
          if not options.asar
            return Promise.resolve()
          util.log PLUGIN_NAME, "packaging app.asar"
          asarPackaging targetDirPath, targetAsarPath,
           {unpack: options.asarUnpack, unpackDir: options.asarUnpackDir}
        .then ->
          if not options.packaging
            return Promise.resolve()
          # FIXME: skip signing
          return Promise.resolve()
          ###
          if platform is "darwin-x64" and process.platform is "darwin"
            if identity is ""
              util.log PLUGIN_NAME, "not found identity file. skip signing"
              return Promise.resolve()
            signDarwin signingCmd.darwin
          ###
        .then ->
          if not options.packaging
            return Promise.resolve()
          packaging pkgZipFilePath, packagingCmd[process.platform]
        .then ->
          resolve()
    .finally ->
      util.log PLUGIN_NAME, "all distribute done."
      callback()

  return through.obj(bufferContents, endStream)

isDir = ->
  filepath = path.join.apply path, arguments
  fs.existsSync(filepath) and not fs.statSync(filepath).isFile()

isFile = ->
  filepath = path.join.apply path, arguments
  fs.existsSync(filepath) and fs.statSync(filepath).isFile()

isExists = ->
  filepath = path.join.apply path, arguments
  fs.existsSync(filepath)

getApmPath = ->
  apmPath = path.join 'apm', 'node_modules', 'atom-package-manager', 'bin', 'apm'
  apmPath = 'apm' unless isFile apmPath

download = (cacheFile, cachePath, version, cacheZip, token) ->
  if isFile cacheFile
    util.log PLUGIN_NAME, "download skip: already exists"
    return Promise.resolve()
  new Promise (resolve, reject) ->
    util.log PLUGIN_NAME, "download electron #{cacheZip} cache filie."
    fs.mkdirsSync cachePath
    # Download electron package throw stream.
    bar = null
    grs
      repo: 'atom/electron'
      tag: version
      name: cacheZip
      token: token
    .on 'error', (error) ->
      throw new PluginError PLUGIN_NAME, error
    .on 'size', (size) ->
      bar = new ProgressBar "#{cacheFile} [:bar] :percent :etas",
        complete: '>'
        incomplete: ' '
        width: 20
        total: size
    .pipe through (chunk, enc, cb) ->
      bar.tick chunk.length
      @push(chunk)
      cb()
    .pipe(fs.createWriteStream(cacheFile))
    .on 'close', resolve
    .on 'error', reject

unzip = (src, target, unpackagingCmd) ->
  if isExists target
    return Promise.resolve()
  return new Promise (resolve, reject) ->
    ###
    decompress = new Decompress src
    decompress.on 'error', reject
    decompress.on 'extract', ->
      util.log PLUGIN_NAME, "decompress done #{src}, #{target}"
      resolve()
    decompress.extract
      path: target
      follow: true
    ###
    spawn unpackagingCmd, ->
      resolve()

distributeBase = (platformPath, cacheedPath, electronFilePath, targetAppPath) ->
  if isExists(platformPath) and isExists(targetAppPath)
    util.log PLUGIN_NAME, "distributeBase skip: already exists"
    return Promise.resolve()
  new Promise (resolve) ->
    fs.mkdirsSync platformPath
    fs.copySync cacheedPath, platformPath
    mvAsync electronFilePath, targetAppPath, {mkdirp: true}
      .then resolve

distributeApp = (src, targetDirPath) ->
  if isExists targetDirPath
    util.log PLUGIN_NAME, "distributeApp skip: already exists"
    return Promise.resolve()
  new Promise (resolve) ->
    rmAsync targetDirPath
      .finally ->
        fs.mkdirsSync targetDirPath
        fs.copySync src, targetDirPath
        resolve()

distributePlist = (darwin, name, targetAppPath) ->
  new Promise (resolve) ->
    contentsPlist = plist.parse fs.readFileSync path.join(targetAppPath, 'Contents', 'Info.plist'), 'utf8'

    if darwin.CFBundleDisplayName?
      contentsPlist.CFBundleDisplayName = darwin.CFBundleDisplayName
    if darwin.CFBundleIdentifier?
      contentsPlist.CFBundleIdentifier = darwin.CFBundleIdentifier

    if darwin.CFBundleName?
      contentsPlist.CFBundleName = darwin.CFBundleName
    if darwin.CFBundleVersion?
      contentsPlist.CFBundleVersion = darwin.CFBundleVersion
    if darwin.CFBundleExecutable?
      contentsPlist.CFBundleExecutable = darwin.CFBundleExecutable
    if darwin.CFBundleURLTypes?
      contentsPlist.CFBundleURLTypes = darwin.CFBundleURLTypes
    fs.writeFileSync path.join(targetAppPath, 'Contents', 'Info.plist'), plist.build contentsPlist
    if darwin.CFBundleExecutable?
      _binarySrc = path.join targetAppPath, 'Contents', 'MacOS', 'Electron'
      _binaryDest = path.join targetAppPath, 'Contents', 'MacOS', darwin.CFBundleExecutable
      mvAsync _binarySrc, _binaryDest, {mkdirp: true}
      .then resolve
    else
      resolve()

distributeMacIcon = (src, targetAppPath) ->
  new Promise (resolve) ->
    iconDir = path.join targetAppPath, 'Contents', 'Resources', 'electron.icns'
    fs.createReadStream(src).pipe fs.createWriteStream iconDir
    resolve()

distributeWinIcon = (src, targetAppPath) ->
  new Promise (resolve) ->
    rcedit targetAppPath, src, resolve
    resolve()

rebuild = (cmd) ->
  new Promise (resolve) ->
    spawn cmd, resolve

asarPackaging = (src, target, opts) ->
  escSrc = src.replace(/(\\\s)/, "\\ ")
  escTarget = target.replace(/(\\\s)/, "\\ ")
  new Promise (resolve) ->
    util.log PLUGIN_NAME, "packaging app.asar #{escSrc}, #{escTarget}"
    asar.createPackageWithOptions escSrc, escTarget, opts, ->
      resolve()

signDarwin = (signingCmd) ->
  promiseList = []
  signingCmd.forEach (cmd) ->
    p = Promise.defer()
    promiseList.push p
    spawn cmd, ->
      p.resolve()
  Promise.when promiseList

packaging = (pkgZipFilePath, packagingCmd) ->
  if not isFile pkgZipFilePath
    return new Promise (resolve) ->
      cmd = packagingCmd
      spawn cmd, ->
        resolve()
  return new Promise (resolve) ->
    rmAsync pkgZipFilePath
      .finally ->
        cmd = packagingCmd
        spawn cmd, ->
          resolve()

spawn = (options, cb) ->
  stdout = []
  stderr = []
  error = null
  options.args.forEach (arg) ->
    arg = arg.replace ' ', '\\ '
  util.log "> #{options.cmd} #{options.args.join ' '}"
  proc = childProcess.spawn options.cmd, options.args, options.opts
  proc.stdout.on 'data', (data) ->
    stdout.push data.toString()
    if process.NODE_ENV is 'test'
      util.log data.toString()
  proc.stderr.on 'data', (data) ->
    stderr.push data.toString()
  proc.on 'exit', (code, signal) ->
    error = new Error(signal) if code isnt 0
    results = stderr: stderr.join(''), stdout: stdout.join(''), code: code
    if code isnt 0
      throw new PluginError PLUGIN_NAME, results.stderr or
       'unknow error , maybe you can try delete the zip packages.'
    cb error, results