Merge branch 'feature/electron' into develop

This commit is contained in:
wpetit 2015-10-13 13:43:34 +02:00
commit 7db3d9eb69
33 changed files with 318 additions and 301 deletions

1
.gitignore vendored
View File

@ -7,3 +7,4 @@ dist
build
nwjs
my-profile.json
js-compiled

View File

@ -1,118 +0,0 @@
/* jshint node: true */
var _ = require('lodash');
var path = require('path');
module.exports = function(grunt) {
var NW_VERSION = '0.12.3';
var BUILD_DIR = 'build';
var BUILD_TARGETS = {
linux_ia32: true,
linux_x64: true,
win: false,
osx: false
};
var PKG = grunt.file.readJSON('package.json');
var PKG_OVERWRITE = {
window: {
toolbar: false,
kiosk: true
}
};
// Create build tasks options
var buildOptions = _.merge({
runtimeVersion: NW_VERSION
}, BUILD_TARGETS);
// Define copy:build tasks files
var appFiles = [];
_.forEach(BUILD_TARGETS, function(isEnabled, target) {
if(!isEnabled) return;
var arch = 'ia32';
var platform = target;
if(platform.indexOf('linux') !== -1) {
arch = platform.split('_')[1];
platform = 'linux';
}
var dirName = PKG.name + '-' + PKG.version + '-' + platform + '-' + arch;
var destPath = path.join(BUILD_DIR, dirName + '/');
// Retreive NPM dependencies
var npmDeps = _.keys(PKG.dependencies).map(function(moduleName) {
return path.join('node_modules', moduleName, '**');
});
appFiles.push({ src: npmDeps, dest: destPath });
// Add main files, licence, & config
appFiles.push({
src: [
'index.html',
'package.json',
'default-profile.json',
'LICENCE',
'css/**',
'js/**',
'img/**'
],
dest: destPath
});
});
// Configure tasks
grunt.initConfig({
pkg: PKG,
download: {
options: {
runtimeVersion: NW_VERSION
}
},
run: {
options: {
nwArgs: ['.'].concat(process.argv.slice(3)),
runtimeVersion: NW_VERSION
}
},
build: {
options: buildOptions
},
clean: {
build: [BUILD_DIR]
},
copy: {
build: {
files: appFiles,
options: {
noProcess: ['**','!package.json'],
process: function() {
var pkg = _.merge(PKG, PKG_OVERWRITE);
return JSON.stringify(pkg, null, 2);
}
}
}
}
});
grunt.registerTask('pitaya:run', ['download', 'run']);
grunt.registerTask(
'pitaya:build',
['download', 'build', 'copy:build']
);
grunt.registerTask('default', ['pitaya:run']);
grunt.loadNpmTasks('grunt-contrib-clean');
grunt.loadNpmTasks('grunt-contrib-copy');
grunt.loadNpmTasks('grunt-nw');
};

View File

