diff --git a/package.json b/package.json index dbf5d3f6c9..aaee45e253 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "Kitematic", - "version": "0.5.19", + "version": "0.5.20", "author": "Kitematic", "description": "Simple Docker Container management for Mac OS X.", "homepage": "https://kitematic.com/", @@ -66,6 +66,7 @@ "mixpanel": "0.2.0", "node-uuid": "^1.4.3", "object-assign": "^2.0.0", + "parseUri": "^1.2.3-2", "react": "^0.13.1", "react-bootstrap": "^0.20.3", "react-retina-image": "^1.1.2", diff --git a/src/app.js b/src/app.js index 3cd14888df..5fdac16168 100644 --- a/src/app.js +++ b/src/app.js @@ -11,6 +11,9 @@ var metrics = require('./utils/MetricsUtil'); var router = require('./router'); var template = require('./menutemplate'); var webUtil = require('./utils/WebUtil'); +var urlUtil = require ('./utils/URLUtil'); +var app = remote.require('app'); +var request = require('request'); webUtil.addWindowSizeSaving(); webUtil.addLiveReload(); @@ -28,13 +31,16 @@ setInterval(function () { router.run(Handler => React.render(, document.body)); SetupStore.setup().then(() => { + if (ContainerStore.pending()) { + router.transitionTo('pull'); + } else { + router.transitionTo('new'); + } Menu.setApplicationMenu(Menu.buildFromTemplate(template())); ContainerStore.on(ContainerStore.SERVER_ERROR_EVENT, (err) => { bugsnag.notify(err); }); - ContainerStore.init(function () { - router.transitionTo('containers'); - }); + ContainerStore.init(function () {}); }).catch(err => { metrics.track('Setup Failed', { step: 'catch', @@ -49,3 +55,20 @@ ipc.on('application:quitting', () => { machine.stop(); } }); + +// Event fires when the app receives a docker:// URL such as +// docker://repository/run/redis +ipc.on('application:open-url', opts => { + request.get('https://kitematic.com/flags.json', (err, response, body) => { + if (err || response.statusCode !== 200) { + return; + } + + var flags = JSON.parse(body); + if (!flags) { + return; + } + + urlUtil.openUrl(opts.url, flags, app.getVersion()); + }); +}); diff --git a/src/browser.js b/src/browser.js index 2eafbf8e5c..aaa8bcfe2c 100644 --- a/src/browser.js +++ b/src/browser.js @@ -18,6 +18,13 @@ try { settingsjson = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'settings.json'), 'utf8')); } catch (err) {} + +var openURL = null; +app.on('open-url', function (event, url) { + event.preventDefault(); + openURL = url; +}); + app.on('ready', function () { var mainWindow = new BrowserWindow({ width: size.width || 1000, @@ -65,6 +72,18 @@ app.on('ready', function () { mainWindow.show(); mainWindow.focus(); + if (openURL) { + mainWindow.webContents.send('application:open-url', { + url: openURL + }); + } + app.on('open-url', function (event, url) { + event.preventDefault(); + mainWindow.webContents.send('application:open-url', { + url: url + }); + }); + if (process.env.NODE_ENV !== 'development') { autoUpdater.setFeedUrl('https://updates.kitematic.com/releases/latest?version=' + app.getVersion() + '&beta=' + !!settingsjson.beta); } diff --git a/src/components/ContainerHomeFolders.react.js b/src/components/ContainerHomeFolders.react.js index ce44d828a1..71ed303b2f 100644 --- a/src/components/ContainerHomeFolders.react.js +++ b/src/components/ContainerHomeFolders.react.js @@ -6,6 +6,7 @@ var shell = require('shell'); var util = require('../utils/Util'); var metrics = require('../utils/MetricsUtil'); var ContainerStore = require('../stores/ContainerStore'); +var dialog = require('remote').require('dialog'); var ContainerHomeFolder = React.createClass({ contextTypes: { @@ -17,20 +18,27 @@ var ContainerHomeFolder = React.createClass({ }); if (hostVolume.indexOf(process.env.HOME) === -1) { - var volumes = _.clone(this.props.container.Volumes); - var newHostVolume = path.join(util.home(), 'Kitematic', this.props.container.Name, containerVolume); - volumes[containerVolume] = newHostVolume; - var binds = _.pairs(volumes).map(function (pair) { - return pair[1] + ':' + pair[0]; - }); - ContainerStore.updateContainer(this.props.container.Name, { - Binds: binds - }, function (err) { - if (err) { - console.log(err); - return; + dialog.showMessageBox({ + message: 'Enable all volumes to edit files via Finder? This may not work with all database containers.', + buttons: ['Enable Volumes', 'Cancel'] + }, (index) => { + if (index === 0) { + var volumes = _.clone(this.props.container.Volumes); + var newHostVolume = path.join(util.home(), 'Kitematic', this.props.container.Name, containerVolume); + volumes[containerVolume] = newHostVolume; + var binds = _.pairs(volumes).map(function (pair) { + return pair[1] + ':' + pair[0]; + }); + ContainerStore.updateContainer(this.props.container.Name, { + Binds: binds + }, (err) => { + if (err) { + console.log(err); + return; + } + shell.showItemInFolder(newHostVolume); + }); } - shell.showItemInFolder(newHostVolume); }); } else { shell.showItemInFolder(hostVolume); diff --git a/src/components/ContainerList.react.js b/src/components/ContainerList.react.js index aca50faa7c..1e6f358d6d 100644 --- a/src/components/ContainerList.react.js +++ b/src/components/ContainerList.react.js @@ -21,7 +21,7 @@ var ContainerList = React.createClass({ }); return ( ); diff --git a/src/components/ContainerListNewItem.react.js b/src/components/ContainerListNewItem.react.js index d26c98ef32..ab64e5792e 100644 --- a/src/components/ContainerListNewItem.react.js +++ b/src/components/ContainerListNewItem.react.js @@ -5,6 +5,9 @@ var ContainerStore = require('../stores/ContainerStore'); var metrics = require('../utils/MetricsUtil'); var ContainerListNewItem = React.createClass({ + contextTypes: { + router: React.PropTypes.func + }, handleItemMouseEnter: function () { var $action = $(this.getDOMNode()).find('.action'); $action.show(); @@ -20,10 +23,10 @@ var ContainerListNewItem = React.createClass({ type: 'new' }); var containers = ContainerStore.sorted(); - $(self.getDOMNode()).fadeOut(300, function () { + $(self.getDOMNode()).fadeOut(300, () => { if (containers.length > 0) { var name = containers[0].Name; - self.transitionTo('containerHome', {name: name}); + this.context.router.transitionTo('containerHome', {name: name}); } }); }, diff --git a/src/components/Containers.react.js b/src/components/Containers.react.js index 2836dc89ed..810d9f4a32 100644 --- a/src/components/Containers.react.js +++ b/src/components/Containers.react.js @@ -33,10 +33,6 @@ var Containers = React.createClass({ ContainerStore.on(ContainerStore.SERVER_CONTAINER_EVENT, this.update); ContainerStore.on(ContainerStore.CLIENT_CONTAINER_EVENT, this.updateFromClient); - if (this.state.sorted.length) { - this.context.router.transitionTo('containerHome', {name: this.state.sorted[0].Name}); - } - ipc.on('application:update-available', () => { this.setState({ updateAvailable: true @@ -48,36 +44,33 @@ var Containers = React.createClass({ ContainerStore.removeListener(ContainerStore.SERVER_CONTAINER_EVENT, this.update); ContainerStore.removeListener(ContainerStore.CLIENT_CONTAINER_EVENT, this.updateFromClient); }, - onDestroy: function () { - if (this.state.sorted.length) { - this.context.router.transitionTo('containerHome', {name: this.state.sorted[0].Name}); - } else { - this.context.router.transitionTo('containers'); - } - }, updateError: function (err) { this.setState({ error: err }); }, update: function (name, status) { + var sorted = ContainerStore.sorted(); this.setState({ containers: ContainerStore.containers(), - sorted: ContainerStore.sorted(), + sorted: sorted, + pending: ContainerStore.pending(), downloading: ContainerStore.downloading() }); if (status === 'destroy') { - this.onDestroy(); + if (sorted.length) { + this.context.router.transitionTo('containerHome', {name: sorted[0].Name}); + } else { + this.context.router.transitionTo('containers'); + } } }, updateFromClient: function (name, status) { - this.setState({ - containers: ContainerStore.containers(), - sorted: ContainerStore.sorted(), - downloading: ContainerStore.downloading() - }); + this.update(name, status); if (status === 'create') { this.context.router.transitionTo('containerHome', {name: name}); + } else if (status === 'pending' && ContainerStore.pending()) { + this.context.router.transitionTo('pull'); } else if (status === 'destroy') { this.onDestroy(); } @@ -186,7 +179,7 @@ var Containers = React.createClass({
- + ); diff --git a/src/components/NewContainerPull.react.js b/src/components/NewContainerPull.react.js new file mode 100644 index 0000000000..33a3fcedc9 --- /dev/null +++ b/src/components/NewContainerPull.react.js @@ -0,0 +1,42 @@ +var React = require('react/addons'); +var Router = require('react-router'); +var shell = require('shell'); +var ContainerStore = require('../stores/ContainerStore'); + +module.exports = React.createClass({ + mixins: [Router.Navigation], + handleOpenClick: function () { + var repo = this.props.pending.repository; + if (repo.indexOf('/') === -1) { + shell.openExternal(`https://registry.hub.docker.com/_/${this.props.pending.repository}`); + } else { + shell.openExternal(`https://registry.hub.docker.com/u/${this.props.pending.repository}`); + } + }, + handleCancelClick: function () { + ContainerStore.clearPending(); + this.context.router.transitionTo('new'); + }, + handleConfirmClick: function () { + ContainerStore.clearPending(); + ContainerStore.create(this.props.pending.repository, this.props.pending.tag, function () {}); + }, + render: function () { + if (!this.props.pending) { + return false; + } + return ( +
+
+
+

You're about to download and run {this.props.pending.repository}:{this.props.pending.tag}.

+

Please confirm to create the container.

+ +
+
+
+ ); + } +}); diff --git a/src/components/NewContainerSearch.react.js b/src/components/NewContainerSearch.react.js new file mode 100644 index 0000000000..7d66b07f8a --- /dev/null +++ b/src/components/NewContainerSearch.react.js @@ -0,0 +1,168 @@ +var _ = require('underscore'); +var $ = require('jquery'); +var React = require('react/addons'); +var RetinaImage = require('react-retina-image'); +var Radial = require('./Radial.react'); +var ImageCard = require('./ImageCard.react'); +var Promise = require('bluebird'); +var metrics = require('../utils/MetricsUtil'); + +var _recommended = []; +var _searchPromise = null; + +module.exports = React.createClass({ + getInitialState: function () { + return { + query: '', + loading: false, + results: _recommended + }; + }, + componentDidMount: function () { + this.refs.searchInput.getDOMNode().focus(); + this.recommended(); + }, + componentWillUnmount: function () { + if (_searchPromise) { + _searchPromise.cancel(); + } + }, + search: function (query) { + if (_searchPromise) { + _searchPromise.cancel(); + _searchPromise = null; + } + + if (!query.length) { + this.setState({ + query: query, + results: _recommended, + loading: false + }); + return; + } + + this.setState({ + query: query, + loading: true + }); + + _searchPromise = Promise.delay(200).cancellable().then(() => Promise.resolve($.get('https://registry.hub.docker.com/v1/search?q=' + query))).then(data => { + metrics.track('Searched for Images'); + this.setState({ + results: data.results, + query: query, + loading: false + }); + _searchPromise = null; + }).catch(Promise.CancellationError, () => { + }); + }, + recommended: function () { + if (_recommended.length) { + return; + } + Promise.resolve($.ajax({ + url: 'https://kitematic.com/recommended.json', + cache: false, + dataType: 'json', + })).then(res => res.repos).map(repo => { + var query = repo.repo; + var vals = query.split('/'); + if (vals.length === 1) { + query = 'library/' + vals[0]; + } + return $.get('https://registry.hub.docker.com/v1/repositories_info/' + query).then(data => { + var res = _.extend(data, repo); + res.description = data.short_description; + res.is_official = data.namespace === 'library'; + res.name = data.repo; + res.star_count = data.stars; + return res; + }); + }).then(results => { + _recommended = results.filter(r => !!r); + if (!this.state.query.length && this.isMounted()) { + this.setState({ + results: _recommended + }); + } + }).catch(err => { + console.log(err); + }); + }, + handleChange: function (e) { + var query = e.target.value; + if (query === this.state.query) { + return; + } + this.search(query); + }, + render: function () { + var title = this.state.query ? 'Results' : 'Recommended'; + var data = this.state.results; + var results; + if (data.length) { + var items = data.map(function (image) { + return ( + + ); + }); + + results = ( +
+ {items} +
+ ); + } else { + if (this.state.results.length === 0 && this.state.query === '') { + results = ( +
+
+

Loading Images

+ +
+
+ ); + } else { + results = ( +
+

Cannot find a matching image.

+
+ ); + } + } + var loadingClasses = React.addons.classSet({ + hidden: !this.state.loading, + loading: true + }); + var magnifierClasses = React.addons.classSet({ + hidden: this.state.loading, + icon: true, + 'icon-magnifier': true, + 'search-icon': true + }); + return ( +
+
+
+
+ Select a Docker image to create a new container. +
+
+
+ +
+ +
+
+
+
+

{title}

+ {results} +
+
+
+ ); + } +}); diff --git a/src/routes.js b/src/routes.js index 95a8a99a81..a65d0e8395 100644 --- a/src/routes.js +++ b/src/routes.js @@ -9,12 +9,14 @@ var ContainerSettingsGeneral = require('./components/ContainerSettingsGeneral.re var ContainerSettingsPorts = require('./components/ContainerSettingsPorts.react'); var ContainerSettingsVolumes = require('./components/ContainerSettingsVolumes.react'); var Preferences = require('./components/Preferences.react'); -var NewContainer = require('./components/NewContainer.react'); +var NewContainerSearch = require('./components/NewContainerSearch.react'); +var NewContainerPull = require('./components/NewContainerPull.react'); var Router = require('react-router'); var Route = Router.Route; var DefaultRoute = Router.DefaultRoute; var RouteHandler = Router.RouteHandler; +var Redirect = Router.Redirect; var App = React.createClass({ render: function () { @@ -27,17 +29,21 @@ var App = React.createClass({ var routes = ( - - - - - - - + + + + + + + + + + + - + diff --git a/src/stores/ContainerStore.js b/src/stores/ContainerStore.js index 2761bcdc68..9cdd9e1abf 100644 --- a/src/stores/ContainerStore.js +++ b/src/stores/ContainerStore.js @@ -14,6 +14,7 @@ var _progress = {}; var _muted = {}; var _blocked = {}; var _error = null; +var _pending = null; var ContainerStore = assign(Object.create(EventEmitter.prototype), { CLIENT_CONTAINER_EVENT: 'client_container_event', @@ -206,6 +207,10 @@ var ContainerStore = assign(Object.create(EventEmitter.prototype), { var data = JSON.parse(json); console.log(data); + if (data.status === 'pull' || data.status === 'untag' || data.status === 'delete') { + return; + } + // If the event is delete, remove the container if (data.status === 'destroy') { var container = _.findWhere(_.values(_containers), {Id: data.id}); @@ -231,7 +236,6 @@ var ContainerStore = assign(Object.create(EventEmitter.prototype), { } }, init: function (callback) { - // TODO: Load cached data from db on loading this.fetchAllContainers(err => { if (err) { _error = err; @@ -303,6 +307,7 @@ var ContainerStore = assign(Object.create(EventEmitter.prototype), { var containerName = this._generateName(repository); _placeholders[containerName] = { + Id: require('crypto').randomBytes(32).toString('hex'), Name: containerName, Image: imageName, Config: { @@ -498,6 +503,20 @@ var ContainerStore = assign(Object.create(EventEmitter.prototype), { }, downloading: function () { return !!_.keys(_placeholders).length; + }, + pending: function () { + return _pending; + }, + setPending: function (repository, tag) { + _pending = { + repository: repository, + tag: tag + }; + this.emit(this.CLIENT_CONTAINER_EVENT, null, 'pending'); + }, + clearPending: function () { + _pending = null; + this.emit(this.CLIENT_CONTAINER_EVENT, null, 'pending'); } }); diff --git a/src/stores/SetupStore-test.js b/src/stores/SetupStore-test.js index 38bd33dec4..17bf010474 100644 --- a/src/stores/SetupStore-test.js +++ b/src/stores/SetupStore-test.js @@ -21,7 +21,7 @@ describe('SetupStore', function () { pit('downloads virtualbox if it is installed but has an outdated version', function () { virtualBox.installed.mockReturnValue(true); virtualBox.version.mockReturnValue(Promise.resolve('4.3.16')); - setupUtil.compareVersions.mockReturnValue(-1); + util.compareVersions.mockReturnValue(-1); setupUtil.download.mockReturnValue(Promise.resolve()); util.packagejson.mockReturnValue({'virtualbox-filename': ''}); util.supportDir.mockReturnValue(''); @@ -50,7 +50,7 @@ describe('SetupStore', function () { pit('only installs binaries if virtualbox is installed', function () { virtualBox.installed.mockReturnValue(true); - setupUtil.compareVersions.mockReturnValue(0); + util.compareVersions.mockReturnValue(0); setupUtil.needsBinaryFix.mockReturnValue(true); return setupStore.steps().install.run().then(() => { expect(util.exec).toBeCalledWith('macsudo copycmd && fixcmd'); @@ -68,7 +68,7 @@ describe('SetupStore', function () { machine.stop.mockReturnValue(Promise.resolve()); machine.start.mockReturnValue(Promise.resolve()); machine.upgrade.mockReturnValue(Promise.resolve()); - setupUtil.compareVersions.mockReturnValue(-1); + util.compareVersions.mockReturnValue(-1); machine.create.mockClear(); machine.upgrade.mockClear(); machine.start.mockClear(); diff --git a/src/stores/SetupStore.js b/src/stores/SetupStore.js index 6bdb76033a..10caa0d4a3 100644 --- a/src/stores/SetupStore.js +++ b/src/stores/SetupStore.js @@ -76,7 +76,7 @@ var _steps = [{ var isoversion = machine.isoversion(); var packagejson = util.packagejson(); - if (!isoversion || setupUtil.compareVersions(isoversion, packagejson['docker-version']) < 0) { + if (!isoversion || util.compareVersions(isoversion, packagejson['docker-version']) < 0) { yield machine.start(); yield machine.upgrade(); } @@ -152,10 +152,10 @@ var SetupStore = assign(Object.create(EventEmitter.prototype), { var vboxNeedsInstall = !virtualBox.installed(); required.download = vboxNeedsInstall && (!fs.existsSync(vboxfile) || setupUtil.checksum(vboxfile) !== packagejson['virtualbox-checksum']); required.install = vboxNeedsInstall || setupUtil.needsBinaryFix(); - required.init = required.install || !(yield machine.exists()) || (yield machine.state()) !== 'Running' || !isoversion || setupUtil.compareVersions(isoversion, packagejson['docker-version']) < 0; + required.init = required.install || !(yield machine.exists()) || (yield machine.state()) !== 'Running' || !isoversion || util.compareVersions(isoversion, packagejson['docker-version']) < 0; var exists = yield machine.exists(); - if (isoversion && setupUtil.compareVersions(isoversion, packagejson['docker-version']) < 0) { + if (isoversion && util.compareVersions(isoversion, packagejson['docker-version']) < 0) { this.steps().init.seconds = 33; } else if (exists && (yield machine.state()) === 'Saved') { this.steps().init.seconds = 8; diff --git a/src/utils/SetupUtil.js b/src/utils/SetupUtil.js index 95fe6c0b5f..5fe0aa10f4 100644 --- a/src/utils/SetupUtil.js +++ b/src/utils/SetupUtil.js @@ -108,55 +108,6 @@ var SetupUtil = { resolve(); }); }); - }, - compareVersions: function (v1, v2, options) { - var lexicographical = options && options.lexicographical, - zeroExtend = options && options.zeroExtend, - v1parts = v1.split('.'), - v2parts = v2.split('.'); - - function isValidPart(x) { - return (lexicographical ? /^\d+[A-Za-z]*$/ : /^\d+$/).test(x); - } - - if (!v1parts.every(isValidPart) || !v2parts.every(isValidPart)) { - return NaN; - } - - if (zeroExtend) { - while (v1parts.length < v2parts.length) { - v1parts.push('0'); - } - while (v2parts.length < v1parts.length) { - v2parts.push('0'); - } - } - - if (!lexicographical) { - v1parts = v1parts.map(Number); - v2parts = v2parts.map(Number); - } - - for (var i = 0; i < v1parts.length; ++i) { - if (v2parts.length === i) { - return 1; - } - if (v1parts[i] === v2parts[i]) { - continue; - } - else if (v1parts[i] > v2parts[i]) { - return 1; - } - else { - return -1; - } - } - - if (v1parts.length !== v2parts.length) { - return -1; - } - - return 0; } }; diff --git a/src/utils/URLUtil-test.js b/src/utils/URLUtil-test.js new file mode 100644 index 0000000000..cfa137770d --- /dev/null +++ b/src/utils/URLUtil-test.js @@ -0,0 +1,65 @@ +jest.dontMock('./URLUtil'); +jest.dontMock('parseUri'); +var urlUtil = require('./URLUtil'); +var util = require('./Util'); + +describe('URLUtil', function () { + beforeEach(() => { + util.compareVersions.mockClear(); + util.isOfficialRepo.mockClear(); + }); + + it('does nothing if the url is undefined', () => { + util.compareVersions.mockReturnValue(1); + util.isOfficialRepo.mockReturnValue(true); + expect(urlUtil.openUrl()).toBe(false); + }); + + it('does nothing if the flags object is undefined', () => { + util.compareVersions.mockReturnValue(1); + util.isOfficialRepo.mockReturnValue(true); + expect(urlUtil.openUrl('docker://repository/run/redis')).toBe(false); + }); + + it('does nothing if the url enabled flag is falsy', () => { + util.compareVersions.mockReturnValue(1); + util.isOfficialRepo.mockReturnValue(true); + expect(urlUtil.openUrl('docker://repository/run/redis', {dockerURLEnabledVersion: undefined})).toBe(false); + }); + + it('does nothing if the url enabled flag version is higher than the app version', () => { + util.compareVersions.mockReturnValue(-1); + util.isOfficialRepo.mockReturnValue(true); + expect(urlUtil.openUrl('docker://repository/run/redis', {dockerURLEnabledVersion: '0.5.19'}, '0.5.18')).toBe(false); + }); + + it('does nothing if the type is not in the whitelist', () => { + util.compareVersions.mockReturnValue(1); + util.isOfficialRepo.mockReturnValue(true); + expect(urlUtil.openUrl('docker://badtype/run/redis', {dockerURLEnabledVersion: '0.5.19'}, '0.5.18')).toBe(false); + }); + + it('does nothing if the method is not in the whitelist', () => { + util.compareVersions.mockReturnValue(1); + util.isOfficialRepo.mockReturnValue(true); + expect(urlUtil.openUrl('docker://repository/badmethod/redis', {dockerURLEnabledVersion: '0.5.19'}, '0.5.18')).toBe(false); + }); + + it('does nothing if protocol is not docker:', () => { + util.compareVersions.mockReturnValue(1); + util.isOfficialRepo.mockReturnValue(true); + expect(urlUtil.openUrl('facetime://')).toBe(false); + }); + + it('does nothing if repo is not official', () => { + util.compareVersions.mockReturnValue(1); + util.isOfficialRepo.mockReturnValue(false); + expect(urlUtil.openUrl('docker://repository/run/not/official', {dockerURLEnabledVersion: '0.5.19'}, '0.5.20')).toBe(false); + }); + + it('returns true if type and method are correct', () => { + util.compareVersions.mockReturnValue(1); + util.isOfficialRepo.mockReturnValue(true); + expect(urlUtil.openUrl('docker://repository/run/redis', {dockerURLEnabledVersion: '0.5.19'}, '0.5.20')).toBe(true); + }); +}); diff --git a/src/utils/URLUtil.js b/src/utils/URLUtil.js new file mode 100644 index 0000000000..9f195ffdb4 --- /dev/null +++ b/src/utils/URLUtil.js @@ -0,0 +1,60 @@ +var util = require('./Util'); +var parseUri = require('parseUri'); +var containerStore = require('../stores/ContainerStore'); + +module.exports = { + TYPE_WHITELIST: ['repository'], + METHOD_WHITELIST: ['run'], + openUrl: function (url, flags, appVersion) { + if (!url || !flags || !flags.dockerURLEnabledVersion || !appVersion) { + return false; + } + + // Make sure this feature is enabled via the feature flag + if (util.compareVersions(appVersion, flags.dockerURLEnabledVersion) < 0) { + return false; + } + + var parser = parseUri(url); + + if (parser.protocol !== 'docker') { + return false; + } + + // Get the type of object we're operating on, e.g. 'repository' + var type = parser.host; + + if (this.TYPE_WHITELIST.indexOf(type) === -1) { + return false; + } + + // Separate the path into [run', 'redis'] + var tokens = parser.path.replace('/', '').split('/'); + + // Get the method trying to be executed, e.g. 'run' + var method = tokens[0]; + + if (this.METHOD_WHITELIST.indexOf(method) === -1) { + return false; + } + + // Get the repository namespace and repo name, e.g. 'redis' or 'myusername/myrepo' + var repo = tokens.slice(1).join('/'); + + // Only accept official repos for now (one component) + if (tokens > 1) { + return false; + } + + // Only accept official repos for now + if (!util.isOfficialRepo(repo)) { + return false; + } + + if (type === 'repository' && method === 'run') { + containerStore.setPending(repo, 'latest'); + return true; + } + return false; + } +}; diff --git a/src/utils/Util-test.js b/src/utils/Util-test.js index 6a5ba0797d..6ad6db2849 100644 --- a/src/utils/Util-test.js +++ b/src/utils/Util-test.js @@ -2,7 +2,7 @@ jest.dontMock('./Util'); var util = require('./Util'); describe('Util', function () { - describe('removeSensitiveData', function () { + describe('when removing sensitive data', function () { it('filters ssh certificate data', function () { var testdata = String.raw`time="2015-04-17T21:43:47-04:00" level="debug" msg="executing: ssh -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectionAttempts=30 -o LogLevel=quiet -p 50483 -i /Users/johnappleseed/.docker/machine/machines/dev2/id_rsa docker@localhost sudo mkdir -p /var/lib/boot2docker" time="2015-04-17T21:43:47-04:00" level="debug" msg="executing: ssh -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectionAttempts=30 -o LogLevel=quiet -p 50483 -i /Users/johnappleseed/.docker/machine/machines/dev2/id_rsa docker@localhost echo \"-----BEGIN CERTIFICATE-----\nMIIC+DCCAeKgAwIBAgIRANfIbsa2M94gDY+fBiBiQBkwCwYJKoZIhvcNAQELMBIx\nEDAOBgNVBAoTB2ptb3JnYW4wHhcNMTUwNDE4MDEzODAwWhcNMTgwNDAyMDEzODAw\nWjAPMQ0wCwYDVQQKEwRkZXYyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC\nAQEA1yamWT0bk0pRU7eiStjiXe2jkzdeI0SdJZo+bjczkl6kzNW/FmR/OkcP8gHX\nCO3fUCWkR/+rBgz3nuM1Sy0BIUo0EMQGfx17OqIJPXO+BrpCHsXlphHmbQl5bE2Y\nF+bAsGc6WCippw/caNnIHRsb6zAZVYX2AHLYY0fwIDAQABo1AwTjAOBgNVHQ8BAf8EBAMCAKAwHQYD\nVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwDwYDVR0R\nBAgwBocEwKhjZTALBgkqhkiG9w0BAQsDggEBAKBdD86+kl4X1VMjgGlNYnc42tWa\nbo1iDl/frxiLkfPSc2McAOm3AqX1ao+ynjqq1XTlBLPTQByu/oNZgA724LRJDfdG\nCKGUV8latW7rB1yhf/SZSmyhNjufuWlgCtbkw7Q/oPddzYuSOdDW8tVok9gMC0vL\naqKCWfVKkCmvGH+8/wPrkYmro/f0uwJ8ee+yrbBPlBE/qE+Lqcfr0YcXEDaS8CmL\nDjWg7KNFpA6M+/tFNQhplbjwRsCt7C4bzQu0aBIG5XH1Jr2HrKlLjWdmluPHWUL6\nX5Vh1bslYJzsSdBNZFWSKShZ+gtRpjtV7NynANDJPQNIRhDxAf4uDY9hA2c=\n-----END CERTIFICATE-----\n\" | sudo tee /var/lib/boot2docker/server.pem" time="2015-04-17T21:43:47-04:00" level="debug" msg="executing: /usr/bin/VBoxManage showvminfo dev2 --machinereadable"`; @@ -32,4 +32,26 @@ describe('Util', function () { expect(util.removeSensitiveData(undefined)).toBe(undefined); }); }); + + describe('when verifying that a repo is official', function () { + it('accepts official repo', () => { + expect(util.isOfficialRepo('redis')).toBe(true); + }); + + it('rejects falsy value as official repo', () => { + expect(util.isOfficialRepo(undefined)).toBe(false); + }); + + it('rejects empty repo name', () => { + expect(util.isOfficialRepo('')).toBe(false); + }); + + it('rejects repo with non official namespace', () => { + expect(util.isOfficialRepo('kitematic/html')).toBe(false); + }); + + it('rejects repo with a different registry address', () => { + expect(util.isOfficialRepo('www.myregistry.com/kitematic/html')).toBe(false); + }); + }); }); diff --git a/src/utils/Util.js b/src/utils/Util.js index 5b23058f7d..4473549441 100644 --- a/src/utils/Util.js +++ b/src/utils/Util.js @@ -52,5 +52,66 @@ module.exports = { } catch (err) {} return settingsjson; }, + isOfficialRepo: function (name) { + if (!name || !name.length) { + return false; + } + + // An official repo is alphanumeric characters separated by dashes or + // underscores. + // Examples: myrepo, my-docker-repo, my_docker_repo + // Non-exapmles: mynamespace/myrepo, my%!repo + var repoRegexp = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/; + return repoRegexp.test(name); + }, + compareVersions: function (v1, v2, options) { + var lexicographical = options && options.lexicographical, + zeroExtend = options && options.zeroExtend, + v1parts = v1.split('.'), + v2parts = v2.split('.'); + + function isValidPart(x) { + return (lexicographical ? /^\d+[A-Za-z]*$/ : /^\d+$/).test(x); + } + + if (!v1parts.every(isValidPart) || !v2parts.every(isValidPart)) { + return NaN; + } + + if (zeroExtend) { + while (v1parts.length < v2parts.length) { + v1parts.push('0'); + } + while (v2parts.length < v1parts.length) { + v2parts.push('0'); + } + } + + if (!lexicographical) { + v1parts = v1parts.map(Number); + v2parts = v2parts.map(Number); + } + + for (var i = 0; i < v1parts.length; ++i) { + if (v2parts.length === i) { + return 1; + } + if (v1parts[i] === v2parts[i]) { + continue; + } + else if (v1parts[i] > v2parts[i]) { + return 1; + } + else { + return -1; + } + } + + if (v1parts.length !== v2parts.length) { + return -1; + } + + return 0; + }, webPorts: ['80', '8000', '8080', '3000', '5000', '2368', '9200', '8983'] }; diff --git a/styles/new-container.less b/styles/new-container.less index 4633387b3e..70deb47743 100644 --- a/styles/new-container.less +++ b/styles/new-container.less @@ -1,3 +1,32 @@ +.new-container-pull { + display: flex; + flex: 1 auto; + align-items: center; + justify-content: center; + .content { + text-align: center; + + .buttons { + margin-top: 30px; + .btn { + margin-left: 10px; + margin-right: 10px; + padding: 8px 18px; + font-size: 14px; + background: white; + font-weight: 300; + } + } + } + h1 { + font-size: 20px; + color: @gray-normal; + font-weight: 400; + text-align: center; + margin-top: 10px; + } +} + .new-container { display: flex; flex: 1 auto; diff --git a/styles/theme.less b/styles/theme.less index 07e8cedd22..d20daccc76 100644 --- a/styles/theme.less +++ b/styles/theme.less @@ -134,7 +134,6 @@ input[type="text"] { font-weight: 400; text-shadow: none; padding: 5px 14px 5px 14px; - height: 30px; cursor: default; &.small { diff --git a/util/Info.plist b/util/Info.plist index 396be69183..5efd29e633 100644 --- a/util/Info.plist +++ b/util/Info.plist @@ -26,5 +26,16 @@ AtomApplication NSSupportsAutomaticGraphicsSwitching + CFBundleURLTypes + + + CFBundleURLSchemes + + docker + + CFBundleURLName + Docker App Protocol + +