mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-01-19 22:50:13 +08:00
546 lines
18 KiB
CoffeeScript
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
|