@ -9,33 +9,25 @@ Lanceur d'application pour GNU/Linux
- [NodeJS](https://nodejs.org/) - Dernière version stable, testé sur la 0.12.*
- [NPM](https://www.npmjs.com/) - Normalement automatiquement installé avec NodeJS.
### Initialisation du projet
### Initialisation du projet & lancement de Pitaya
```
git clone https://forge.cadoles.com/wpetit/pitaya.git
cd pitaya
git checkout develop
npm install
DEBUG=pitaya* npm start
DEBUG=pitaya* NODE_ENV=development npm start
```
## Options
## Variables d'environnement
```
--profile=<chemin_profile> Chemin vers le fichier de profil à charger dans l'application.
--edit Ouvrir l'application en mode édition.
```
Vous pouvez configurer le comportement de Pitaya en passant des variables d'environnement:
### Passer des options en développement
```
npm start -- [options...]
```
Exemple:
```
npm start -- --profile=my-profile.json
```
| Variable | Description | Valeurs possibles | Valeur par défaut |
|-------------------|------------------------------------|-------------------|------------------------|
| PITAYA_MODE | Mode d'exécution de Pitaya | launcher, edit | launcher |
| PITAYA_PROFILE | Chemin du fichier profil à charger | -- | ./default-profile.json |
| PITAYA_AS_DESKTOP | Afficher Pitaya en mode "Bureau" | 1, 0 | 0 |
## Comment construire l'application depuis les sources
@ -43,7 +35,7 @@ npm start -- --profile=my-profile.json
npm run build
```
Un dossier `pitaya-<version>-<target>-<arch>` sera créé dans le répertoire `./build`. Celui ci contient tous les fichiers nécessaires à l'application.
Un dossier `pitaya-<target>-<arch>` sera créé dans le répertoire `./build`. Celui ci contient tous les fichiers nécessaires à l'application.
## Comment contribuer

View File

@ -27,21 +27,42 @@ html, body {
width: 100%;
height: 100%;
flex-direction: column;
background: url('../img/background.png') no-repeat;
background: url('../img/background.png');
background-repeat: no-repeat;
background-size: cover;
background-position: center center;
transition: background-image 250ms ease-in-out;
}
.launcher .main {
flex-direction: row;
display: flex;
flex-grow: 1;
}
.launcher .nav {
justify-content: center;
align-items: center;
display: flex;
width: 50px;
}
.launcher .nav a.goback {
text-decoration: none;
color: white;
font-size: 60px;
text-shadow: 1px 1px #444;
}
.launcher .nav a.goback:hover {
-webkit-animation: 500ms pulse-large infinite;
}
.launcher .category-header {
padding: 40px 50px 0;
padding: 25px 40px 0;
font-size: 50px;
}
.launcher .category-header a.goback {
text-decoration: none;
color: white;
}
.launcher .category-header a.goback:hover {
-webkit-animation: 500ms pulse-large infinite;
color: #fff;
text-shadow: 1px 1px #444;
}
.launcher .category-header > .category-label {
@ -65,6 +86,10 @@ html, body {
flex-grow: 1;
}
.launcher .nav ~ ul.apps-list {
margin-left: -50px;
}
.launcher li.app-item {
margin: 5px;
border-radius: 5px;
@ -221,7 +246,7 @@ html, body {
@-webkit-keyframes pulse-large {
0% { transform: scale(1); }
50% { transform: scale(1.5); }
50% { transform: scale(1.3); }
100% { transform: scale(1); }
}

View File

@ -3,6 +3,7 @@
{
"label": "Level 1",
"icon": "chromium-browser",
"background": "./img/background2.jpg",
"items": [
{
"label": "Level 2-1",
@ -11,9 +12,11 @@
{
"label": "Chromium Browser 1",
"icon": "chromium-browser",
"exec": "/usr/bin/chromium-browser"
"exec": "/usr/bin/chromium-browser",
"_key": "item_1444480285022_3"
}
]
],
"_key": "item_1444480285022_2"
},
{
"label": "Level 2-2",
@ -26,13 +29,24 @@
{
"label": "Chromium Browser 2",
"icon": "chromium-browser",
"exec": "/usr/bin/chromium-browser"
"exec": "/usr/bin/chromium-browser",
"_key": "item_1444480285022_6"
},
{
"label": "Atom",
"icon": "atom",
"exec": "/usr/share/atom/atom %U",
"_key": "item_1444480288996_7"
}
]
],
"_key": "item_1444480285022_5"
}
]
],
"_key": "item_1444480285022_4"
}
]
],
"_key": "item_1444480285022_1"
}
]
],
"_key": "item_1444480285021_0"
}

BIN
img/background2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 KiB

View File

@ -15,11 +15,16 @@
global.document = global.window.document;
global.navigator = global.window.navigator;
// Auto transform JSX
require('node-jsx').install();
var isDev = process.env.NODE_ENV === 'development';
if(isDev) {
// Auto transform JSX
require('node-jsx').install({extension: '.js'});
// Launch application
require('./js/app.jsx');
require('./js/app.js');
} else {
require('./js-compiled/app.js');
}
</script>

View File

@ -1,6 +1,6 @@
var React = require('react');
var LauncherView = require('./components/launcher/launcher-view.jsx');
var EditView = require('./components/edit/edit-view.jsx');
var LauncherView = require('./components/launcher/launcher-view.js');
var EditView = require('./components/edit/edit-view.js');
var Provider = require('react-redux').Provider;
var connect = require('react-redux').connect;
var store = require('./store');
@ -10,7 +10,7 @@ var App = React.createClass({
render: function() {
var editMode = this.props.processOpts.edit || false;
var editMode = process.env.PITAYA_MODE === 'edit';
var view = editMode ? <EditView /> : <LauncherView />;

View File

@ -1,6 +1,6 @@
var React = require('react');
var Util = require('../../util');
var AppIcon = require('../common/app-icon.jsx');
var AppIcon = require('../common/app-icon.js');
var DragSource = require('react-dnd').DragSource;
var DesktopAppItem = React.createClass({

View File

@ -1,6 +1,6 @@
var React = require('react');
var Util = require('../../util');
var DesktopAppItem = require('./desktop-app-item.jsx');
var DesktopAppItem = require('./desktop-app-item.js');
var path = require('path');
var debug = require('../../util/debug')('pitaya:desktop-app-list');

View File

@ -1,10 +1,10 @@
var React = require('react');
var connect = require('react-redux').connect;
var ProfileTree = require('./profile-tree.jsx');
var DesktopAppList = require('./desktop-app-list.jsx');
var ItemForm = require('./item-form.jsx');
var IconThemeSelector = require('./icon-theme-selector.jsx');
var ProfileMenu = require('./profile-menu.jsx');
var ProfileTree = require('./profile-tree.js');
var DesktopAppList = require('./desktop-app-list.js');
var ItemForm = require('./item-form.js');
var IconThemeSelector = require('./icon-theme-selector.js');
var ProfileMenu = require('./profile-menu.js');
var tree = require('../../util/tree');
var actions = require('../../store/actions');

View File

@ -0,0 +1,85 @@
/* jhsint node:true, jsx: true */
var React = require('react');
var connect = require('react-redux').connect;
var actions = require('../../store/actions');
var dialog = require('remote').require('dialog');
var ProfileMenu = React.createClass({
render: function() {
return (
<div className="profile-menu">
<button className="btn btn-default" onClick={this.handleOpenClick}>Ouvrir</button>
<button className="btn btn-primary" onClick={this.handleSaveClick}>Enregistrer</button>
</div>
);
},
handleOpenClick: function() {
var dispatch = this.props.dispatch;
this.showOpenProfileDialog()
.then(function(profilePath) {
if(profilePath) dispatch(actions.common.loadProfile(profilePath));
})
;
},
handleSaveClick: function() {
var dispatch = this.props.dispatch;
var profile = this.props.profile;
var profilePath = this.props.profilePath;
this.showSaveProfileDialog(profilePath)
.then(function(profilePath) {
if(profilePath) dispatch(actions.edit.saveProfile(profilePath, profile));
});
},
showOpenProfileDialog: function() {
return new Promise(function(resolve) {
dialog.showOpenDialog(
{
title: 'Éditer un profil',
filters: [ {name: 'Profils Pitaya', extensions: ['json'] } ],
properties: ['openFile']
},
function(files) {
return resolve(files ? files[0] : null);
}
)
});
},
showSaveProfileDialog: function(defaultPath) {
return new Promise(function(resolve) {
dialog.showSaveDialog(
{
defaultPath: defaultPath,
title: 'Enregistrer un profil',
filters: [ {name: 'Profils Pitaya', extensions: ['json'] } ]
},
function(file) {
return resolve(file);
}
)
});
}
});
function select(state) {
return {
profile: state.profile,
profilePath: state.profilePath
};
}
module.exports = connect(select)(ProfileMenu);

View File

@ -1,79 +0,0 @@
var React = require('react');
var connect = require('react-redux').connect;
var actions = require('../../store/actions');
var ProfileMenu = React.createClass({
render: function() {
return (
<div className="profile-menu">
<input ref="fileInput" style={{display: 'none'}} filter=".json" type="file" />
<button className="btn btn-default" onClick={this.handleOpenClick}>Ouvrir</button>
<button className="btn btn-primary" onClick={this.handleSaveClick}>Enregistrer</button>
</div>
);
},
handleOpenClick: function() {
var dispatch = this.props.dispatch;
this.showFileDialog()
.then(function(profilePath) {
dispatch(actions.common.loadProfile(profilePath));
})
;
},
handleSaveClick: function() {
var dispatch = this.props.dispatch;
var profile = this.props.profile;
var profilePath = this.props.profilePath;
var promise = profilePath ? Promise.resolve(profilePath) : this.showFileDialog(true);
promise.then(function(profilePath) {
dispatch(actions.edit.saveProfile(profilePath, profile));
});
},
showFileDialog: function(saveAs) {
var fileInput = this.refs.fileInput.getDOMNode();
// Toggle 'save as' feature
if(saveAs) {
fileInput.nwsaveas = true;
} else {
fileInput.removeAttribute('nwsaveas');
}
return new Promise(function(resolve, reject) {
fileInput.addEventListener('change', handleChange, false);
fileInput.click();
function handleChange(evt) {
fileInput.removeEventListener('change', handleChange);
var value = this.value;
this.value = null;
resolve(value);
}
});
}
});
function select(state) {
return {
profile: state.profile,
profilePath: state.profilePath
};
}
module.exports = connect(select)(ProfileMenu);

View File

@ -1,7 +1,7 @@
var React = require('react');
var connect = require('react-redux').connect;
var actions = require('../../store/actions');
var TreeItem = require('./tree-item.jsx');
var TreeItem = require('./tree-item.js');
var TreeNode = React.createClass({
@ -17,6 +17,7 @@ var TreeNode = React.createClass({
selectedItem={this.props.selectedItem}
onItemClicked={this.props.onItemClicked}
onItemMoved={this.props.onItemMoved}
onItemRemoved={this.props.onItemRemoved}
theme={this.props.theme} />
</li>
);
@ -66,6 +67,7 @@ var ProfileTree = React.createClass({
selectedItem={this.props.selectedItem}
onItemClicked={this.onItemSelected}
onItemMoved={this.onItemMoved}
onItemRemoved={this.onItemRemoved}
theme={this.props.theme} />
);
},
@ -76,6 +78,10 @@ var ProfileTree = React.createClass({
onItemSelected: function(selectedItem) {
this.props.dispatch(actions.edit.selectProfileItem(selectedItem));
},
onItemRemoved: function(selectedItem) {
this.props.dispatch(actions.edit.removeProfileItem(selectedItem));
}
});

View File

@ -1,6 +1,6 @@
var React = require('react/addons');
var classNames = require('classnames');
var AppIcon = require('../common/app-icon.jsx');
var AppIcon = require('../common/app-icon.js');
var DragSource = require('react-dnd').DragSource;
var DropTarget = require('react-dnd').DropTarget;
var _ = require('lodash');
@ -30,6 +30,9 @@ var TreeItem = React.createClass({
<div className={classes} style={style} onClick={this.handleClick}>
{appIcon}
<span className="app-label">{data.label}</span>
<button type="button" className="close pull-right" onClick={this.handleRemoveClick}>
<span>&times;</span>
</button>
</div>
));
@ -38,6 +41,11 @@ var TreeItem = React.createClass({
handleClick: function(evt) {
evt.preventDefault();
this.props.onItemClicked(this.props.data);
},
handleRemoveClick: function(evt) {
evt.preventDefault();
this.props.onItemRemoved(this.props.data);
}
});

View File

@ -1,5 +1,5 @@
var React = require('react');
var AppIcon = require('../common/app-icon.jsx');
var AppIcon = require('../common/app-icon.js');
module.exports = React.createClass({

View File

@ -1,5 +1,5 @@
var React = require('react');
var AppItem = require('./app-item.jsx');
var AppItem = require('./app-item.js');
module.exports = React.createClass({

View File

@ -0,0 +1,19 @@
var React = require('react');
module.exports = React.createClass({
propTypes: {
item: React.PropTypes.object.isRequired,
},
render: function() {
return (
<div className="category-header">
<span className="category-label">{this.props.item.label}</span>
</div>
);
}
});

View File

@ -1,12 +1,14 @@
var React = require('react');
var CategoryHeader = require('./category-header.jsx');
var AppList = require('./app-list.jsx');
var CategoryHeader = require('./category-header.js');
var AppList = require('./app-list.js');
var Nav = require('./nav.js');
var AnimateMixin = require('../mixins/animate');
var actions = require('../../store/actions');
var connect = require('react-redux').connect;
var debug = require('../../util/debug')('launcher-view');
var path = require('path');
var DEFAULT_PROFILE = './default-profile.json';
var DEFAULT_PROFILE = path.join(__dirname, '..', '..', '..', 'default-profile.json');
var LauncherView = React.createClass({
@ -20,7 +22,7 @@ var LauncherView = React.createClass({
},
componentDidMount: function() {
var profilePath = this.props.processOpts.profile || DEFAULT_PROFILE;
var profilePath = process.env.PITAYA_PROFILE || DEFAULT_PROFILE;
this.props.dispatch(actions.common.loadProfile(profilePath));
},
@ -38,20 +40,35 @@ var LauncherView = React.createClass({
var header = currentItemPath !== '' ?
( <CategoryHeader
item={currentItem} /> ) :
null
;
var nav = currentItemPath !== '' ?
( <Nav
onBackClick={this.onBackClick}
item={currentItem}
itemPath={currentItemPath} /> ) :
null
;
var style = {};
if(currentItem && currentItem.background) {
style.backgroundImage = 'url('+currentItem.background+')';
}
return (
<div className="launcher">
<div className="launcher" style={style}>
{header}
<div className="main">
{nav}
<AppList ref="appList"
items={items}
parentPath={currentItemPath}
onItemClick={this.onItemClick} />
</div>
</div>
);
},

View File

@ -1,3 +1,4 @@
/* jshint node: true jsx: true */
var React = require('react');
module.exports = React.createClass({
@ -14,9 +15,8 @@ module.exports = React.createClass({
render: function() {
return (
<div className="category-header">
<div className="nav">
<a href="#" onClick={this._onBackClick} className="goback" >&#9668;</a>
<span className="category-label">{this.props.item.label}</span>
</div>
);

View File

@ -12,6 +12,7 @@ var SAVE_PROFILE_FAILED = exports.SAVE_PROFILE_FAILED = 'SAVE_PROFILE_FAILED';
var MOVE_PROFILE_ITEM = exports.MOVE_PROFILE_ITEM = 'MOVE_PROFILE_ITEM';
var ADD_PROFILE_ITEM = exports.ADD_PROFILE_ITEM = 'ADD_PROFILE_ITEM';
var REMOVE_PROFILE_ITEM = exports.REMOVE_PROFILE_ITEM = 'REMOVE_PROFILE_ITEM';
var USE_ICON_THEME = exports.USE_ICON_THEME = 'USE_ICON_THEME';
var SELECT_PROFILE_ITEM = exports.SELECT_PROFILE_ITEM = 'SELECT_PROFILE_ITEM';
var UPDATE_PROFILE_ITEM = exports.UPDATE_PROFILE_ITEM = 'UPDATE_PROFILE_ITEM';
@ -71,6 +72,13 @@ exports.moveProfileItem = function(movedItem, targetItem) {
};
};
exports.removeProfileItem = function(removedItem) {
return {
type: REMOVE_PROFILE_ITEM,
removedItem: removedItem
};
};
exports.addProfileItem = function(newItem, targetItem) {
return {
type: ADD_PROFILE_ITEM,

View File

@ -1,6 +1,5 @@
var redux = require('redux');
var thunkMiddleware = require('redux-thunk');
var loggerMiddleware = require('redux-logger');
var reducers = require('./reducers');
var loggerMiddleware = require('./middlewares/logger');

View File

@ -1,4 +1,3 @@
exports.desktopApps = require('./desktop-apps');
exports.profile = require('./profile');
exports.processOpts = require('./process-opts');
exports.theme = require('./theme');

View File

@ -1,8 +0,0 @@
var minimist = require('minimist');
var gui = global.window.require('nw.gui');
var opts = minimist(gui.App.argv);
module.exports = function(state, action) {
return opts;
};

View File

@ -14,6 +14,9 @@ module.exports = function(oldProfile, action) {
case actions.edit.MOVE_PROFILE_ITEM:
return moveProfileItem(oldProfile, action.movedItem, action.targetItem);
case actions.edit.REMOVE_PROFILE_ITEM:
return removeProfileItem(oldProfile, action.removedItem);
case actions.edit.ADD_PROFILE_ITEM:
return addProfileItem(oldProfile, action.newItem, action.targetItem);
@ -48,6 +51,18 @@ function updateProfileItem(oldProfile, targetItem, key, value) {
return newProfile;
}
function removeProfileItem(oldProfile, removedItem) {
var newProfile = _.cloneDeep(oldProfile);
var parent = tree.find(newProfile, removedItem).parent;
parent.items = _.reject(parent.items, function(item) {
return _.isEqual(item, removedItem);
});
return newProfile;
}
function moveProfileItem(oldProfile, movedItem, targetItem) {

2
js/util/const.js Normal file
View File

@ -0,0 +1,2 @@
exports.EDIT_MODE = 'edit';
exports.LAUNCHER_MODE = 'launcher';

View File

@ -3,15 +3,5 @@ var util = require('util');
module.exports = function createLogger(namespace) {
var logger = debug('pitaya:'+namespace);
var isNWContext = 'window' in global;
var console = isNWContext ? global.window.console : global.console;
logger.log = function() {
if(isNWContext) {
console.log.apply(console, arguments);
} else {
var str = util.format.apply(util, arguments);
console.log(str);
}
};
return logger;
};

View File

@ -249,7 +249,9 @@ exports.findPixmapsIcon = function(iconName) {
exports.findIconThemes = function() {
return System.findFiles('*/', {cwd: ICON_THEMES_ROOTDIR, realpath: true})
.then(function(files) {
return files.map(path.basename.bind(path));
return files.map(function(f) {
return path.basename(f);
});
})
;
};

41
main.js Normal file
View File

@ -0,0 +1,41 @@
var app = require('app'); // Module to control application life.
var BrowserWindow = require('browser-window'); // Module to create native browser window.
var isDev = process.env.NODE_ENV === 'development';
var constants = require('./'+(isDev ? 'js': 'js-compiled')+'/util/const');
var mainWindow = null;
// Quit when all windows are closed.
app.on('window-all-closed', function() {
app.quit();
});
app.on('ready', function() {
// Create the browser window.
var electronScreen = require('screen');
var size = electronScreen.getPrimaryDisplay().workAreaSize;
var asDesktop = process.env.PITAYA_AS_DESKTOP == 1;
mainWindow = new BrowserWindow({
type: asDesktop ? 'desktop' : undefined,
'skip-taskbar': asDesktop,
frame: !asDesktop,
width: asDesktop ? size.width : undefined,
height: asDesktop ? size.height : undefined,
x: asDesktop ? 0 : undefined,
y: asDesktop ? 0 : undefined,
});
if(process.env.NODE_ENV === 'development') {
mainWindow.openDevTools();
}
// and load the index.html of the app.
mainWindow.loadUrl('file://' + __dirname + '/index.html');
mainWindow.on('closed', function() {
mainWindow = null;
});
});

View File

@ -2,25 +2,21 @@
"name": "pitaya",
"version": "0.0.0",
"private": "true",
"main": "index.html",
"main": "main.js",
"devDependencies": {
"grunt": "^0.4.5",
"grunt-cli": "^0.1.13",
"grunt-contrib-clean": "^0.6.0",
"grunt-contrib-copy": "^0.7.0",
"grunt-nw": "git+https://github.com/snap-project/grunt-nw#develop",
"lodash": "^3.0.1",
"nodeunit": "^0.9.1"
"electron-packager": "^5.1.0",
"electron-prebuilt": "^0.33.6",
"nodeunit": "^0.9.1",
"react-tools": "^0.13.3",
"node-jsx": "^0.13.3"
},
"scripts": {
"test": "./node_modules/.bin/nodeunit test",
"start": "./node_modules/.bin/grunt pitaya:run",
"build": "./node_modules/.bin/grunt pitaya:build"
},
"chromium-args": "--ignore-certificate-errors",
"window": {
"toolbar": true,
"kiosk": false
"start": "./node_modules/.bin/electron .",
"compile": "./node_modules/.bin/jsx -x js js js-compiled",
"clean": "rm -rf js-compiled/* build/*",
"package": "./node_modules/.bin/electron-packager ./ pitaya --prune --ignore=js/ --platform=linux --arch=ia32 --version=0.33.6 --out=build --overwrite --app-version 0.0.0",
"build": "npm run clean && npm run compile && npm run package"
},
"dependencies": {
"bootstrap": "^3.3.5",
@ -29,8 +25,6 @@
"glob": "^5.0.14",
"ini": "^1.3.4",
"lodash": "^3.10.1",
"minimist": "^1.1.3",
"node-jsx": "^0.13.3",
"react": "^0.13.3",
"react-dnd": "^1.1.5",
"react-redux": "^2.0.0",