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 (
+
+
+
+
+
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 = (
+
+ );
+ } 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
+
+