diff --git a/.gitignore b/.gitignore index 73c0553298..293db15e3e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,8 +11,8 @@ bin # Resources resources/cache resources/base-images.tar.gz -resources/virtualbox-4.3.12.pkg -resources/boot2docker +resources/virtualbox-*.pkg +resources/boot2docker* resources/node-webkit resources/mongod resources/MONGOD_LICENSE.txt diff --git a/index.html b/index.html deleted file mode 100644 index 850739c226..0000000000 --- a/index.html +++ /dev/null @@ -1,9 +0,0 @@ - - - - Welcome to Kitematic - - - - - diff --git a/index.js b/index.js index 9edc43f678..58d006cf6c 100644 --- a/index.js +++ b/index.js @@ -4,6 +4,9 @@ var os = require('os'); var fs = require('fs'); var path = require('path'); +var app = require('app'); +var BrowserWindow = require('browser-window'); + var freeport = function (callback) { var server = net.createServer(); var port = 0; @@ -44,7 +47,7 @@ var start = function (callback) { console.log('MongoDB: ' + mongoPort); console.log('webPort: ' + webPort); child_process.exec('kill $(ps aux -e | grep PURPOSE=KITEMATIC | awk \'{print $2}\') && rm ' + path.join(dataPath, 'mongod.lock'), function (error, stdout, stderr) { - var mongoChild = child_process.spawn(path.join(process.cwd(), 'resources', 'mongod'), ['--bind_ip', '127.0.0.1', '--dbpath', dataPath, '--port', mongoPort, '--unixSocketPrefix', dataPath], { + var mongoChild = child_process.spawn(path.join(__dirname, 'resources', 'mongod'), ['--bind_ip', '127.0.0.1', '--dbpath', dataPath, '--port', mongoPort, '--unixSocketPrefix', dataPath], { env: { PURPOSE: 'KITEMATIC' } @@ -68,8 +71,11 @@ var start = function (callback) { user_env.BIND_IP = '127.0.0.1'; user_env.DB_PATH = dataPath; user_env.MONGO_URL = 'mongodb://localhost:' + mongoPort + '/meteor'; - console.log(path.join(process.cwd(), 'resources', 'node')); - var nodeChild = child_process.spawn(path.join(process.cwd(), 'resources', 'node'), ['./bundle/main.js'], { + user_env.METEOR_SETTINGS = fs.readFileSync(path.join(__dirname, 'resources', 'settings.json'), 'utf8'); + user_env.DIR = __dirname; + user_env.NODE_ENV = 'production'; + user_env.NODE_PATH = path.join(__dirname, 'node_modules'); + var nodeChild = child_process.spawn(path.join(__dirname, 'resources', 'node'), [path.join(__dirname, 'bundle', 'main.js')], { env: user_env }); @@ -94,40 +100,47 @@ var start = function (callback) { } }; -start(function (url, nodeChild, mongoChild) { - var cleanUpChildren = function () { - console.log('Cleaning up children.') - mongoChild.kill(); - nodeChild.kill(); - }; - if (nodeChild && mongoChild) { - process.on('exit', cleanUpChildren); - process.on('uncaughtException', cleanUpChildren); - process.on('SIGINT', cleanUpChildren); - process.on('SIGTERM', cleanUpChildren); - } +mainWindow = null; - var gui = require('nw.gui'); - var mainWindow = gui.Window.get(); - gui.App.on('reopen', function () { +app.on('activate-with-no-open-windows', function () { + if (mainWindow) { mainWindow.show(); - }); - setTimeout(function () { - mainWindow.window.location = url; - mainWindow.on('loaded', function () { - mainWindow.show(); - }); - }, 400); - mainWindow.on('close', function (type) { - this.hide(); - console.log('closed'); - if (type === 'quit') { - console.log('here'); - if (nodeChild && mongoChild) { - cleanUpChildren(); - } - this.close(true); + } + return false; +}); + +app.on('ready', function() { + start(function (url, nodeChild, mongoChild) { + var cleanUpChildren = function () { + console.log('Cleaning up children.') + mongoChild.kill(); + nodeChild.kill(); + app.quit(); + process.exit(); + }; + + if (nodeChild && mongoChild) { + process.on('exit', cleanUpChildren); + process.on('uncaughtException', cleanUpChildren); + process.on('SIGINT', cleanUpChildren); + process.on('SIGTERM', cleanUpChildren); } - console.log('Window Closed.'); + + // Create the browser window. + var windowOptions = { + width: 800, + height: 578, + frame: false, + resizable: false, + 'web-preferences': { + 'web-security': false + } + }; + mainWindow = new BrowserWindow(windowOptions); + + // and load the index.html of the app. + mainWindow.loadUrl(url); + mainWindow.show(); + mainWindow.focus(); }); }); diff --git a/kitematic.icns b/kitematic.icns new file mode 100644 index 0000000000..8f8fdaac95 Binary files /dev/null and b/kitematic.icns differ diff --git a/meteor/.jshintrc b/meteor/.jshintrc index 1398d15f46..66bf659a23 100755 --- a/meteor/.jshintrc +++ b/meteor/.jshintrc @@ -157,6 +157,7 @@ "Images": true, "Apps": true, "Installs": true, + "Settings": true, "Docker": true, "Util": true, "Sync": true, diff --git a/meteor/.meteor/.finished-upgraders b/meteor/.meteor/.finished-upgraders new file mode 100644 index 0000000000..ee0ed5a316 --- /dev/null +++ b/meteor/.meteor/.finished-upgraders @@ -0,0 +1,6 @@ +# This file contains information which helps Meteor properly upgrade your +# app when you run 'meteor update'. You should check it into version control +# with your project. + +notices-for-0.9.0 +notices-for-0.9.1 diff --git a/meteor/.meteor/packages b/meteor/.meteor/packages index 0a499fa9ec..ef35f4dc26 100755 --- a/meteor/.meteor/packages +++ b/meteor/.meteor/packages @@ -6,9 +6,8 @@ standard-app-packages less bootstrap3-less -iron-router handlebar-helpers underscore-string-latest collection-helpers -fast-render -iron-router-ga +iron:router +reywood:iron-router-ga diff --git a/meteor/.meteor/release b/meteor/.meteor/release index ee94dd834b..8f6f45d174 100755 --- a/meteor/.meteor/release +++ b/meteor/.meteor/release @@ -1 +1 @@ -0.8.3 +METEOR@0.9.1.1 diff --git a/meteor/.meteor/versions b/meteor/.meteor/versions index de5ff9f39c..a3642e1f6c 100644 --- a/meteor/.meteor/versions +++ b/meteor/.meteor/versions @@ -1,60 +1,54 @@ -accounts-base@1.0.0 -application-configuration@1.0.0 -autoupdate@1.0.4 +application-configuration@1.0.1 +autoupdate@1.0.6 binary-heap@1.0.0 blaze-tools@1.0.0 -blaze@1.0.3 +blaze@2.0.0 bootstrap3-less@0.0.0 callback-hook@1.0.0 check@1.0.0 collection-helpers@0.0.0 -collection-hooks@0.0.0 -collection2@0.0.0 -ctl-helper@1.0.2 -ctl@1.0.0 -deps@1.0.0 -ejson@1.0.0 -fast-render@0.0.0 -follower-livedata@1.0.0 +ctl-helper@1.0.3 +ctl@1.0.1 +ddp@1.0.8 +deps@1.0.3 +ejson@1.0.1 +follower-livedata@1.0.1 geojson-utils@1.0.0 handlebar-helpers@0.0.0 -headers@0.0.0 html-tools@1.0.0 htmljs@1.0.0 id-map@1.0.0 -inject-initial@0.0.0 -iron-core@0.2.0 -iron-dynamic-template@0.2.1 -iron-layout@0.2.0 -iron-router@0.8.2 +iron:core@0.3.4 +iron:dynamic-template@0.4.1 +iron:layout@0.4.1 +iron:router@0.9.3 jquery@1.0.0 json@1.0.0 -less@1.0.4 -livedata@1.0.5 -localstorage@1.0.0 +less@1.0.7 +livedata@1.0.9 logging@1.0.2 -meteor@1.0.2 +meteor-platform@1.0.2 +meteor@1.0.3 minifiers@1.0.2 -minimongo@1.0.1 -moment@0.0.0 -mongo-livedata@1.0.3 -npm@0.0.0 -observe-sequence@1.0.1 -octicons@0.0.0 +minimongo@1.0.2 +mongo-livedata@1.0.4 +mongo@1.0.4 +observe-sequence@1.0.2 ordered-dict@1.0.0 random@1.0.0 -reactive-dict@1.0.0 -reload@1.0.0 +reactive-dict@1.0.2 +reactive-var@1.0.1 +reload@1.0.1 retry@1.0.0 +reywood:iron-router-ga@0.3.2 routepolicy@1.0.0 -service-configuration@1.0.0 -session@1.0.0 -simple-schema@0.0.0 -spacebars-compiler@1.0.1 -spacebars@1.0.0 -standard-app-packages@1.0.0 -templating@1.0.4 -ui@1.0.0 +session@1.0.1 +spacebars-compiler@1.0.2 +spacebars@1.0.1 +standard-app-packages@1.0.1 +templating@1.0.5 +tracker@1.0.2 +ui@1.0.2 underscore-string-latest@0.0.0 underscore@1.0.0 -webapp@1.0.2 +webapp@1.0.3 diff --git a/meteor/client/lib/apputil.js b/meteor/client/lib/apputil.js index 4af94d476d..058b0401f4 100644 --- a/meteor/client/lib/apputil.js +++ b/meteor/client/lib/apputil.js @@ -53,17 +53,20 @@ AppUtil.start = function (appId) { Apps.update(app._id, {$set: { status: 'STARTING' }}); - Docker.getContainerData(app.docker.Id, function (err, data) { + Docker.startContainer(app.docker.Id, function (err) { if (err) { console.error(err); } - // Use dig to refresh the DNS - exec('/usr/bin/dig ' + app.name + '.kite @172.17.42.1', function(err, stdout, stderr) { - console.log(err); - console.log(stdout); - console.log(stderr); - Apps.update(app._id, {$set: { - status: 'READY', - docker: data - }}); + Docker.getContainerData(app.docker.Id, function (err, data) { + if (err) { console.error(err); } + // Use dig to refresh the DNS + exec('/usr/bin/dig ' + app.name + '.kite @172.17.42.1', function(err, stdout, stderr) { + console.log(err); + console.log(stdout); + console.log(stderr); + Apps.update(app._id, {$set: { + status: 'READY', + docker: data + }}); + }); }); }); } @@ -98,8 +101,8 @@ AppUtil.restart = function (appId) { AppUtil.remove = function (appId) { var app = Apps.findOne(appId); + Apps.remove({_id: appId}); if (app.docker) { - Apps.remove({_id: appId}); Docker.removeContainer(app.docker.Id, function (err) { if (err) { console.error(err); } var appPath = path.join(Util.KITE_PATH, app.name); @@ -131,7 +134,6 @@ AppUtil.logs = function (appId) { logs: [] } }); - var logs = []; response.setEncoding('utf8'); response.on('data', function (line) { Apps.update(app._id, { diff --git a/meteor/client/lib/boot2docker.js b/meteor/client/lib/boot2docker.js index 8e19950443..a98369e0fc 100644 --- a/meteor/client/lib/boot2docker.js +++ b/meteor/client/lib/boot2docker.js @@ -6,8 +6,12 @@ Boot2Docker = {}; Boot2Docker.REQUIRED_IP = '192.168.60.103'; +Boot2Docker.command = function () { + return path.join(Util.getBinDir(), 'boot2docker-1.2.0') + ' --vm="kitematic-vm"'; +}; + Boot2Docker.exec = function (command, callback) { - exec(path.join(Util.getBinDir(), 'boot2docker') + ' ' + command, function(err, stdout, stderr) { + exec(Boot2Docker.command() + ' ' + command, function(err, stdout, stderr) { callback(err, stdout, stderr); }); }; @@ -30,7 +34,7 @@ Boot2Docker.stop = function (callback) { }; Boot2Docker.erase = function (callback) { - var VMFileLocation = path.join(Util.getHomePath(), 'VirtualBox\\ VMs/boot2docker-vm'); + var VMFileLocation = path.join(Util.getHomePath(), 'VirtualBox\\ VMs/kitematic-vm'); exec('rm -rf ' + VMFileLocation, function (err) { callback(err); }); @@ -235,7 +239,7 @@ Boot2Docker.version = function (callback) { }; Boot2Docker.injectUtilities = function (callback) { - exec('/bin/cat ' + path.join(Util.getBinDir(), 'kite-binaries.tar.gz') + ' | ' + path.join(Util.getBinDir(), 'boot2docker') + ' ssh "tar zx -C /usr/local/bin"', function (err, stdout) { + exec('/bin/cat ' + path.join(Util.getBinDir(), 'kite-binaries.tar.gz') + ' | ' + Boot2Docker.command() + ' ssh "tar zx -C /usr/local/bin"', function (err, stdout) { callback(err); }); }; diff --git a/meteor/client/lib/docker.js b/meteor/client/lib/docker.js index f4c4e70855..2bd3b447f4 100644 --- a/meteor/client/lib/docker.js +++ b/meteor/client/lib/docker.js @@ -6,6 +6,9 @@ var path = require('path'); Docker = {}; Docker.DOCKER_HOST = '192.168.60.103'; +Docker.DEFAULT_IMAGES_FILENAME = 'base-images-0.0.2.tar.gz'; +Docker.DEFAULT_IMAGES_CHECKSUM = 'a3517ac21034a1969d9ff15e3c41b1e2f1aa83c67b16a8bd0bc378ffefaf573b'; // Sha256 Checksum + Docker.client = function () { return new Dockerode({host: Docker.DOCKER_HOST, port: '2375'}); }; @@ -83,7 +86,7 @@ Docker.runContainer = function (app, image, callback) { Docker.startContainer = function (containerId, callback) { var container = docker.getContainer(containerId); - container.stop(function (err) { + container.start(function (err) { if (err) { console.log(err); callback(err); @@ -158,7 +161,7 @@ Docker.removeImage = function (imageId, callback) { }; Docker.removeBindFolder = function (name, callback) { - exec(path.join(Util.getBinDir(), 'boot2docker') + ' ssh "sudo rm -rf /var/lib/docker/binds/' + name + '"', function (err, stdout) { + exec(Boot2Docker.command() + ' ssh "sudo rm -rf /var/lib/docker/binds/' + name + '"', function (err, stdout) { callback(err, stdout); }); }; @@ -267,13 +270,13 @@ Docker.reloadDefaultContainers = function (callback) { async.until(function () { return ready; }, function (callback) { - docker.listContainers(function (err, containers) { + docker.listContainers(function (err) { if (!err) { ready = true; } callback(); }); - }, function (err) { + }, function () { console.log('Removing old Kitematic default containers.'); Docker.killAndRemoveContainers(Docker.defaultContainerNames, function (err) { console.log('Removed old Kitematic default containers.'); @@ -283,7 +286,7 @@ Docker.reloadDefaultContainers = function (callback) { return; } console.log('Loading new Kitematic default images.'); - docker.loadImage(path.join(Util.getBinDir(), 'base-images.tar.gz'), {}, function (err) { + docker.loadImage(path.join(Util.getResourceDir(), Docker.DEFAULT_IMAGES_FILENAME), {}, function (err) { if (err) { callback(err); return; diff --git a/meteor/client/lib/installer.js b/meteor/client/lib/installer.js index 2535019432..aeeebd177f 100644 --- a/meteor/client/lib/installer.js +++ b/meteor/client/lib/installer.js @@ -1,10 +1,13 @@ var async = require('async'); var fs = require('fs'); var path = require('path'); +var remote = require('remote'); +var app = remote.require('app'); Installer = {}; -Installer.CURRENT_VERSION = '0.0.2'; +Installer.CURRENT_VERSION = app.getVersion(); +Installer.baseURL = 'https://s3.amazonaws.com/kite-installer/'; Installer.isUpToDate = function () { return !!Installs.findOne({version: Installer.CURRENT_VERSION}); @@ -19,26 +22,42 @@ Installer.isUpToDate = function () { */ Installer.steps = [ { - run: function (callback) { + run: function (callback, progressCallback) { var installed = VirtualBox.installed(); if (!installed) { - VirtualBox.install(function (err) { - callback(err); + Util.downloadFile(Installer.baseURL + VirtualBox.INSTALLER_FILENAME, path.join(Util.getResourceDir(), VirtualBox.INSTALLER_FILENAME), VirtualBox.INSTALLER_CHECKSUM, function (err) { + if (err) {callback(err); return;} + VirtualBox.install(function (err) { + if (!VirtualBox.installed()) { + callback('VirtualBox could not be installed. The installation either failed or was cancelled. Please try closing all VirtualBox instances and try again.'); + } else { + callback(err); + } + }); + }, function (progress) { + progressCallback(progress); }); } else { // Version 4.3.12 is required. VirtualBox.version(function (err, installedVersion) { - if (err) { - callback(err); - return; - } + if (err) {callback(err); return;} if (Util.compareVersions(installedVersion, VirtualBox.REQUIRED_VERSION) < 0) { - VirtualBox.install(function (err) { - if (Util.compareVersions(installedVersion, VirtualBox.REQUIRED_VERSION) < 0) { - callback('VirtualBox could not be installed. The installation either failed or was cancelled. Please try closing all VirtualBox instances and try again.'); - } else { - callback(err); - } + // Download a newer version of Virtualbox + Util.downloadFile(Installer.baseURL + VirtualBox.INSTALLER_FILENAME, path.join(Util.getResourceDir(), VirtualBox.INSTALLER_FILENAME), VirtualBox.INSTALLER_CHECKSUM, function (err) { + if (err) {callback(err); return;} + VirtualBox.install(function (err) { + if (err) {callback(err); return;} + VirtualBox.version(function (err, installedVersion) { + if (err) {callback(err); return;} + if (Util.compareVersions(installedVersion, VirtualBox.REQUIRED_VERSION) < 0) { + callback('VirtualBox could not be installed. The installation either failed or was cancelled. Please try closing all VirtualBox instances and try again.'); + } else { + callback(err); + } + }); + }, function (progress) { + progressCallback(progress); + }); }); } else { callback(); @@ -47,8 +66,8 @@ Installer.steps = [ } }, pastMessage: 'VirtualBox installed', - message: 'Installing VirtualBox', - futureMessage: 'Install VirtualBox if necessary' + message: 'Downloading & Installing VirtualBox', + futureMessage: 'Download & Install VirtualBox if necessary' }, // Initialize Boot2Docker if necessary. @@ -57,7 +76,7 @@ Installer.steps = [ Boot2Docker.exists(function (err, exists) { if (err) { callback(err); return; } if (!exists) { - var vmFilesPath = path.join(Util.getHomePath(), 'VirtualBox\ VMs', 'boot2docker-vm'); + var vmFilesPath = path.join(Util.getHomePath(), 'VirtualBox\ VMs', 'kitematic-vm'); if (fs.existsSync(vmFilesPath)) { Util.deleteFolder(vmFilesPath); } @@ -67,30 +86,30 @@ Installer.steps = [ } else { if (!Boot2Docker.sshKeyExists()) { callback('Boot2Docker SSH key doesn\'t exist. Fix by deleting the existing Boot2Docker VM and re-run the installer. This usually occurs because an old version of Boot2Docker is installed.'); - } - Boot2Docker.stop(function (err) { - if (err) { callback(err); return; } - Boot2Docker.upgrade(function (err) { - callback(err); + } else { + Boot2Docker.stop(function(err) { + Boot2Docker.upgrade(function (err) { + callback(err); + }); }); - }); + } } }); }, - pastMessage: 'Setup the Boot2Docker VM (if required)', - message: 'Setting up the Boot2Docker VM', - futureMessage: 'Set up the Boot2Docker VM(if required)' + pastMessage: 'Setup the Kitematic VM (if required)', + message: 'Setting up the Kitematic VM', + futureMessage: 'Set up the Kitematic VM(if required)' }, { run: function (callback) { - VirtualBox.addCustomHostAdapter('boot2docker-vm', function (err, ifname) { + VirtualBox.addCustomHostAdapter('kitematic-vm', function (err, ifname) { callback(err); }); }, - pastMessage: 'Added custom host adapter to the Boot2Docker VM', - message: 'Adding custom host adapter to the Boot2Docker VM', - futureMessage: 'Add custom host adapter to the Boot2Docker VM' + pastMessage: 'Added custom host adapter to the Kitematic VM', + message: 'Adding custom host adapter to the Kitematic VM', + futureMessage: 'Add custom host adapter to the Kitematic VM' }, // Start the Kitematic VM @@ -110,14 +129,14 @@ Installer.steps = [ } }); }, - pastMessage: 'Started the Boot2Docker VM', - message: 'Starting the Boot2Docker VM', + pastMessage: 'Started the Kitematic VM', + message: 'Starting the Kitematic VM', futureMessage: 'Start the Kitematic VM' }, { run: function (callback) { - VirtualBox.setupRouting('boot2docker-vm', function (err, ifname) { + VirtualBox.setupRouting('kitematic-vm', function (err, ifname) { callback(err); }); }, @@ -128,12 +147,16 @@ Installer.steps = [ // Set up the default Kitematic images { - run: function (callback) { - Docker.reloadDefaultContainers(function (err) { - callback(err); + run: function (callback, progressCallback) { + Util.downloadFile(Installer.baseURL + Docker.DEFAULT_IMAGES_FILENAME, path.join(Util.getResourceDir(), Docker.DEFAULT_IMAGES_FILENAME), Docker.DEFAULT_IMAGES_CHECKSUM, function (err) { + Docker.reloadDefaultContainers(function (err) { + callback(err); + }); + }, function (progress) { + progressCallback(progress); }); }, - pastMessage: 'Started the Boot2Docker VM', + pastMessage: 'Set up the default Kitematic images.', message: 'Setting up the default Kitematic images...', subMessage: '(This may take a few minutes)', futureMessage: 'Set up the default Kitematic images' @@ -146,6 +169,7 @@ Installer.run = function (callback) { Session.set('numberOfInstallSteps', this.steps.length); async.eachSeries(this.steps, function (step, callback) { console.log('Performing step ' + currentStep); + Session.set('currentInstallStepProgress', 0); step.run(function (err) { if (err) { callback(err); @@ -154,6 +178,8 @@ Installer.run = function (callback) { Session.set('currentInstallStep', currentStep); callback(); } + }, function (progress) { + Session.set('currentInstallStepProgress', progress); }); }, function (err) { if (err) { diff --git a/meteor/client/lib/menu.js b/meteor/client/lib/menu.js new file mode 100644 index 0000000000..1fc2f38323 --- /dev/null +++ b/meteor/client/lib/menu.js @@ -0,0 +1,130 @@ +var remote = require('remote'); +var app = remote.require('app'); +var Menu = remote.require('menu'); + +// main.js +var template = [ + { + label: 'Kitematic', + submenu: [ + { + label: 'About Kitematic', + selector: 'orderFrontStandardAboutPanel:' + }, + { + type: 'separator' + }, + { + label: 'Services', + submenu: [] + }, + { + type: 'separator' + }, + { + label: 'Hide Kitematic', + accelerator: 'Command+H', + selector: 'hide:' + }, + { + label: 'Hide Others', + accelerator: 'Command+Shift+H', + selector: 'hideOtherApplications:' + }, + { + label: 'Show All', + selector: 'unhideAllApplications:' + }, + { + type: 'separator' + }, + { + label: 'Quit', + accelerator: 'Command+Q', + click: function() { app.quit(); } + } + ] + }, + { + label: 'Edit', + submenu: [ + { + label: 'Undo', + accelerator: 'Command+Z', + selector: 'undo:' + }, + { + label: 'Redo', + accelerator: 'Shift+Command+Z', + selector: 'redo:' + }, + { + type: 'separator' + }, + { + label: 'Cut', + accelerator: 'Command+X', + selector: 'cut:' + }, + { + label: 'Copy', + accelerator: 'Command+C', + selector: 'copy:' + }, + { + label: 'Paste', + accelerator: 'Command+V', + selector: 'paste:' + }, + { + label: 'Select All', + accelerator: 'Command+A', + selector: 'selectAll:' + } + ] + }, + { + label: 'View', + submenu: [ + { + label: 'Reload', + accelerator: 'Command+R', + click: function() { remote.getCurrentWindow().reloadIgnoringCache(); } + }, + { + label: 'Toggle DevTools', + accelerator: 'Alt+Command+I', + click: function() { remote.getCurrentWindow().toggleDevTools(); } + } + ] + }, + { + label: 'Window', + submenu: [ + { + label: 'Minimize', + accelerator: 'Command+M', + selector: 'performMiniaturize:' + }, + { + label: 'Close', + accelerator: 'Command+W', + click: function () { remote.getCurrentWindow().hide(); } + }, + { + type: 'separator' + }, + { + label: 'Bring All to Front', + selector: 'arrangeInFront:' + } + ] + }, + { + label: 'Help', + submenu: [] + } +]; + +var menu = Menu.buildFromTemplate(template); +Menu.setApplicationMenu(menu); diff --git a/meteor/client/lib/router.js b/meteor/client/lib/router.js index 6e8168efd6..dac40de9da 100755 --- a/meteor/client/lib/router.js +++ b/meteor/client/lib/router.js @@ -1,19 +1,26 @@ Router.configure({ layoutTemplate: 'layout', - trackPageView: true + onBeforeAction: function () { + var install = Installs.findOne({}); + if (install && install.tracking) { + var currentPath = Router.current().path; + console.log(currentPath); + ga('send', 'pageview', currentPath); + } + } }); SetupController = RouteController.extend({ layoutTemplate: 'setup_layout', waitOn: function () { - return [Meteor.subscribe('installs')]; + return [Meteor.subscribe('installs'), Meteor.subscribe('settings')]; } }); DashboardController = RouteController.extend({ layoutTemplate: 'dashboard_layout', waitOn: function () { - return [Meteor.subscribe('apps'), Meteor.subscribe('images'), Meteor.subscribe('installs')]; + return [Meteor.subscribe('apps'), Meteor.subscribe('images'), Meteor.subscribe('installs'), Meteor.subscribe('settings')]; } }); @@ -43,6 +50,11 @@ Router.map(function () { controller: 'SetupController' }); + this.route('setup_finish', { + path: '/setup/finish', + controller: 'SetupController' + }); + this.route('setup', { path: '/', controller: 'SetupController', @@ -51,12 +63,11 @@ Router.map(function () { if (!Installer.isUpToDate()) { if (!Installs.findOne()) { console.log('No installs detected, running installer again.'); - this.redirect('/setup/intro'); } else { // There's an install but it's lower than the current version, re-run as an 'update'. Session.set('isUpdating', true); - this.redirect('/setup/intro'); } + this.redirect('/setup/intro'); } else { this.redirect('/apps'); } diff --git a/meteor/client/lib/startup.js b/meteor/client/lib/startup.js index 7b5ae10af0..d0b9b6ba8e 100644 --- a/meteor/client/lib/startup.js +++ b/meteor/client/lib/startup.js @@ -13,4 +13,7 @@ Meteor.startup(function () { console.log('Created Kitematic .images directory.'); fs.mkdirSync(Util.KITE_IMAGES_PATH); } + if (!fs.existsSync(Util.getResourceDir())) { + fs.mkdirSync(Util.getResourceDir()); + } }); diff --git a/meteor/client/lib/sync.js b/meteor/client/lib/sync.js index d52702a55f..00157febf9 100644 --- a/meteor/client/lib/sync.js +++ b/meteor/client/lib/sync.js @@ -59,18 +59,20 @@ Sync.addAppWatcher = function (app) { } exec(args, function (err, out, code) { + var results = null; + var location = null; try { if (err.indexOf('the archives are locked.') !== -1) { - var results = errorPattern.exec(err); - var location = results[1].replace(' ', '\\ '); + results = errorPattern.exec(err); + location = results[1].replace(' ', '\\ '); exec('/bin/rm -rf ' + location, function () { console.log('Removed unison file.'); console.log(location); }); } if (err.indexOf('The archive file is missing on some hosts') !== -1) { - var results = archiveErrorPattern.exec(err); - var location = results[1].replace(' ', '\\ '); + results = archiveErrorPattern.exec(err); + location = results[1].replace(' ', '\\ '); var fullLocation = path.join(Util.getHomePath(), 'Library/Application\\ Support/Unison', location); var cmd = '/bin/rm -rf ' + fullLocation; exec(cmd, function () {}); diff --git a/meteor/client/lib/util.js b/meteor/client/lib/util.js index 88ee3f3b9f..7a89cbe4e0 100755 --- a/meteor/client/lib/util.js +++ b/meteor/client/lib/util.js @@ -1,5 +1,8 @@ var path = require('path'); var fs = require('fs'); +var nodeCrypto = require('crypto'); +var request = require('request'); +var progress = require('request-progress'); Util = {}; @@ -8,15 +11,11 @@ Util.getHomePath = function () { }; Util.getBinDir = function () { - if (process.env.NODE_ENV === 'development') { - return path.join(path.join(process.env.PWD, '..'), 'resources'); - } else { - if (Meteor.isClient) { - return path.join(process.cwd(), 'resources'); - } else { - return path.join(process.cwd(), '../../../resources'); - } - } + return path.join(process.env.DIR, 'resources'); +}; + +Util.getResourceDir = function () { + return path.join(Util.getHomePath(), 'Library/Application\ Support/Kitematic/Resources'); }; Util.KITE_PATH = path.join(Util.getHomePath(), 'Kitematic'); @@ -78,6 +77,47 @@ Util.hasDockerfile = function (directory) { return fs.existsSync(path.join(directory, 'Dockerfile')); }; +Util.openTerminal = function (command) { + var terminalCmd = path.join(Util.getBinDir(), 'terminal') + ' ' + command; + var exec = require('child_process').exec; + exec(terminalCmd, function (err, stdout) { + console.log(stdout); + if (err) { + console.log(err); + } + }); +}; + +Util.downloadFile = function (url, filename, checksum, callback, progressCallback) { + var doDownload = function () { + progress(request(url), { + throttle: 250 + }).on('progress', function (state) { + progressCallback(state.percent); + }).on('error', function (err) { + callback(err); + }).pipe(fs.createWriteStream(filename)).on('error', function (err) { + callback(err); + }).on('close', function (err) { + callback(err); + }); + }; + + // Compare checksum to see if it already exists first + if (fs.existsSync(filename)) { + var existingChecksum = nodeCrypto.createHash('sha256').update(fs.readFileSync(filename), 'utf8').digest('hex'); + console.log(existingChecksum); + if (existingChecksum !== checksum) { + fs.unlinkSync(filename); + doDownload(); + } else { + callback(); + } + } else { + doDownload(); + } +}; + /** * Compares two software version numbers (e.g. "1.7.1" or "1.2b"). * @@ -156,8 +196,11 @@ Util.compareVersions = function (v1, v2, options) { }; trackLink = function (trackLabel) { - if (trackLabel) { - console.log(trackLabel); - ga('send', 'event', 'link', 'click', trackLabel); + var install = Installs.findOne({}); + if (install && install.tracking) { + if (trackLabel) { + console.log(trackLabel); + ga('send', 'event', 'link', 'click', trackLabel); + } } }; diff --git a/meteor/client/lib/virtualbox.js b/meteor/client/lib/virtualbox.js index 24d58e0094..de70fe6c74 100644 --- a/meteor/client/lib/virtualbox.js +++ b/meteor/client/lib/virtualbox.js @@ -7,6 +7,7 @@ VirtualBox = {}; VirtualBox.REQUIRED_VERSION = '4.3.14'; VirtualBox.INCLUDED_VERSION = '4.3.14'; VirtualBox.INSTALLER_FILENAME = 'virtualbox-4.3.14.pkg'; +VirtualBox.INSTALLER_CHECKSUM = '486348a5336539728ca20dcd9674cf3d37e5c7f32255d90f1edc7391b54bd5dd'; // Sha256 Checksum // Info for the hostonly interface we add to the VM. VirtualBox.HOSTONLY_HOSTIP = '192.168.60.3'; @@ -24,7 +25,7 @@ VirtualBox.exec = function (command, callback) { VirtualBox.install = function (callback) { // -W waits for the process to close before finishing. - exec('open -W ' + path.join(Util.getBinDir(), this.INSTALLER_FILENAME), function (error, stdout, stderr) { + exec('open -W ' + path.join(Util.getResourceDir(), this.INSTALLER_FILENAME).replace(' ', '\\ '), function (error, stdout, stderr) { console.log(stdout); console.log(stderr); if (error) { diff --git a/meteor/client/main.js b/meteor/client/main.js index a406e1616f..02d807b144 100755 --- a/meteor/client/main.js +++ b/meteor/client/main.js @@ -1,11 +1,11 @@ try { moment = require('moment'); - gui = require('nw.gui'); - gui.App.clearCache(); - win = gui.Window.get(); - var nativeMenuBar = new gui.Menu({type: 'menubar'}); - nativeMenuBar.createMacBuiltin('Kitematic'); - win.menu = nativeMenuBar; + // gui = require('nw.gui'); + // gui.App.clearCache(); + // win = gui.Window.get(); + // var nativeMenuBar = new gui.Menu({type: 'menubar'}); + // nativeMenuBar.createMacBuiltin('Kitematic'); + // win.menu = nativeMenuBar; } catch (e) { console.error(e); } diff --git a/meteor/client/main.less b/meteor/client/main.less index b0d775924d..80701a0b9f 100755 --- a/meteor/client/main.less +++ b/meteor/client/main.less @@ -9,3 +9,4 @@ @import "stylesheets/dashboard.import.less"; @import "stylesheets/setup.import.less"; @import "stylesheets/spinner.import.less"; +@import "stylesheets/radial-progress.import.less"; diff --git a/meteor/client/stylesheets/radial-progress.import.less b/meteor/client/stylesheets/radial-progress.import.less new file mode 100644 index 0000000000..f522a827d0 --- /dev/null +++ b/meteor/client/stylesheets/radial-progress.import.less @@ -0,0 +1,91 @@ +.radial-progress { + @circle-size: 34px; + @circle-background: #d6dadc; + @circle-color: #3FD899; + @inset-size: 28px; + @inset-color: #fbfbfb; + @transition-length: 1s; + @shadow: 0px 1px 3px rgba(0,0,0,0.2); + @percentage-color: #3FD899; + @percentage-font-size: 11px; + @percentage-text-width: 57px; + + width: @circle-size; + height: @circle-size; + + background-color: @circle-background; + border-radius: 50%; + .circle { + .mask, .fill, .shadow { + width: @circle-size; + height: @circle-size; + position: absolute; + border-radius: 50%; + } + .shadow { + box-shadow: @shadow inset; + } + .mask, .fill { + -webkit-backface-visibility: hidden; + transition: -webkit-transform @transition-length; + transition: -ms-transform @transition-length; + transition: transform @transition-length; + border-radius: 50%; + } + .mask { + clip: rect(0px, @circle-size, @circle-size, @circle-size/2); + .fill { + clip: rect(0px, @circle-size/2, @circle-size, 0px); + background-color: @circle-color; + } + } + } + .inset { + width: @inset-size; + height: @inset-size; + position: absolute; + margin-left: (@circle-size - @inset-size)/2; + margin-top: (@circle-size - @inset-size)/2; + + background-color: @inset-color; + border-radius: 50%; + box-shadow: @shadow; + .percentage { + width: @percentage-text-width; + position: absolute; + top: (@inset-size - @percentage-font-size) / 2; + left: (@inset-size - @percentage-text-width) / 2; + + line-height: 1; + text-align: center; + + color: @percentage-color; + font-weight: 800; + font-size: @percentage-font-size; + } + } + + @i: 0; + @increment: 180deg / 100; + .loop (@i) when (@i <= 100) { + &[data-progress="@{i}"] { + .circle { + .mask.full, .fill { + -webkit-transform: rotate(@increment * @i); + -ms-transform: rotate(@increment * @i); + transform: rotate(@increment * @i); + } + .fill.fix { + -webkit-transform: rotate(@increment * @i * 2); + -ms-transform: rotate(@increment * @i * 2); + transform: rotate(@increment * @i * 2); + } + } + .inset .percentage:before { + content: "@{i}%" + } + } + .loop(@i + 1); + } + .loop(@i); +} \ No newline at end of file diff --git a/meteor/client/stylesheets/setup.import.less b/meteor/client/stylesheets/setup.import.less index 347e16c4a9..a520c3a160 100644 --- a/meteor/client/stylesheets/setup.import.less +++ b/meteor/client/stylesheets/setup.import.less @@ -40,7 +40,27 @@ margin-top: 60px; } + .install-finish { + -webkit-app-region: no-drag; + margin-top: 80px; + p { + margin-top: 0; + font-size: 13px; + } + } + + .install-diagonistics { + -webkit-app-region: no-drag; + label { + text-align: left; + } + margin: 0 auto; + max-width: 300px; + margin-top: 50px; + } + .install-continue { + -webkit-app-region: no-drag; margin-top: 140px; p { margin-top: 0; diff --git a/meteor/client/stylesheets/theme.import.less b/meteor/client/stylesheets/theme.import.less index ec8280f8c9..1598310cb1 100755 --- a/meteor/client/stylesheets/theme.import.less +++ b/meteor/client/stylesheets/theme.import.less @@ -10,6 +10,7 @@ html, body { font-size: @font-size-base; height: @window-height; .no-select(); + overflow: hidden; } html, body, div, span, object, diff --git a/meteor/client/views/dashboard/apps/dashboard-apps-settings.js b/meteor/client/views/dashboard/apps/dashboard-apps-settings.js index ca119c5d9a..1ed9927603 100755 --- a/meteor/client/views/dashboard/apps/dashboard-apps-settings.js +++ b/meteor/client/views/dashboard/apps/dashboard-apps-settings.js @@ -1,3 +1,6 @@ +var remote = require('remote'); +var dialog = remote.require('dialog'); + var getConfigVars = function ($form) { var configVars = {}; $form.find('.env-var-pair').each(function () { @@ -39,10 +42,15 @@ Template.dashboard_apps_settings.events({ return false; }, 'click .btn-delete-app': function () { - var result = confirm("Are you sure you want to delete this app?"); - if (result === true) { - AppUtil.remove(this._id); - Router.go('dashboard_apps'); - } + var appId = this._id; + dialog.showMessageBox({ + message: 'Are you sure you want to delete this app?', + buttons: ['Delete', 'Cancel'] + }, function (index) { + if (index === 0) { + AppUtil.remove(appId); + Router.go('dashboard_apps'); + } + }); } }); diff --git a/meteor/client/views/dashboard/apps/dashboard-single-app.html b/meteor/client/views/dashboard/apps/dashboard-single-app.html index e8fd11b7ac..baa2df2470 100755 --- a/meteor/client/views/dashboard/apps/dashboard-single-app.html +++ b/meteor/client/views/dashboard/apps/dashboard-single-app.html @@ -22,17 +22,19 @@ {{image.meta.name}}
- {{#if $eq status 'STOPPED'}} - - {{/if}} {{#if $eq status 'READY'}} - {{#if url}} {{/if}} {{/if}} + {{#if $eq status 'READY'}} + + {{/if}} + {{#if $eq status 'STOPPED'}} + + {{/if}} diff --git a/meteor/client/views/dashboard/apps/dashboard-single-app.js b/meteor/client/views/dashboard/apps/dashboard-single-app.js index 8bd9da0b01..1f2a17d02b 100755 --- a/meteor/client/views/dashboard/apps/dashboard-single-app.js +++ b/meteor/client/views/dashboard/apps/dashboard-single-app.js @@ -1,5 +1,3 @@ -var path = require('path'); - Template.dashboard_single_app.rendered = function () { Meteor.setInterval(function () { $('.btn-icon').tooltip(); @@ -21,16 +19,8 @@ Template.dashboard_single_app.events({ }, 'click .btn-terminal': function () { var app = this; - var cmd = path.join(Util.getBinDir(), 'boot2docker') + ' ssh -t "sudo docker-enter ' + app.docker.Id + '"'; - var terminalCmd = path.join(Util.getBinDir(), 'terminal') + ' ' + cmd; - var exec = require('child_process').exec; - console.log(terminalCmd); - exec(terminalCmd, function (err, stdout) { - console.log(stdout); - if (err) { - console.log(err); - } - }); + var cmd = Boot2Docker.command() + ' ssh -t "sudo docker-enter ' + app.docker.Id + '"'; + Util.openTerminal(cmd); }, 'click .btn-start': function () { AppUtil.start(this._id); diff --git a/meteor/client/views/dashboard/components/dashboard-menu.js b/meteor/client/views/dashboard/components/dashboard-menu.js index 71c325f59b..1c21d2a13f 100755 --- a/meteor/client/views/dashboard/components/dashboard-menu.js +++ b/meteor/client/views/dashboard/components/dashboard-menu.js @@ -1,9 +1,11 @@ +var remote = require('remote'); + Template.dashboard_menu.events({ 'click .mac-close': function () { - win.close(); + remote.getCurrentWindow().hide(); }, 'click .mac-minimize': function () { - win.minimize(); + remote.getCurrentWindow().minimize(); }, 'mouseover .mac-window-options': function () { $('.mac-close i').show(); diff --git a/meteor/client/views/dashboard/components/modal-create-app.js b/meteor/client/views/dashboard/components/modal-create-app.js index b450b79c2d..a5656cb6e6 100755 --- a/meteor/client/views/dashboard/components/modal-create-app.js +++ b/meteor/client/views/dashboard/components/modal-create-app.js @@ -14,7 +14,7 @@ Template.modal_create_app.events({ var validationResult = formValidate(formData, FormSchema.formCreateApp); if (validationResult.errors) { clearFormErrors($form); - showFormErrors($form, validationResult.errors.details); + showFormErrors($form, validationResult.errors); } else { clearFormErrors($form); var cleaned = validationResult.cleaned; diff --git a/meteor/client/views/dashboard/components/modal-create-image.html b/meteor/client/views/dashboard/components/modal-create-image.html index 1f6f981dd6..a96e9b3033 100755 --- a/meteor/client/views/dashboard/components/modal-create-image.html +++ b/meteor/client/views/dashboard/components/modal-create-image.html @@ -12,7 +12,6 @@
Select Folder -
diff --git a/meteor/client/views/dashboard/images/dashboard-images-settings.js b/meteor/client/views/dashboard/images/dashboard-images-settings.js index 0a4f20eb57..5f5d8f1f54 100755 --- a/meteor/client/views/dashboard/images/dashboard-images-settings.js +++ b/meteor/client/views/dashboard/images/dashboard-images-settings.js @@ -1,8 +1,16 @@ +var remote = require('remote'); +var dialog = remote.require('dialog'); + Template.dashboard_images_settings.events({ 'click .btn-delete-image': function () { - var result = confirm("Are you sure you want to delete this image?"); - if (result === true) { - var imageId = this._id; + var imageId = this._id; + dialog.showMessageBox({ + message: 'Are you sure you want to delete this image?', + buttons: ['Delete', 'Cancel'] + }, function (index) { + if (index !== 0) { + return; + } var image = Images.findOne(imageId); var app = Apps.findOne({imageId: imageId}); if (!app) { @@ -23,26 +31,27 @@ Template.dashboard_images_settings.events({ $('#error-delete-image').html('This image is currently being used by ' + app.name + '.'); $('#error-delete-image').fadeIn(); } - } + }); }, 'click #btn-pick-directory': function () { - $('#directory-picker').click(); - }, - 'change #directory-picker': function (e) { var imageId = this._id; - var $picker = $(e.currentTarget); - var pickedDirectory = $picker.val(); $('#picked-directory-error').html(''); - if (pickedDirectory) { - if (!Util.hasDockerfile(pickedDirectory)) { - $('#picked-directory-error').html('Only directories with Dockerfiles are supported now.'); - } else { - Images.update(imageId, { - $set: { - originPath: pickedDirectory - } - }); + dialog.showOpenDialog({properties: ['openDirectory']}, function (filenames) { + if (!filenames) { + return; } - } + var directory = filenames[0]; + if (directory) { + if (!Util.hasDockerfile(directory)) { + $('#picked-directory-error').html('Only directories with Dockerfiles are supported now.'); + } else { + Images.update(imageId, { + $set: { + originPath: directory + } + }); + } + } + }); } }); diff --git a/meteor/client/views/dashboard/layouts/dashboard-apps-layout.html b/meteor/client/views/dashboard/layouts/dashboard-apps-layout.html index 7f35bc79e3..67465a1e9b 100755 --- a/meteor/client/views/dashboard/layouts/dashboard-apps-layout.html +++ b/meteor/client/views/dashboard/layouts/dashboard-apps-layout.html @@ -19,6 +19,12 @@ {{/if}} + {{#if $eq status 'READY'}} + + {{/if}} + {{#if $eq status 'STOPPED'}} + + {{/if}} diff --git a/meteor/client/views/dashboard/layouts/dashboard-apps-layout.js b/meteor/client/views/dashboard/layouts/dashboard-apps-layout.js index 9a32743715..8cb8e72c8a 100644 --- a/meteor/client/views/dashboard/layouts/dashboard-apps-layout.js +++ b/meteor/client/views/dashboard/layouts/dashboard-apps-layout.js @@ -24,20 +24,8 @@ Template.dashboard_apps_layout.events({ AppUtil.logs(this._id); }, 'click .btn-terminal': function () { - var buildCmd = function (dockerId, termApp) { - return "echo 'boot2docker --vm=\"boot2docker-vm\" ssh -t \"sudo docker-enter " + dockerId + "\"' > /tmp/nsenter-start && chmod +x /tmp/nsenter-start && open -a " + termApp + " /tmp/nsenter-start"; - }; - var app = this; - var nsenterCmd = buildCmd(app.docker.Id, 'iTerm.app'); - var exec = require('child_process').exec; - exec(nsenterCmd, function (err) { - if (err) { - nsenterCmd = buildCmd(app.docker.Id, 'Terminal.app'); - exec(nsenterCmd, function (err) { - if (err) { throw err; } - }); - } - }); + var cmd = Boot2Docker.command() + ' ssh -t "sudo docker-enter ' + this.docker.Id + '"'; + Util.openTerminal(cmd); }, 'click .btn-restart': function () { AppUtil.restart(this._id); @@ -47,5 +35,13 @@ Template.dashboard_apps_layout.events({ exec('open ' + this.path, function (err) { if (err) { throw err; } }); + }, + 'click .btn-start': function () { + AppUtil.start(this._id); + $('.btn-icon').tooltip('hide'); + }, + 'click .btn-stop': function () { + AppUtil.stop(this._id); + $('.btn-icon').tooltip('hide'); } }); diff --git a/meteor/client/views/dashboard/settings/dashboard-settings.html b/meteor/client/views/dashboard/settings/dashboard-settings.html index cf615b4d5b..67cc58fb9e 100644 --- a/meteor/client/views/dashboard/settings/dashboard-settings.html +++ b/meteor/client/views/dashboard/settings/dashboard-settings.html @@ -39,4 +39,17 @@ {{/if}}
+
+
+

Usage Diagnostics

+

Send anonymized usage diagnostics to help us improve Kitematic.

+
+
+ {{#if settings.tracking}} + Turn Off Usage Diagnostics + {{else}} + Turn On Usage Diagnostics + {{/if}} +
+
diff --git a/meteor/client/views/dashboard/settings/dashboard-settings.js b/meteor/client/views/dashboard/settings/dashboard-settings.js index 1a2ef4b7bd..2028f2edd6 100644 --- a/meteor/client/views/dashboard/settings/dashboard-settings.js +++ b/meteor/client/views/dashboard/settings/dashboard-settings.js @@ -20,6 +20,28 @@ Template.dashboard_settings.events({ console.log(err); } }); + }, + 'click .btn-usage-analytics-on': function () { + var settings = Settings.findOne(); + Settings.update(settings._id, { + $set: { + tracking: true + } + }); + }, + 'click .btn-usage-analytics-off': function () { + var settings = Settings.findOne(); + Settings.update(settings._id, { + $set: { + tracking: false + } + }); + } +}); + +Template.dashboard_settings.helpers({ + settings: function () { + return Settings.findOne({}); } }); diff --git a/meteor/client/views/dashboard/setup/setup-finish.html b/meteor/client/views/dashboard/setup/setup-finish.html new file mode 100644 index 0000000000..de5f103ff0 --- /dev/null +++ b/meteor/client/views/dashboard/setup/setup-finish.html @@ -0,0 +1,21 @@ + diff --git a/meteor/client/views/dashboard/setup/setup-finish.js b/meteor/client/views/dashboard/setup/setup-finish.js new file mode 100644 index 0000000000..617dd6e08c --- /dev/null +++ b/meteor/client/views/dashboard/setup/setup-finish.js @@ -0,0 +1,20 @@ +Template.setup_finish.events({ + 'click .finish-button': function (e) { + var enableDiagnostics = $('.install-diagonistics input').attr('checked') ? true : false; + Installs.insert({version: Installer.CURRENT_VERSION}); + var settings = Settings.findOne(); + if (!settings) { + Settings.insert({tracking: enableDiagnostics}); + } else { + settings.update(settings._id, { + $set: { + tracking: enableDiagnostics + } + }); + } + Router.go('dashboard_apps'); + e.preventDefault(); + e.stopPropagation(); + return false; + } +}); diff --git a/meteor/client/views/dashboard/setup/setup-install.html b/meteor/client/views/dashboard/setup/setup-install.html index a58b7c88b9..9f16050b26 100644 --- a/meteor/client/views/dashboard/setup/setup-install.html +++ b/meteor/client/views/dashboard/setup/setup-install.html @@ -1,4 +1,11 @@