Merge remote-tracking branch 'upstream/windows' into windows

Signed-off-by: kraynel <kraynel@yahoo.fr>

Conflicts:
	src/components/ContainerHomeFolders.react.js
This commit is contained in:
kraynel 2015-06-10 14:38:10 +02:00
commit f945e4ab45
15 changed files with 375 additions and 55 deletions

1
.gitignore vendored
View File

@ -2,6 +2,7 @@
.swp .swp
build build
dist dist
installer
node_modules node_modules
coverage coverage
npm-debug.log npm-debug.log

View File

@ -18,13 +18,14 @@ Before you fil an issue or a pull request, quickly read of the following tips on
### Prerequisites ### Prerequisites
Most of the time, you'll have installed Kitematic before contibuting, but for the Most of the time, you'll have installed Kitematic before contibuting, but for the
sake of completeness, you can also install [Node.js](https://nodejs.org/) and the latest Xcode from the Apple App Store and then run from your Git clone. sake of completeness, you can also install [Node.js 0.10.38](https://nodejs.org/dist/v0.10.38/).
Running `npm start` will download and install the OS X Docker client, ### Other Prerequisites (Mac)
[Docker machine](https://github.com/docker/machine), - The latest Xcode from the Apple App Store.
the [Boot2Docker iso](https://github.com/boot2docker/boot2docker),
[Electron](http://electron.atom.io/), and [VirtualBox](https://www.virtualbox.org/) ### Other Prerequisites (Windows)
if needed. - [Visual Studio 2013 Community](https://www.visualstudio.com/en-us/products/visual-studio-community-vs.aspx) (or similar)
- [Python](https://www.python.org/downloads/release/python-2710/)
### Getting Started ### Getting Started
@ -34,6 +35,11 @@ To run the app in development:
- `npm start` - `npm start`
Running `npm start` will download and install the OS X Docker client,
[Docker Machine](https://github.com/docker/machine), [Docker Compose](https://github.com/docker/compose)
the [Boot2Docker iso](https://github.com/boot2docker/boot2docker),
[Electron](http://electron.atom.io/).
### Building & Release ### Building & Release
- `npm run release` - `npm run release`

272
Gruntfile.js Normal file
View File

@ -0,0 +1,272 @@
var path = require('path');
var execFile = require('child_process').execFile;
var packagejson = require('./package.json');
var electron = require('electron-prebuilt');
var WINDOWS_DOCKER_URL = 'https://get.docker.com/builds/Windows/x86_64/docker-1.6.2.exe';
var DARWIN_DOCKER_URL = 'https://get.docker.com/builds/Darwin/x86_64/docker-' + packagejson['docker-version'];
var WINDOWS_DOCKER_MACHINE_URL = 'https://github.com/docker/machine/releases/download/v' + packagejson['docker-machine-version'] + '/docker-machine_windows-amd64.exe';
var DARWIN_DOCKER_MACHINE_URL = 'https://github.com/docker/machine/releases/download/v' + packagejson['docker-machine-version'] + '/docker-machine_darwin-amd64';
var DARWIN_COMPOSE_URL = 'https://github.com/docker/compose/releases/download/' + packagejson['docker-compose-version'] + '/docker-compose-Darwin-x86_64';
var BOOT2DOCKER_ISO_URL = 'https://github.com/boot2docker/boot2docker/releases/download/v' + packagejson['docker-version'] + '/boot2docker.iso';
module.exports = function (grunt) {
require('load-grunt-tasks')(grunt);
var target = grunt.option('target') || 'development';
var beta = grunt.option('beta') || false;
var env = process.env;
env.NODE_ENV = target;
var version = function (str) {
var match = str.match(/(\d+\.\d+\.\d+)/);
return match ? match[1] : null;
};
grunt.registerMultiTask('download-binary', 'Downloads binary unless version up to date', function () {
var target = grunt.task.current.target;
var done = this.async();
var config = grunt.config('download-binary')[target];
execFile(config.binary, ['--version'], function (err, stdout) {
var currentVersion = version(stdout);
if (!currentVersion || currentVersion !== version(config.version)) {
grunt.task.run('curl:' + target);
grunt.task.run('chmod');
}
done();
});
});
var APPNAME = beta ? 'Kitematic (Beta)' : 'Kitematic';
var OSX_OUT = './dist/osx';
var OSX_FILENAME = OSX_OUT + '/' + APPNAME + '.app';
grunt.initConfig({
IDENTITY: 'Developer ID Application: Docker Inc',
APPNAME: APPNAME,
OSX_OUT: OSX_OUT,
OSX_FILENAME: OSX_FILENAME,
OSX_FILENAME_ESCAPED: OSX_FILENAME.replace(' ', '\\ ').replace('(','\\(').replace(')','\\)'),
// electron
electron: {
windows: {
options: {
name: '<%= APPNAME %>',
dir: 'build/',
out: 'dist/',
version: packagejson['electron-version'],
platform: 'win32',
arch: 'x64',
asar: true
}
},
osx: {
options: {
name: '<%= APPNAME %>',
dir: 'build/',
out: '<%= OSX_OUT %>',
version: packagejson['electron-version'],
platform: 'darwin',
arch: 'x64',
asar: true,
'app-bundle-id': 'com.kitematic.kitematic'
}
}
},
'create-windows-installer': {
appDirectory: 'dist/Kitematic-win32/',
outputDirectory: 'installer/',
authors: 'Docker Inc.'
},
// docker binaries
'download-binary': {
docker: {
version: packagejson['docker-version'],
binary: path.join('resources', 'docker'),
download: 'curl:docker'
},
'docker-machine': {
version: packagejson['docker-machine-version'],
binary: path.join('resources', 'docker-machine'),
download: 'curl:docker-machine'
}
},
// images
copy: {
dev: {
files: [{
expand: true,
cwd: '.',
src: ['package.json', 'settings.json', 'index.html'],
dest: 'build/'
}, {
expand: true,
cwd: 'images/',
src: ['**/*'],
dest: 'build/'
}, {
expand: true,
cwd: 'fonts/',
src: ['**/*'],
dest: 'build/'
}, {
cwd: 'node_modules/',
src: Object.keys(packagejson.dependencies).map(function (dep) { return dep + '/**/*';}),
dest: 'build/node_modules/',
expand: true
}]
},
windows: {
files: [{
expand: true,
cwd: 'resources',
src: ['docker*'],
dest: 'dist/Kitematic-win32/resources/resources/'
}],
options: {
mode: true
}
},
osx: {
files: [{
expand: true,
cwd: 'resources',
src: ['**/*'],
dest: '<%= OSX_FILENAME %>/Contents/Resources/resources/'
}, {
src: 'util/kitematic.icns',
dest: '<%= OSX_FILENAME %>/Contents/Resources/atom.icns'
}],
options: {
mode: true
}
}
},
// download binaries
curl: {
docker: {
src: process.platform === 'win32' ? WINDOWS_DOCKER_URL : DARWIN_DOCKER_URL,
dest: process.platform === 'win32' ? path.join('resources', 'docker.exe') : path.join('resources', 'docker')
},
'docker-machine': {
src: process.platform === 'win32' ? WINDOWS_DOCKER_MACHINE_URL : DARWIN_DOCKER_MACHINE_URL,
dest: process.platform === 'win32' ? path.join('resources', 'docker-machine.exe') : path.join('resources', 'docker-machine')
},
'docker-compose': {
src: DARWIN_COMPOSE_URL,
dest: 'resources/docker-compose'
},
'boot2docker-iso': {
src: BOOT2DOCKER_ISO_URL,
dest: path.join('resources', 'boot2docker-' + packagejson['docker-version'])
}
},
chmod: {
binaries: {
options: {
mode: '755'
},
src: ['resources/docker*']
}
},
// styles
less: {
options: {
sourceMapFileInline: true
},
dist: {
files: {
'build/main.css': 'styles/main.less'
}
}
},
// javascript
babel: {
options: {
sourceMap: 'inline',
blacklist: 'regenerator'
},
dist: {
files: [{
expand: true,
cwd: 'src/',
src: ['**/*.js'],
dest: 'build/',
}]
}
},
shell: {
electron: {
command: electron + ' ' + 'build',
options: {
async: true,
execOptions: {
env: env
}
}
},
sign: {
options: {
failOnError: false,
},
command: [
'codesign --deep -v -f -s "<%= IDENTITY %>" <%= OSX_FILENAME_ESCAPED %>/Contents/Frameworks/*',
'codesign -v -f -s "<%= IDENTITY %>" <%= OSX_FILENAME_ESCAPED %>',
'codesign -vvv --display <%= OSX_FILENAME_ESCAPED %>',
'codesign -v --verify <%= OSX_FILENAME_ESCAPED %>',
].join(' && '),
},
zip: {
command: 'ditto -c -k --sequesterRsrc --keepParent <%= OSX_FILENAME_ESCAPED %> <%= OSX_OUT %>/Kitematic-' + packagejson.version + '.zip',
}
},
clean: {
dist: ['dist/'],
build: ['build/']
},
// livereload
watchChokidar: {
options: {
spawn: false
},
livereload: {
options: {livereload: true},
files: ['build/**/*']
},
js: {
files: ['src/**/*.js'],
tasks: ['babel']
},
less: {
files: ['styles/**/*.less'],
tasks: ['less']
},
copy: {
files: ['images/*', 'index.html', 'fonts/*'],
tasks: ['copy']
}
}
});
grunt.registerTask('default', ['download-binary', 'babel', 'less', 'copy:dev', 'shell:electron', 'watchChokidar']);
if (process.platform === 'win32') {
grunt.registerTask('release', ['clean:dist', 'clean:build', 'download-binary', 'babel', 'less', 'copy:dev', 'electron:windows', 'copy:windows', 'copy:osx']);
} else {
grunt.registerTask('release', ['clean:dist', 'clean:build', 'download-binary', 'babel', 'less', 'copy:dev', 'electron:osx', 'copy:osx', 'shell:sign', 'shell:zip']);
}
process.on('SIGINT', function () {
grunt.task.run(['shell:electron:kill']);
process.exit(1);
});
};

View File

@ -28,10 +28,9 @@ container!
## Technical Details ## Technical Details
Kitematic is a self-contained .app, with a two exceptions: Kitematic is a self-contained .app, with an exception:
- It will install VirtualBox if it's not already installed. - It will install VirtualBox if it's not already installed.
- It copies the `docker` and `docker-machine` binaries to `/usr/local/bin` for
convenience. convenience.
### Why does Kitematic need my root password? ### Why does Kitematic need my root password?

View File

@ -207,7 +207,7 @@ gulp.task('download-docker', function (cb) {
} else { } else {
request('https://get.docker.com/builds/Darwin/x86_64/docker-' + packagejson['docker-version']) request('https://get.docker.com/builds/Darwin/x86_64/docker-' + packagejson['docker-version'])
.pipe(fs.createWriteStream('./resources/docker')).on('finish', function () { .pipe(fs.createWriteStream('./resources/docker')).on('finish', function () {
fs.chmodSync('./resources/docker', 755); fs.chmodSync('./resources/docker', 0755);
cb(); cb();
}); });
} }
@ -230,7 +230,7 @@ gulp.task('download-docker-machine', function (cb) {
} else { } else {
request('https://github.com/docker/machine/releases/download/v' + packagejson['docker-machine-version'] + '/docker-machine_darwin-amd64') request('https://github.com/docker/machine/releases/download/v' + packagejson['docker-machine-version'] + '/docker-machine_darwin-amd64')
.pipe(fs.createWriteStream('./resources/docker-machine')).on('finish', function () { .pipe(fs.createWriteStream('./resources/docker-machine')).on('finish', function () {
fs.chmodSync('./resources/docker-machine', 755); fs.chmodSync('./resources/docker-machine', 0755);
cb(); cb();
}); });
} }
@ -252,7 +252,7 @@ gulp.task('download-docker-compose', function (cb) {
gutil.log(gutil.colors.green('Downloading Docker Compose')); gutil.log(gutil.colors.green('Downloading Docker Compose'));
request('https://github.com/docker/compose/releases/download/' + packagejson['docker-compose-version'] + '/docker-compose-Darwin-x86_64') request('https://github.com/docker/compose/releases/download/' + packagejson['docker-compose-version'] + '/docker-compose-Darwin-x86_64')
.pipe(fs.createWriteStream('./resources/docker-compose')).on('finish', function () { .pipe(fs.createWriteStream('./resources/docker-compose')).on('finish', function () {
fs.chmodSync('./resources/docker-compose', 755); fs.chmodSync('./resources/docker-compose', 0755);
cb(); cb();
}); });
} }

View File

@ -4,7 +4,7 @@
"author": "Kitematic", "author": "Kitematic",
"description": "Simple Docker Container management for Mac OS X.", "description": "Simple Docker Container management for Mac OS X.",
"homepage": "https://kitematic.com/", "homepage": "https://kitematic.com/",
"main": "build/browser.js", "main": "browser.js",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git@github.com:kitematic/kitematic.git" "url": "git@github.com:kitematic/kitematic.git"
@ -14,8 +14,8 @@
"start": "gulp", "start": "gulp",
"test": "jest -c jest-unit.json", "test": "jest -c jest-unit.json",
"integration": "jest -c jest-integration.json", "integration": "jest -c jest-integration.json",
"release": "gulp release", "release": "grunt release",
"release:beta": "gulp release --beta", "release:beta": "grunt release --beta=true",
"lint": "jsxhint src", "lint": "jsxhint src",
"reset": "gulp reset" "reset": "gulp reset"
}, },
@ -26,7 +26,7 @@
} }
], ],
"docker-version": "1.6.2", "docker-version": "1.6.2",
"docker-machine-version": "0.3.0-rc1", "docker-machine-version": "0.3.0-rc2",
"docker-compose-version": "1.2.0", "docker-compose-version": "1.2.0",
"electron-version": "0.27.2", "electron-version": "0.27.2",
"virtualbox-version": "4.3.28", "virtualbox-version": "4.3.28",
@ -67,25 +67,28 @@
"devDependencies": { "devDependencies": {
"babel": "^5.1.10", "babel": "^5.1.10",
"babel-jest": "^5.2.0", "babel-jest": "^5.2.0",
"gulp": "^3.8.11", "electron-prebuilt": "^0.27.3",
"gulp-babel": "^5.1.0", "grunt": "^0.4.5",
"gulp-changed": "^1.2.1", "grunt-babel": "^5.0.1",
"gulp-concat": "^2.5.2", "grunt-chmod": "^1.0.3",
"gulp-cssmin": "^0.1.6", "grunt-cli": "^0.1.13",
"gulp-download-electron": "^0.0.5", "grunt-contrib-clean": "^0.6.0",
"gulp-if": "^1.2.5", "grunt-contrib-copy": "^0.8.0",
"gulp-insert": "^0.4.0", "grunt-contrib-less": "^1.0.1",
"gulp-less": "^3.0.2", "grunt-contrib-watch-chokidar": "^1.0.0",
"gulp-livereload": "^3.8.0", "grunt-curl": "^2.2.0",
"gulp-plumber": "^1.0.0", "grunt-download-electron": "^2.1.1",
"gulp-shell": "^0.4.1", "grunt-electron": "^1.0.0",
"gulp-sourcemaps": "^1.5.2", "grunt-electron-installer": "^0.33.0",
"gulp-util": "^3.0.4", "grunt-shell": "^1.1.2",
"grunt-shell-spawn": "^0.3.8",
"jest-cli": "^0.4.5", "jest-cli": "^0.4.5",
"jsxhint": "^0.14.0", "jsxhint": "^0.14.0",
"load-grunt-tasks": "^3.2.0",
"minimist": "^1.1.1", "minimist": "^1.1.1",
"react-tools": "^0.13.1", "react-tools": "^0.13.1",
"run-sequence": "^1.0.2", "run-sequence": "^1.0.2",
"shell-escape": "^0.2.0",
"source-map-support": "^0.2.10" "source-map-support": "^0.2.10"
} }
} }

View File

@ -5,9 +5,8 @@ var fs = require('fs');
var ipc = require('ipc'); var ipc = require('ipc');
var path = require('path'); var path = require('path');
process.env.NODE_PATH = path.join(__dirname, '/../node_modules'); process.env.NODE_PATH = path.join(__dirname, 'node_modules');
process.env.RESOURCES_PATH = path.join(__dirname, '/../resources'); process.env.RESOURCES_PATH = path.join(__dirname, '/../resources');
process.chdir(path.join(__dirname, '..'));
process.env.PATH = '/usr/local/bin:' + process.env.PATH; process.env.PATH = '/usr/local/bin:' + process.env.PATH;
var size = {}, settingsjson = {}; var size = {}, settingsjson = {};
@ -15,9 +14,49 @@ try {
size = JSON.parse(fs.readFileSync(path.join(process.env[(process.platform === 'win32') ? 'USERPROFILE' : 'HOME'], 'Library', 'Application\ Support', 'Kitematic', 'size'))); size = JSON.parse(fs.readFileSync(path.join(process.env[(process.platform === 'win32') ? 'USERPROFILE' : 'HOME'], 'Library', 'Application\ Support', 'Kitematic', 'size')));
} catch (err) {} } catch (err) {}
try { try {
settingsjson = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'settings.json'), 'utf8')); settingsjson = JSON.parse(fs.readFileSync(path.join(__dirname, 'settings.json'), 'utf8'));
} catch (err) {} } catch (err) {}
var handleStartupEvent = function() {
if (process.platform !== 'win32') {
return false;
}
var squirrelCommand = process.argv[1];
switch (squirrelCommand) {
case '--squirrel-install':
case '--squirrel-updated':
// Optionally do things such as:
//
// - Install desktop and start menu shortcuts
// - Add your .exe to the PATH
// - Write to the registry for things like file associations and
// explorer context menus
// Always quit when done
app.quit();
return true;
case '--squirrel-uninstall':
// Undo anything you did in the --squirrel-install and
// --squirrel-updated handlers
// Always quit when done
app.quit();
return true;
case '--squirrel-obsolete':
// This is called on the outgoing version of your app before
// we update to the new version - it's the opposite of
// --squirrel-updated
app.quit();
return true;
}
};
handleStartupEvent();
var openURL = null; var openURL = null;
app.on('open-url', function (event, url) { app.on('open-url', function (event, url) {
event.preventDefault(); event.preventDefault();
@ -36,7 +75,7 @@ app.on('ready', function () {
show: false, show: false,
}); });
mainWindow.loadUrl(path.normalize('file://' + path.join(__dirname, '..', 'build/index.html'))); mainWindow.loadUrl(path.normalize('file://' + path.join(__dirname, 'index.html')));
app.on('activate-with-no-open-windows', function () { app.on('activate-with-no-open-windows', function () {
if (mainWindow) { if (mainWindow) {
@ -52,7 +91,8 @@ app.on('ready', function () {
}); });
app.on('before-quit', function () { app.on('before-quit', function () {
if (!updating) { // TODO: make this work for right click + close
if (!updating && mainWindow.webContents) {
mainWindow.webContents.send('application:quitting'); mainWindow.webContents.send('application:quitting');
} }
}); });

View File

@ -103,8 +103,12 @@ var ContainerDetailsSubheader = React.createClass({
if (!this.disableTerminal()) { if (!this.disableTerminal()) {
metrics.track('Terminaled Into Container'); metrics.track('Terminaled Into Container');
var container = this.props.container; var container = this.props.container;
var shell = ContainerUtil.env(container).SHELL; var shell = ContainerUtil.env(container).reduce((envs, env) => {
if(typeof shell === 'undefined') { envs[env[0]] = env[1];
return envs;
}, {}).SHELL;
if(!shell) {
shell = 'sh'; shell = 'sh';
} }
machine.ip().then(ip => { machine.ip().then(ip => {

View File

@ -25,7 +25,7 @@ var ContainerHomeFolder = React.createClass({
}, (index) => { }, (index) => {
if (index === 0) { if (index === 0) {
var volumes = _.clone(this.props.container.Volumes); var volumes = _.clone(this.props.container.Volumes);
var newHostVolume = path.join(util.home(), 'Kitematic', this.props.container.Name, containerVolume); var newHostVolume = path.join(util.home(), util.documents(), 'Kitematic', this.props.container.Name, containerVolume);
volumes[containerVolume] = newHostVolume; volumes[containerVolume] = newHostVolume;
var binds = _.pairs(volumes).map(function (pair) { var binds = _.pairs(volumes).map(function (pair) {
if(util.isWindows()) { if(util.isWindows()) {
@ -58,7 +58,8 @@ var ContainerHomeFolder = React.createClass({
return false; return false;
} }
var folders = _.map(this.props.container.Volumes, (val, key) => { console.log(this.props.container.Volumes);
var folders = _.map(_.omit(this.props.container.Volumes, (v, k) => k.indexOf('/Users/') !== -1), (val, key) => {
var firstFolder = key.split('/')[1]; var firstFolder = key.split('/')[1];
return ( return (
<div key={key} className="folder" onClick={this.handleClickFolder.bind(this, val, key)}> <div key={key} className="folder" onClick={this.handleClickFolder.bind(this, val, key)}>

View File

@ -66,16 +66,6 @@ var _steps = [{
yield machine.rm(); yield machine.rm();
} }
yield machine.create(); yield machine.create();
if(util.isWindows()) {
let home = util.home();
let driveLetter = home.charAt(0);
let parts = home.split('\\').slice(0, -1);
let usersDirName = parts[parts.length-1];
let usersDirPath = parts.join('\\');
let shareName = driveLetter + '/' + usersDirName;
yield virtualBox.mountSharedDir(machine.name(), shareName, usersDirPath);
yield machine.start();
}
return; return;
} }

View File

@ -103,6 +103,8 @@ export default {
containerData.Env = containerData.Config.Env; containerData.Env = containerData.Config.Env;
} }
containerData.Volumes = _.mapObject(containerData.Volumes, () => {return {};});
let existing = this.client.getContainer(name); let existing = this.client.getContainer(name);
existing.kill(() => { existing.kill(() => {
existing.remove(() => { existing.remove(() => {
@ -203,7 +205,6 @@ export default {
containerServerActions.error({name, error}); containerServerActions.error({name, error});
return; return;
} }
existingData.name = existingData.Name || name;
if (existingData.Config && existingData.Config.Image) { if (existingData.Config && existingData.Config.Image) {
existingData.Image = existingData.Config.Image; existingData.Image = existingData.Config.Image;

View File

@ -7,7 +7,7 @@ var util = require('./Util');
var settings; var settings;
try { try {
settings = JSON.parse(fs.readFileSync(path.join(__dirname, '../..', 'settings.json'), 'utf8')); settings = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'settings.json'), 'utf8'));
} catch (err) { } catch (err) {
settings = {}; settings = {};
} }

View File

@ -56,7 +56,7 @@ module.exports = {
recommended: function () { recommended: function () {
request.get('https://kitematic.com/recommended.json', (error, response, body) => { request.get('https://kitematic.com/recommended.json', (error, response, body) => {
if (error) { if (error) {
repositoryServerActions.recommendedError({error}); repositoryServerActions.error({error});
} }
let data = JSON.parse(body); let data = JSON.parse(body);

View File

@ -55,6 +55,9 @@ module.exports = {
home: function () { home: function () {
return app.getPath('home'); return app.getPath('home');
}, },
documents: function () {
return this.isWindows() ? 'My\ Documents' : 'Documents';
},
supportDir: function () { supportDir: function () {
return app.getPath('userData'); return app.getPath('userData');
}, },
@ -70,12 +73,12 @@ module.exports = {
.replace(/\/Users\/[^\/]*\//mg, '/Users/<redacted>/'); .replace(/\/Users\/[^\/]*\//mg, '/Users/<redacted>/');
}, },
packagejson: function () { packagejson: function () {
return JSON.parse(fs.readFileSync(path.join(__dirname, '../..', 'package.json'), 'utf8')); return JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
}, },
settingsjson: function () { settingsjson: function () {
var settingsjson = {}; var settingsjson = {};
try { try {
settingsjson = JSON.parse(fs.readFileSync(path.join(__dirname, '../..', 'settings.json'), 'utf8')); settingsjson = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'settings.json'), 'utf8'));
} catch (err) {} } catch (err) {}
return settingsjson; return settingsjson;
}, },

View File

@ -22,5 +22,5 @@
@color-divider: @gray-lightest; @color-divider: @gray-lightest;
@color-background: #FCFCFC; @color-background: #FCFCFC;
@font-regular: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; @font-regular: "Helvetica Neue", Segoe UI, Arial, "Lucida Grande", sans-serif;
@font-code: Menlo; @font-code: Menlo, Consolas;