diff --git a/spec/message-registry-spec.coffee b/spec/message-registry-spec.coffee new file mode 100644 index 000000000..3752719e7 --- /dev/null +++ b/spec/message-registry-spec.coffee @@ -0,0 +1,84 @@ +MessageRegistry = require '../src/message-registry' + +describe 'MessageRegistry', -> + [registry] = [] + + beforeEach -> + registry = new MessageRegistry + + describe '::add', -> + it 'throws an error when the listener is not a function', -> + badAdder = -> registry.add 'package:message', 'not a function' + expect(badAdder).toThrow() + + describe 'the returned disosable', -> + it 'removes the callback', -> + spy = jasmine.createSpy('callback') + disposable = registry.add 'package:message', spy + disposable.dispose() + registry.dispatch 'atom://atom/package:message' + expect(spy).not.toHaveBeenCalled() + + it 'removes only the associated callback', -> + spy1 = jasmine.createSpy('callback 1') + spy2 = jasmine.createSpy('callback 2') + registry.add 'package:message', spy1 + disposable = registry.add 'package:message', spy2 + disposable.dispose() + registry.dispatch 'atom://atom/package:message' + expect(spy1).toHaveBeenCalledWith('package:message', {}) + expect(spy2).not.toHaveBeenCalledWith('package:message', {}) + + it 'removes all callbacks when created via ::add(object)', -> + spy1 = jasmine.createSpy('callback 1') + spy2 = jasmine.createSpy('callback 2') + disposable = registry.add + 'package:message1': spy1 + 'package:message2': spy2 + disposable.dispose() + registry.dispatch 'atom://atom/package:message1' + registry.dispatch 'atom://atom/package:message2' + expect(spy1).not.toHaveBeenCalled() + expect(spy2).not.toHaveBeenCalled() + + describe '::dispatch', -> + describe 'when a single callback is registered', -> + [spy1, spy2] = [] + + beforeEach -> + spy1 = jasmine.createSpy('callback1 ') + spy2 = jasmine.createSpy('callback 2') + + it 'invokes callbacks for matching messages', -> + registry.add 'package:message', spy1 + registry.add 'package:other-message', spy2 + registry.dispatch 'atom://atom/package:message' + expect(spy1).toHaveBeenCalledWith 'package:message', {} + expect(spy2).not.toHaveBeenCalled() + + describe 'when multiple callbacks are registered', -> + [spy1, spy2, spy3] = [] + + beforeEach -> + spy1 = jasmine.createSpy('callback 1') + spy2 = jasmine.createSpy('callback 2') + spy3 = jasmine.createSpy('callback 3') + + it 'invokes all the registered callbacks for matching messages', -> + registry.add 'package:message', spy1 + registry.add 'package:message', spy2 + registry.add 'package:other-message', spy3 + registry.dispatch 'atom://atom/package:message' + expect(spy1).toHaveBeenCalledWith('package:message', {}) + expect(spy2).toHaveBeenCalledWith('package:message', {}) + expect(spy3).not.toHaveBeenCalled() + + describe 'when a message with params is dispatched', -> + it 'invokes the callback with the given params', -> + spy = jasmine.createSpy('callback') + registry.add 'package:message', spy + registry.dispatch 'atom://atom/package:message?one=1&2=two' + expectedParams = + one: '1' + 2: 'two' + expect(spy).toHaveBeenCalledWith('package:message', expectedParams) diff --git a/src/application-delegate.coffee b/src/application-delegate.coffee index aee02ee8e..217c4c389 100644 --- a/src/application-delegate.coffee +++ b/src/application-delegate.coffee @@ -235,6 +235,14 @@ class ApplicationDelegate new Disposable -> ipcRenderer.removeListener('context-command', outerCallback) + onUrlMessage: (callback) -> + outerCallback = (event, args...) -> + callback(args...) + + ipcRenderer.on('url-message', outerCallback) + new Disposable -> + ipcRenderer.removeListener('url-message', outerCallback) + didCancelWindowUnload: -> ipcRenderer.send('did-cancel-window-unload') diff --git a/src/atom-environment.coffee b/src/atom-environment.coffee index 7b3edee0a..912b307c6 100644 --- a/src/atom-environment.coffee +++ b/src/atom-environment.coffee @@ -22,6 +22,7 @@ Config = require './config' KeymapManager = require './keymap-extensions' TooltipManager = require './tooltip-manager' CommandRegistry = require './command-registry' +MessageRegistry = require './message-registry' GrammarRegistry = require './grammar-registry' StyleManager = require './style-manager' PackageManager = require './package-manager' @@ -160,6 +161,8 @@ class AtomEnvironment extends Model @commands = new CommandRegistry @commands.attach(@window) + @messages = new MessageRegistry + @grammars = new GrammarRegistry({@config}) @styles = new StyleManager({@configDirPath}) @@ -668,6 +671,7 @@ class AtomEnvironment extends Model @disposables.add(@applicationDelegate.onDidOpenLocations(@openLocations.bind(this))) @disposables.add(@applicationDelegate.onApplicationMenuCommand(@dispatchApplicationMenuCommand.bind(this))) @disposables.add(@applicationDelegate.onContextMenuCommand(@dispatchContextMenuCommand.bind(this))) + @disposables.add(@applicationDelegate.onUrlMessage(@dispatchUrlMessage.bind(this))) @listenForUpdates() @registerDefaultTargetForKeymaps() @@ -938,6 +942,9 @@ class AtomEnvironment extends Model dispatchContextMenuCommand: (command, args...) -> @commands.dispatch(@contextMenu.activeElement, command, args) + dispatchUrlMessage: (uri) -> + @messages.dispatch(uri) + openLocations: (locations) -> needsProjectPaths = @project?.getPaths().length is 0 diff --git a/src/command-registry.coffee b/src/command-registry.coffee index 955a1b540..19dcd3f2a 100644 --- a/src/command-registry.coffee +++ b/src/command-registry.coffee @@ -49,6 +49,7 @@ class CommandRegistry @clear() clear: -> + @urlWhitelistedCommands = {} @registeredCommands = {} @selectorBasedListenersByCommandName = {} @inlineListenersByCommandName = {} @@ -108,6 +109,9 @@ class CommandRegistry else @addInlineListener(target, commandName, callback) + whitelistUrlCommand: (commandName) -> + @urlWhitelistedCommands[commandName] = true + addSelectorBasedListener: (selector, commandName, callback) -> @selectorBasedListenersByCommandName[commandName] ?= [] listenersForCommand = @selectorBasedListenersByCommandName[commandName] @@ -183,6 +187,10 @@ class CommandRegistry Object.defineProperty(event, 'target', value: target) @handleCommandEvent(event) + dispatchFromUrl: (target, commandName, detail) -> + if @urlWhitelistedCommands[commandName] + @dispatch(target, commandName, detail) + # Public: Invoke the given callback before dispatching a command event. # # * `callback` {Function} to be called before dispatching each command diff --git a/src/main-process/atom-application.coffee b/src/main-process/atom-application.coffee index ba2fce4a4..3e38f7110 100644 --- a/src/main-process/atom-application.coffee +++ b/src/main-process/atom-application.coffee @@ -11,6 +11,7 @@ path = require 'path' os = require 'os' net = require 'net' url = require 'url' +querystring = require 'querystring' {EventEmitter} = require 'events' _ = require 'underscore-plus' FindParentDir = null @@ -550,6 +551,29 @@ class AtomApplication # :devMode - Boolean to control the opened window's dev mode. # :safeMode - Boolean to control the opened window's safe mode. openUrl: ({urlToOpen, devMode, safeMode, env}) -> + parsedUrl = url.parse(urlToOpen) + if parsedUrl.host is "atom" + @openWithCommandFromUrl(urlToOpen, devMode, safeMode, env) + else + @openPackageUrlMain(parsedUrl.host, devMode, safeMode, env) + + openWithCommandFromUrl: (url, devMode, safeMode, env) -> + resourcePath = @resourcePath + if devMode + try + windowInitializationScript = require.resolve(path.join(@devResourcePath, 'src', 'initialize-application-window')) + resourcePath = @devResourcePath + + windowInitializationScript ?= require.resolve('../initialize-application-window') + if @lastFocusedWindow? + @lastFocusedWindow.sendUrlMessage url + else + windowDimensions = @getDimensionsForNewWindow() + @lastFocusedWindow = new AtomWindow({resourcePath, windowInitializationScript, devMode, safeMode, windowDimensions, env}) + @lastFocusedWindow.on 'window:loaded', => + @lastFocusedWindow.sendUrlMessage url + + openPackageUrlMain: (packageName, devMode, safeMode, env) -> unless @packages? PackageManager = require '../package-manager' @packages = new PackageManager @@ -557,7 +581,6 @@ class AtomApplication devMode: devMode resourcePath: @resourcePath - packageName = url.parse(urlToOpen).host pack = _.find @packages.getAvailablePackageMetadata(), ({name}) -> name is packageName if pack? if pack.urlMain @@ -568,7 +591,7 @@ class AtomApplication else console.log "Package '#{pack.name}' does not have a url main: #{urlToOpen}" else - console.log "Opening unknown url: #{urlToOpen}" + console.log "Opening unknown url: #{urlToOpen}" # TODO: should this forward the URL to the workspace? # Opens up a new {AtomWindow} to run specs within. # diff --git a/src/main-process/atom-window.coffee b/src/main-process/atom-window.coffee index 34999b44e..798fd78ac 100644 --- a/src/main-process/atom-window.coffee +++ b/src/main-process/atom-window.coffee @@ -194,6 +194,9 @@ class AtomWindow unless global.atomApplication.sendCommandToFirstResponder(command) @sendCommandToBrowserWindow(command, args...) + sendUrlMessage: (url) -> + @browserWindow.webContents.send 'url-message', url + sendCommandToBrowserWindow: (command, args...) -> action = if args[0]?.contextCommand then 'context-command' else 'command' @browserWindow.webContents.send action, command, args... diff --git a/src/message-registry.coffee b/src/message-registry.coffee new file mode 100644 index 000000000..f90dc3e1c --- /dev/null +++ b/src/message-registry.coffee @@ -0,0 +1,131 @@ +querystring = require 'querystring' +url = require 'url' +{Disposable, CompositeDisposable} = require 'event-kit' + +# Public: Associates listener functions with messages from outside the +# application. You can access a global instance of this class via +# `atom.messages`. +# +# The global message registry is similar to the {CommandRegistry} in that it +# maps messages, identified by strings, to listener functions; however, unlike +# commands, messages can originate from outside the application, and thus the +# range of actions that messages can trigger should be more limited. +# +# Message names must follow the `namespace:action` pattern, where `namespace` +# will typically be the name of your package, and `action` describes the +# behavior of your command. If either part consists of multiple words, these +# must be separated by hyphens. E.g. `awesome-package:turn-it-up-to-eleven`. All +# words should be lowercased. +# +# Messages are exposed to applications outside Atom via special URIs that begin +# with `atom://atom/`. For example, a message named `package:show-pane` could +# be triggered by visiting `atom://atom/package:show-pane`. Additional +# parameters can be passed via query string parameters. +# +# Since messages can originate from outside the application, you should avoid +# registering messages for operations that can be destructive to the user's +# environment; for example, a message to open the install page for a package is +# fine, but a message that immediately installs a package is not. +# +# ## Example +# +# Here is a message that could open a specific panel in a package's view: +# +# ```coffee +# atom.messages.add 'package:show-panel', (message, params) -> +# packageView.showPanel(params.panel) +# ``` +# +# Such a message could be triggered by visiting the associated URL: +# +# ``` +# atom://atom/package:show-panel?panel=help +# ``` +module.exports = +class MessageRegistry + constructor: -> + @clear() + + clear: -> + @listenersByMessageName = {} + + # Public: Add one or more message listeners. + # + # ## Arguments: Registering One Message + # + # * `messageName` A {String} containing the name of a message you want to + # handle such as `package:show-panel`. + # * `callback` A {Function} to call when the given message is activated. + # * `message` An {String} containing the message that triggered this + # callback. + # * `params` An {Object} containing any key-value pairs passed to the + # message via query string parameters. The values will always be {String}s. + # + # ## Arguments: Registering Multiple Messages + # + # * `messages` An {Object} mapping message names like `package:show-panel` + # to listener {Function}s. + # + # Returns a {Disposable} on which `.dispose()` can be called to remove the + # added message handler(s). + add: (messageName, callback) -> + if typeof messageName is 'object' + messages = messageName + disposable = new CompositeDisposable + for messageName, callback of messages + disposable.add @add(messageName, callback) + return disposable + + if typeof callback isnt 'function' + throw new Error("Can't register a message with a non-function callback") + + @addListener(messageName, callback) + + addListener: (messageName, callback) -> + messageListeners = @listenersByMessageName[messageName] + + if typeof messageListeners is 'function' + @listenersByMessageName[messageName] = [ + messageListeners, + callback + ] + else if messageListeners? + messageListeners.push(callback) + else + @listenersByMessageName[messageName] = callback + + new Disposable => + @removeListener(messageName, callback) + + removeListener: (messageName, callback) -> + messageListeners = @listenersByMessageName[messageName] + + if callback? and messageListeners is callback + delete @listenersByMessageName[messageName] + else + messageListeners.splice(messageListeners.indexOf(callback), 1) + + # Public: Simulates the dispatch of a given message URI. + # + # This can be useful for testing when you want to simulate a mesasge being + # passed from outside Atom. + # + # * `uri` {String} The URI to dispatch. URIs are expected to be in the form + # `atom://atom/package:message?param=value&other=more`, where + # `package:message?param=value&other=more` describes the message to + # dispatch. + dispatch: (uri) -> + parsedUri = url.parse(uri) + return unless parsedUri.host is 'atom' + + path = parsedUri.pathname or '' + messageName = path.substr(1) + + listeners = @listenersByMessageName[messageName] + return unless listeners? + + params = querystring.parse(parsedUri.query) + if typeof listeners is 'function' + listeners(messageName, params) + else + listeners.forEach (l) -> l(messageName, params)