Mailspring/src/package-manager.coffee

546 lines
18 KiB
CoffeeScript

path = require 'path'
_ = require 'underscore'
{remote} = require 'electron'
EmitterMixin = require('emissary').Emitter
{Emitter} = require 'event-kit'
fs = require 'fs-plus'
Q = require 'q'
Package = require './package'
ThemePackage = require './theme-package'
DatabaseStore = require './flux/stores/database-store'
APMWrapper = require './apm-wrapper'
basePackagePaths = null
# Extended: Package manager for coordinating the lifecycle of N1 packages.
#
# An instance of this class is always available as the `NylasEnv.packages` global.
#
# Packages can be loaded, activated, and deactivated, and unloaded:
# * Loading a package reads and parses the package's metadata and resources
# such as keymaps, menus, stylesheets, etc.
# * Activating a package registers the loaded resources and calls `activate()`
# on the package's main module.
# * Deactivating a package unregisters the package's resources and calls
# `deactivate()` on the package's main module.
# * Unloading a package removes it completely from the package manager.
#
# Packages can be enabled/disabled via the `core.disabledPackages` config
# settings and also by calling `enablePackage()/disablePackage()`.
#
# Section: N1
module.exports =
class PackageManager
EmitterMixin.includeInto(this)
constructor: ({configDirPath, @devMode, safeMode, @resourcePath, @specMode}) ->
@emitter = new Emitter
@packageDirPaths = []
if @specMode
@packageDirPaths.push(path.join(@resourcePath, "spec", "fixtures", "packages"))
else
@packageDirPaths.push(path.join(@resourcePath, "internal_packages"))
if not safeMode
if @devMode
@packageDirPaths.push(path.join(configDirPath, "dev", "packages"))
@packageDirPaths.push(path.join(configDirPath, "packages"))
@loadedPackages = {}
@packagesWithDatabaseObjects = []
@activePackages = {}
@packageStates = {}
@packageActivators = []
@registerPackageActivator(this, ['nylas'])
###
Section: Event Subscription
###
# Public: Invoke the given callback when all packages have been loaded.
#
# * `callback` {Function}
#
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidLoadInitialPackages: (callback) ->
@emitter.on 'did-load-initial-packages', callback
# Public: Invoke the given callback when all packages have been activated.
#
# * `callback` {Function}
#
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidActivateInitialPackages: (callback) ->
@emitter.on 'did-activate-initial-packages', callback
# Public: Invoke the given callback when a package is activated.
#
# * `callback` A {Function} to be invoked when a package is activated.
# * `package` The {Package} that was activated.
#
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidActivatePackage: (callback) ->
@emitter.on 'did-activate-package', callback
# Public: Invoke the given callback when a package is deactivated.
#
# * `callback` A {Function} to be invoked when a package is deactivated.
# * `package` The {Package} that was deactivated.
#
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidDeactivatePackage: (callback) ->
@emitter.on 'did-deactivate-package', callback
# Public: Invoke the given callback when a package is loaded.
#
# * `callback` A {Function} to be invoked when a package is loaded.
# * `package` The {Package} that was loaded.
#
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidLoadPackage: (callback) ->
@emitter.on 'did-load-package', callback
# Public: Invoke the given callback when a package is unloaded.
#
# * `callback` A {Function} to be invoked when a package is unloaded.
# * `package` The {Package} that was unloaded.
#
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidUnloadPackage: (callback) ->
@emitter.on 'did-unload-package', callback
###
Section: Package system data
###
# Public: Get the path to the apm command.
#
# Return a {String} file path to apm.
getApmPath: ->
return @apmPath if @apmPath?
commandName = 'apm'
commandName += '.cmd' if process.platform is 'win32'
@apmPath = path.join(process.resourcesPath, 'app', 'apm', 'bin', commandName)
if not fs.isFileSync(@apmPath)
@apmPath = path.join(@resourcePath, 'apm', 'bin', commandName)
if not fs.isFileSync(@apmPath)
@apmPath = path.join(@resourcePath, 'apm', 'node_modules', 'atom-package-manager', 'bin', commandName)
@apmPath
# Public: Get the paths being used to look for packages.
#
# Returns an {Array} of {String} directory paths.
getPackageDirPaths: ->
_.clone(@packageDirPaths)
###
Section: General package data
###
# Public: Resolve the given package name to a path on disk.
#
# * `name` - The {String} package name.
#
# Return a {String} folder path or undefined if it could not be resolved.
resolvePackagePath: (name) ->
return name if fs.isDirectorySync(name)
packagePath = fs.resolve(@packageDirPaths..., name)
return packagePath if fs.isDirectorySync(packagePath)
packagePath = path.join(@resourcePath, 'node_modules', name)
return packagePath if @hasNylasEngine(packagePath)
# Public: Is the package with the given name bundled with Nylas?
#
# * `name` - The {String} package name.
#
# Returns a {Boolean}.
isBundledPackage: (name) ->
@getPackageDependencies().hasOwnProperty(name)
###
Section: Enabling and disabling packages
###
# Public: Enable the package with the given name.
#
# Returns the {Package} that was enabled or null if it isn't loaded.
enablePackage: (name) ->
pack = @loadPackage(name)
pack?.enable()
pack
# Public: Disable the package with the given name.
#
# Returns the {Package} that was disabled or null if it isn't loaded.
disablePackage: (name) ->
pack = @loadPackage(name)
pack?.disable()
pack
# Public: Is the package with the given name disabled?
#
# * `name` - The {String} package name.
#
# Returns a {Boolean}.
isPackageDisabled: (name) ->
_.include(NylasEnv.config.get('core.disabledPackages') ? [], name)
###
Section: Accessing active packages
###
# Public: Get an {Array} of all the active {Package}s.
getActivePackages: ->
_.values(@activePackages)
# Public: Get the active {Package} with the given name.
#
# * `name` - The {String} package name.
#
# Returns a {Package} or undefined.
getActivePackage: (name) ->
@activePackages[name]
# Public: Is the {Package} with the given name active?
#
# * `name` - The {String} package name.
#
# Returns a {Boolean}.
isPackageActive: (name) ->
@getActivePackage(name)?
###
Section: Accessing loaded packages
###
# Public: Get an {Array} of all the loaded {Package}s
getLoadedPackages: ->
_.values(@loadedPackages)
# Get packages for a certain package type
#
# * `types` an {Array} of {String}s like ['nylas', 'my-package'].
getLoadedPackagesForTypes: (types) ->
pack for pack in @getLoadedPackages() when pack.getType() in types
# Public: Get the loaded {Package} with the given name.
#
# * `name` - The {String} package name.
#
# Returns a {Package} or undefined.
getLoadedPackage: (name) ->
@loadedPackages[name]
# Public: Gets the root paths of all loaded packages.
#
# Useful when determining if an error originated from a package.
getPluginIdsByPathBase: ->
pluginIdsByPathBase = {}
for name, pack of @loadedPackages
pathBase = _.last(pack.path.split("/"))
if pack.pluginId() and pack.pluginId() isnt name
id = "#{name}-#{pack.pluginId()}"
else
id = pack.pluginId()
pluginIdsByPathBase[pathBase] = id
return pluginIdsByPathBase
# Public: Is the package with the given name loaded?
#
# * `name` - The {String} package name.
#
# Returns a {Boolean}.
isPackageLoaded: (name) ->
@getLoadedPackage(name)?
###
Section: Accessing available packages
###
# Public: Get an {Array} of {String}s of all the available package paths.
#
# If the optional windowType is passed, it will only load packages
# that declare that windowType in their package.json
getAvailablePackagePaths: (windowType) ->
packagePaths = []
loadPackagesWhenNoTypesSpecified = windowType is 'default'
basePackagePaths ?= NylasEnv.fileListCache().basePackagePaths ? []
if basePackagePaths.length is 0
for packageDirPath in @packageDirPaths
for packagePath in fs.listSync(packageDirPath)
# Ignore files in package directory
continue unless fs.isDirectorySync(packagePath)
# Ignore .git in package directory
continue if path.basename(packagePath)[0] is '.'
packagePaths.push(packagePath)
basePackagePaths = packagePaths
cache = NylasEnv.fileListCache()
cache.basePackagePaths = basePackagePaths
else
packagePaths = basePackagePaths
if windowType
packagePaths = _.filter packagePaths, (packagePath) ->
try
metadata = Package.loadMetadata(packagePath) ? {}
if not (metadata.engines?.nylas)
console.error("INVALID PACKAGE: Your package at #{packagePath} does not have a properly formatted `package.json`. You must include an {'engines': {'nylas': version}} property")
{windowTypes} = metadata
if windowTypes
return windowTypes[windowType]? or windowTypes["all"]?
else if loadPackagesWhenNoTypesSpecified
return true
return false
catch
return false
packagesPath = path.join(@resourcePath, 'node_modules')
for packageName, packageVersion of @getPackageDependencies()
packagePath = path.join(packagesPath, packageName)
packagePaths.push(packagePath) if fs.isDirectorySync(packagePath)
_.uniq(packagePaths)
# Public: Get an {Array} of {String}s of all the available package names.
getAvailablePackageNames: ->
_.uniq _.map @getAvailablePackagePaths(), (packagePath) -> path.basename(packagePath)
# Public: Get an {Array} of {String}s of all the available package metadata.
getAvailablePackageMetadata: ->
packages = []
for packagePath in @getAvailablePackagePaths()
name = path.basename(packagePath)
metadata = @getLoadedPackage(name)?.metadata ? Package.loadMetadata(packagePath, true)
packages.push(metadata)
packages
installPackageFromPath: (packageSourceDir, callback) ->
return unless @verifyValidPackage(packageSourceDir, callback)
packagesDir = path.join(NylasEnv.getConfigDirPath(), 'packages')
packageName = path.basename(packageSourceDir)
packageTargetDir = path.join(packagesDir, packageName)
fs.makeTree packagesDir, (err) =>
return callback(err, null) if err
fs.exists packageTargetDir, (packageAlreadyExists) =>
if packageAlreadyExists
message = "A package named '#{packageName}' is already installed
in ~/.nylas/packages."
remote.dialog.showMessageBox({
type: 'warning'
buttons: ['OK']
title: 'Package already installed'
detail: 'Remove it before trying to install another package of the same name.'
message: message
})
callback(new Error(message), null)
return
fs.copySync(packageSourceDir, packageTargetDir)
apm = new APMWrapper()
apm.installDependenciesInPackageDirectory packageTargetDir, (err) =>
if err
remote.dialog.showMessageBox({
type: 'warning'
buttons: ['OK']
title: 'Package installation failed'
message: err.toString()
})
callback(err, packageTargetDir)
else
@enablePackage(packageTargetDir)
@activatePackage(packageName)
callback(null, packageTargetDir)
verifyValidPackage: (packageSourceDir, callback) ->
if fs.existsSync(path.join(packageSourceDir, 'package.json'))
return true
else
errMsg = "The folder you selected doesn't look like a valid N1 plugin. All N1 plugins must have a package.json file in the top level of the folder. Check the contents of #{packageSourceDir} and try again"
remote.dialog.showMessageBox({
type: 'warning'
buttons: ['OK']
title: 'Not a valid plugin folder'
message: errMsg
})
callback(errMsg)
return false
###
Section: Private
###
getPackageState: (name) ->
@packageStates[name]
setPackageState: (name, state) ->
@packageStates[name] = state
getPackageDependencies: ->
unless @packageDependencies?
try
metadataPath = path.join(@resourcePath, 'package.json')
{@packageDependencies} = JSON.parse(fs.readFileSync(metadataPath)) ? {}
@packageDependencies ?= {}
@packageDependencies
hasNylasEngine: (packagePath) ->
metadata = Package.loadMetadata(packagePath, true)
metadata?.engines?.nylas?
unobserveDisabledPackages: ->
@disabledPackagesSubscription?.dispose()
@disabledPackagesSubscription = null
observeDisabledPackages: ->
@disabledPackagesSubscription ?= NylasEnv.config.onDidChange 'core.disabledPackages', ({newValue, oldValue}) =>
packagesToEnable = _.difference(oldValue, newValue)
packagesToDisable = _.difference(newValue, oldValue)
@deactivatePackage(packageName) for packageName in packagesToDisable when @getActivePackage(packageName)
for packageName in packagesToEnable
@loadPackage(packageName)
@refreshDatabaseSchema()
for packageName in packagesToEnable
@activatePackage(packageName)
null
# If a windowType is passed, we'll only load packages who declare that
# windowType as `true` in their package.json file.
loadPackages: (windowType) ->
packagePaths = @getAvailablePackagePaths(windowType)
packagePaths = packagePaths.filter (packagePath) => not @isPackageDisabled(path.basename(packagePath))
packagePaths = _.uniq packagePaths, (packagePath) -> path.basename(packagePath)
@loadPackage(packagePath) for packagePath in packagePaths
@emit 'loaded'
@emitter.emit 'did-load-initial-packages'
loadPackage: (nameOrPath) ->
return pack if pack = @getLoadedPackage(nameOrPath)
if packagePath = @resolvePackagePath(nameOrPath)
name = path.basename(nameOrPath)
return pack if pack = @getLoadedPackage(name)
try
metadata = Package.loadMetadata(packagePath) ? {}
if metadata.theme
pack = new ThemePackage(packagePath, metadata)
else
pack = new Package(packagePath, metadata)
pack.load()
if pack.declaresNewDatabaseObjects
@packagesWithDatabaseObjects.push pack
@loadedPackages[pack.name] = pack
@emitter.emit 'did-load-package', pack
return pack
catch error
console.warn "Failed to load package.json '#{path.basename(packagePath)}'"
console.warn error.stack ? error
else
console.warn "Could not resolve '#{nameOrPath}' to a package path"
null
unloadPackages: ->
@unloadPackage(name) for name in Object.keys(@loadedPackages)
null
unloadPackage: (name) ->
if @isPackageActive(name)
throw new Error("Tried to unload active package '#{name}'")
if pack = @getLoadedPackage(name)
delete @loadedPackages[pack.name]
@emitter.emit 'did-unload-package', pack
else
throw new Error("No loaded package for name '#{name}'")
# Activate all the packages that should be activated.
activate: ->
promises = []
for [activator, types] in @packageActivators
packages = @getLoadedPackagesForTypes(types)
promises = promises.concat(activator.activatePackages(packages))
Q.all(promises).then =>
@emit 'activated'
@emitter.emit 'did-activate-initial-packages'
# another type of package manager can handle other package types.
# See ThemeManager
registerPackageActivator: (activator, types) ->
@packageActivators.push([activator, types])
activatePackages: (packages) ->
promises = []
NylasEnv.config.transact =>
for pack in packages
@loadPackage(pack.name)
@refreshDatabaseSchema()
for pack in packages
promise = @activatePackage(pack.name)
promises.push(promise)
@observeDisabledPackages()
promises
# When packages load they can declare new DatabaseObjects that need to
# be setup in the Database. It's important that the Database starts
# getting setup before packages activate so any DB queries in the
# `activate` methods get properly queued then executed.
#
# When a package with database-altering changes loads, it will put an
# entry in `packagesWithDatabaseObjects`.
refreshDatabaseSchema: ->
if @packagesWithDatabaseObjects.length > 0
DatabaseStore.refreshDatabaseSchema()
@packagesWithDatabaseObjects = []
# Activate a single package by name
activatePackage: (name) ->
if pack = @getActivePackage(name)
Q(pack)
else if pack = @loadPackage(name)
pack.activate().then =>
@activePackages[pack.name] = pack
@emitter.emit 'did-activate-package', pack
pack
else
Q.reject(new Error("Failed to load package '#{name}'"))
# Deactivate all packages
deactivatePackages: ->
NylasEnv.config.transact =>
@deactivatePackage(pack.name) for pack in @getLoadedPackages()
@unobserveDisabledPackages()
# Deactivate the package with the given name
deactivatePackage: (name) ->
pack = @getLoadedPackage(name)
if @isPackageActive(name)
@setPackageState(pack.name, state) if state = pack.serialize?()
pack.deactivate()
delete @activePackages[pack.name]
@emitter.emit 'did-deactivate-package', pack