diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..602af11 --- /dev/null +++ b/.babelrc @@ -0,0 +1,16 @@ +{ + "stage": 0, + "plugins": [ + "react-transform" + ], + "extra": { + "react-transform": [{ + "target": "react-transform-webpack-hmr", + "imports": ["react"], + "locals": ["module"] + }, { + "target": "react-transform-catch-errors", + "imports": ["react", "redbox-react/dist/redbox"] + }] + } +} \ No newline at end of file diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..3af7199 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,4 @@ +node_modules +build +dev +webpack/replace \ No newline at end of file diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..5ba7b68 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,30 @@ +{ + "extends": "eslint-config-airbnb", + "globals": { + "chrome": true, + "__DEVELOPMENT__": true + }, + "env": { + "browser": true, + "node": true + }, + "rules": { + "react/jsx-uses-react": 2, + "react/jsx-uses-vars": 2, + "react/react-in-jsx-scope": 2, + "react/jsx-quotes": 0, + "block-scoped-var": 0, + "padded-blocks": 0, + "quotes": [ 1, "single" ], + "comma-style": [ 2, "last" ], + "eol-last": 0, + "no-unused-vars": 0, + "no-console": 0, + "func-names": 0, + "prefer-const": 0, + "comma-dangle": 0 + }, + "plugins": [ + "react" + ] +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..35db468 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules +npm-debug.log +.DS_Store +.idea/ +build/ +dev/ diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..78277c2 --- /dev/null +++ b/.npmignore @@ -0,0 +1,6 @@ +node_modules +npm-debug.log +.DS_Store + +build/ +dev/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..d99c2e7 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,3 @@ +language: node_js +node_js: + - "iojs" \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fa45e85 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2015 Mihail Diordiev +Copyright (c) 2015 Jhen-Jie Hong + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..dd37eaf --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# Browser App and Extension Boilerplate using Redux Actions + +Simple boilerplate and library for building Chrome apps and cross-browser extensions (support for Firefox and Safari will come later) that use Redux actions instead of messaging. + +Redux states are synced between background, inject page, app window, extension popup and badge. + +The developing is the same as for the web apps with React (optional) and Redux, just use the `src/app` boilerplate. If you need some extension or Chrome app customizations, use `src/browser/` boilerplates. A simple example will be injected on the bottom of `https://github.com/*` pages (change arrowURLs in `src/browser/extension/background/inject.js` and add to `permissions` in extension's manifest). + +The example is edited from [Redux Counter example](https://github.com/rackt/redux/tree/master/examples/counter), based on [React Chrome Extension Boilerplate](https://github.com/jhen0409/react-chrome-extension-boilerplate). + +## Included + + - [react](https://github.com/facebook/react) + - [redux](https://github.com/rackt/redux) + - [react-redux](https://github.com/gaearon/react-redux) + - [redux-persist](https://github.com/rt2zz/redux-persist) + - [redux-devtools](https://github.com/gaearon/redux-devtools) + - [redux-logger](https://github.com/fcomb/redux-logger) + - [redbox-react](https://github.com/KeywordBrain/redbox-react) + - [react-chrome-extension-boilerplate](https://github.com/jhen0409/react-chrome-extension-boilerplate) + - [webpack](https://github.com/webpack/webpack) + - [react-transform-webpack-hmr](https://github.com/gaearon/react-transform-webpack-hmr) + - [react-transform-catch-errors](https://github.com/gaearon/react-transform-catch-errors) + - [babel](https://github.com/babel/babel) + - [babel-plugin-react-transform](https://github.com/gaearon/babel-plugin-react-transform) + - [gulp](https://github.com/gulpjs/gulp) + +## Installation + +```bash +# required node.js/io.js +# clone it +npm install +``` + +## Development + +```bash +# build files to './dev' +# watch files change +# start WebpackDevServer +npm run dev +``` + +You can load unpacked extensions with `./dev`. + +#### React/Flux hot reload + +This boilerplate uses `Webpack` and `react-transform`, and use `Redux`. You can hot reload by editing related files of Popup & Window. + +## Build extension + +```bash +# build files to './build/extension' +npm run build:extension +``` + +## Build app + +```bash +# build files to './build/app' +npm run build:app +``` + +## Build & Compress ZIP file + +```bash +# compress extension's build folder to extension.zip +npm run compress:extension + +# compress app's build folder to app.zip +npm run compress:app +``` + +## Load to Chrome + +- [Load the extension](https://developer.chrome.com/extensions/getstarted#unpacked) +- [Launch your app](https://developer.chrome.com/apps/first_app#five) + + +## Roadmap + +- [ ] Add tests (using [sinon-chrome](https://github.com/vitalets/sinon-chrome)) +- [ ] Firefox extension (according to [wiki.mozilla.org/WebExtensions](https://wiki.mozilla.org/WebExtensions)) +- [ ] Safari extension (based on [Chrome to Safari port](https://code.google.com/p/adblockforchrome/source/browse/trunk/port.js)) + +## LICENSE + +[MIT](LICENSE) \ No newline at end of file diff --git a/gulpfile.babel.js b/gulpfile.babel.js new file mode 100644 index 0000000..9dba994 --- /dev/null +++ b/gulpfile.babel.js @@ -0,0 +1,140 @@ +import fs from 'fs'; +import gulp from 'gulp'; +import gutil from 'gulp-util'; +import jade from 'gulp-jade'; +import rename from 'gulp-rename'; +import zip from 'gulp-zip'; +import webpack from 'webpack'; +import WebpackDevServer from 'webpack-dev-server'; +import devConfig from './webpack/dev.config'; +import prodConfig from './webpack/prod.config'; +import appConfig from './webpack/app.config'; + +const port = 3000; + +/* + * common tasks + */ +gulp.task('replace-webpack-code', () => { + const replaceTasks = [ { + from: './webpack/replace/JsonpMainTemplate.runtime.js', + to: './node_modules/webpack/lib/JsonpMainTemplate.runtime.js' + }, { + from: './webpack/replace/log-apply-result.js', + to: './node_modules/webpack/hot/log-apply-result.js' + } ]; + replaceTasks.forEach(task => fs.writeFileSync(task.to, fs.readFileSync(task.from))); +}); + +/* + * dev tasks + */ + +gulp.task('webpack-dev-server', () => { + let myConfig = Object.create(devConfig); + new WebpackDevServer(webpack(myConfig), { + contentBase: `http://localhost:${port}`, + publicPath: myConfig.output.publicPath, + stats: {colors: true}, + hot: true, + historyApiFallback: true + }).listen(port, 'localhost', (err) => { + if (err) { + throw new gutil.PluginError('webpack-dev-server', err); + } + gutil.log('[webpack-dev-server]', `listening at port ${port}`); + }); +}); + +gulp.task('views:dev', () => { + gulp.src('./src/browser/views/*.jade') + .pipe(jade({ + locals: { env: 'dev' } + })) + .pipe(gulp.dest('./dev')); +}); + +gulp.task('copy:dev', () => { + gulp.src('./src/browser/extension/manifest.dev.json') + .pipe(rename('manifest.json')) + .pipe(gulp.dest('./dev')); + gulp.src('./src/assets/**/*').pipe(gulp.dest('./dev')); +}); + +/* + * build tasks + */ + +gulp.task('webpack:build:extension', (callback) => { + let myConfig = Object.create(prodConfig); + webpack(myConfig, (err, stats) => { + if (err) { + throw new gutil.PluginError('webpack:build', err); + } + gutil.log('[webpack:build]', stats.toString({ colors: true })); + callback(); + }); +}); + +gulp.task('webpack:build:app', (callback) => { + let myConfig = Object.create(appConfig); + webpack(myConfig, (err, stats) => { + if (err) { + throw new gutil.PluginError('webpack:build', err); + } + gutil.log('[webpack:build]', stats.toString({ colors: true })); + callback(); + }); +}); + +gulp.task('views:build:extension', () => { + gulp.src('./src/browser/views/*.jade') + .pipe(jade({ + locals: { env: 'prod' } + })) + .pipe(gulp.dest('./build/extension')); +}); + +gulp.task('views:build:app', () => { + gulp.src('./src/browser/views/*.jade') + .pipe(jade({ + locals: { env: 'prod' } + })) + .pipe(gulp.dest('./build/app')); +}); + +gulp.task('copy:build:extension', () => { + gulp.src('./src/browser/extension/manifest.prod.json') + .pipe(rename('manifest.json')) + .pipe(gulp.dest('./build/extension')); + gulp.src('./src/assets/**/*').pipe(gulp.dest('./build/extension')); +}); + +gulp.task('copy:build:app', () => { + gulp.src('./src/browser/chromeApp/manifest.json') + .pipe(rename('manifest.json')) + .pipe(gulp.dest('./build/app')); + gulp.src('./src/assets/**/*').pipe(gulp.dest('./build/app')); +}); + +/* + * compress task + */ + +gulp.task('zip:extension', () => { + gulp.src('build/extension/*') + .pipe(zip('extension.zip')) + .pipe(gulp.dest('./build')); +}); + +gulp.task('zip:app', () => { + gulp.src('build/app/*') + .pipe(zip('app.zip')) + .pipe(gulp.dest('./build')); +}); + +gulp.task('default', ['replace-webpack-code', 'webpack-dev-server', 'views:dev', 'copy:dev']); +gulp.task('build:extension', ['replace-webpack-code', 'webpack:build:extension', 'views:build:extension', 'copy:build:extension']); +gulp.task('build:app', ['replace-webpack-code', 'webpack:build:app', 'views:build:app', 'copy:build:app']); +gulp.task('compress:extension', ['zip:extension']); +gulp.task('compress:app', ['zip:app']); diff --git a/package.json b/package.json new file mode 100644 index 0000000..d0bcf4c --- /dev/null +++ b/package.json @@ -0,0 +1,70 @@ +{ + "name": "browser-redux", + "version": "0.1.0", + "description": "Building Chrome apps and cross-browser extensions with Redux and Webpack.", + "scripts": { + "start": "gulp", + "build:extension": "gulp build:extension", + "build:app": "gulp build:app", + "compress:extension": "npm run build:extension && gulp compress:extension", + "compress:app": "npm run build:app && gulp compress:app", + "clean": "rm -rf build/ && rm -rf dev/", + "test": "eslint ." + }, + "repository": { + "type": "git", + "url": "https://github.com/zalmoxisus/browser-extension-boilerplate" + }, + "homepage": "https://github.com/zalmoxisus/browser-extension-boilerplate", + "keywords": [ + "react", + "reactjs", + "boilerplate", + "hot", + "live", + "edit", + "webpack", + "flux", + "redux", + "chrome", + "extension" + ], + "author": "Jhen ", + "license": "MIT", + "devDependencies": { + "babel": "^5.8.21", + "babel-core": "^5.8.22", + "babel-eslint": "^4.0.10", + "babel-loader": "^5.3.2", + "babel-plugin-react-transform": "^1.0.1", + "chai": "^3.2.0", + "eslint": "^1.1.0", + "eslint-config-airbnb": "0.0.7", + "eslint-plugin-react": "^3.2.3", + "gulp": "^3.9.0", + "gulp-jade": "^1.0.1", + "gulp-rename": "^1.2.2", + "gulp-util": "^3.0.5", + "gulp-zip": "^3.0.2", + "mocha": "^2.2.5", + "phantomjs": "^1.9.17", + "raw-loader": "^0.5.1", + "react-transform-catch-errors": "^0.1.1", + "react-transform-webpack-hmr": "^0.1.1", + "redbox-react": "^1.0.1", + "redux-logger": "^1.0.8", + "sinon-chrome": "^0.2.1", + "style-loader": "^0.12.3", + "webpack": "^1.11.0", + "webpack-dev-server": "^1.10.1" + }, + "dependencies": { + "react": "^0.13.3", + "react-redux": "^3.0.1", + "redux": "^3.0.2", + "redux-immutable-state-invariant": "^1.1.0", + "redux-persist": "^1.0.0", + "redux-persist-crosstab": "^1.0.1", + "redux-thunk": "^1.0.0" + } +} diff --git a/src/app/actions/commands.js b/src/app/actions/commands.js new file mode 100644 index 0000000..ec9da8c --- /dev/null +++ b/src/app/actions/commands.js @@ -0,0 +1,5 @@ +import { INCREMENT_IN_BG } from '../constants/BgCommands'; + +export function incrementBG() { + return { type: INCREMENT_IN_BG }; +} diff --git a/src/app/actions/counter.js b/src/app/actions/counter.js new file mode 100755 index 0000000..97a73c7 --- /dev/null +++ b/src/app/actions/counter.js @@ -0,0 +1,29 @@ +import { INCREMENT_COUNTER, DECREMENT_COUNTER } from '../constants/ActionTypes'; + +export function increment() { + return { type: INCREMENT_COUNTER }; +} + +export function decrement() { + return { type: DECREMENT_COUNTER }; +} + +export function incrementIfOdd() { + return (dispatch, getState) => { + const { counter } = getState(); + + if (counter.count % 2 === 0) { + return; + } + + dispatch(increment()); + }; +} + +export function incrementAsync(delay = 1000) { + return dispatch => { + setTimeout(() => { + dispatch(increment()); + }, delay); + }; +} diff --git a/src/app/actions/rehydrateAction.js b/src/app/actions/rehydrateAction.js new file mode 100644 index 0000000..683f530 --- /dev/null +++ b/src/app/actions/rehydrateAction.js @@ -0,0 +1,19 @@ +import { REHYDRATE } from 'redux-persist/constants' +import { increment } from './counter'; + +const rehydrateAction = (store) => { + return (key, data) => { + if (key === 'extension') { + console.warn('key', key, data); + store.dispatch(increment()); + } + + return { + type: REHYDRATE, + key: key, + payload: data + } + }; +}; + +export default rehydrateAction; \ No newline at end of file diff --git a/src/app/components/Counter.js b/src/app/components/Counter.js new file mode 100644 index 0000000..62a1bca --- /dev/null +++ b/src/app/components/Counter.js @@ -0,0 +1,34 @@ +import React, { Component, PropTypes } from 'react'; + +class Counter extends Component { + render() { + const { increment, incrementIfOdd, incrementAsync, incrementBG, decrement, state } = this.props; + console.log('%cRender ' + this.constructor.displayName + ' component', 'background: #FFF; color: #2aa198 ', 'state', this.state, 'props', this.props); + return ( +

+ Clicked: {state.counter.count} times + {' '} + + {' '} + + {' '} + + {' '} + + {' '} + +

+ ); + } +} + +Counter.propTypes = { + increment: PropTypes.func.isRequired, + incrementIfOdd: PropTypes.func.isRequired, + incrementAsync: PropTypes.func.isRequired, + incrementBG: PropTypes.func.isRequired, + decrement: PropTypes.func.isRequired, + state: PropTypes.object.isRequired +}; + +export default Counter; diff --git a/src/app/constants/ActionTypes.js b/src/app/constants/ActionTypes.js new file mode 100644 index 0000000..c01b8a9 --- /dev/null +++ b/src/app/constants/ActionTypes.js @@ -0,0 +1,2 @@ +export const INCREMENT_COUNTER = 'INCREMENT_COUNTER'; +export const DECREMENT_COUNTER = 'DECREMENT_COUNTER'; diff --git a/src/app/constants/BgCommands.js b/src/app/constants/BgCommands.js new file mode 100644 index 0000000..ac6555e --- /dev/null +++ b/src/app/constants/BgCommands.js @@ -0,0 +1 @@ +export const INCREMENT_IN_BG = 'INCREMENT_IN_BG'; diff --git a/src/app/containers/Root.js b/src/app/containers/Root.js new file mode 100644 index 0000000..d07788c --- /dev/null +++ b/src/app/containers/Root.js @@ -0,0 +1,16 @@ +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; + +import Counter from '../components/Counter'; +import * as counterActions from '../actions/counter'; +import * as commandsActions from '../actions/commands'; + +function mapStateToProps(state) { + return { + state: state + } +} + +const mapDispatchToProps = { ...counterActions, ...commandsActions}; + +export default connect(mapStateToProps, mapDispatchToProps)(Counter); diff --git a/src/app/reducers/counter.js b/src/app/reducers/counter.js new file mode 100644 index 0000000..72a834b --- /dev/null +++ b/src/app/reducers/counter.js @@ -0,0 +1,12 @@ +import { INCREMENT_COUNTER, DECREMENT_COUNTER } from '../constants/ActionTypes'; + +export default function counter(state = { count: 0 }, action) { + switch (action.type) { + case INCREMENT_COUNTER: + return { ...state, count: state.count + 1 }; + case DECREMENT_COUNTER: + return { ...state, count: state.count - 1 }; + default: + return state; + } +} diff --git a/src/app/reducers/extension.js b/src/app/reducers/extension.js new file mode 100755 index 0000000..2d3e0f4 --- /dev/null +++ b/src/app/reducers/extension.js @@ -0,0 +1,8 @@ +import * as commands from '../constants/BgCommands'; + +export default function extension(state = { command: null, ts: null }, action) { + if (commands[action.type]) { + return { ...state, command: action.type, ts: Date.now() }; + } + else return state; +} diff --git a/src/app/reducers/index.js b/src/app/reducers/index.js new file mode 100644 index 0000000..868226b --- /dev/null +++ b/src/app/reducers/index.js @@ -0,0 +1,10 @@ +import { combineReducers } from 'redux'; +import extension from './extension'; +import counter from './counter'; + +const rootReducer = combineReducers({ + extension, + counter +}); + +export default rootReducer; diff --git a/src/app/store/configureStore.js b/src/app/store/configureStore.js new file mode 100755 index 0000000..881141b --- /dev/null +++ b/src/app/store/configureStore.js @@ -0,0 +1,19 @@ +import { createStore, applyMiddleware, compose } from 'redux'; +import thunk from 'redux-thunk'; +import { persistStore, autoRehydrate } from 'redux-persist' +import crosstabSync from 'redux-persist-crosstab' +import reducers from '../reducers'; +import rehydrateAction from '../actions/rehydrateAction'; + +const middleware = __DEVELOPMENT__ ? + [require('redux-logger')({ level: 'info', collapsed: true }), require('redux-immutable-state-invariant')(), thunk] : + [thunk]; +const finalCreateStore = compose(applyMiddleware(...middleware),autoRehydrate())(createStore); + +export default function configureStore(initialState, isFromBackground) { + console.warn('isFromBackground',isFromBackground); + var store = finalCreateStore(reducers); + const persistor = persistStore(store, isFromBackground ? { rehydrateAction: rehydrateAction(store) } : {}); + crosstabSync(persistor); + return store; +} diff --git a/src/assets/fonts/.gitkeep b/src/assets/fonts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/assets/img/logo/128x128.png b/src/assets/img/logo/128x128.png new file mode 100644 index 0000000..7c6b485 Binary files /dev/null and b/src/assets/img/logo/128x128.png differ diff --git a/src/assets/img/logo/16x16.png b/src/assets/img/logo/16x16.png new file mode 100644 index 0000000..84c2a0c Binary files /dev/null and b/src/assets/img/logo/16x16.png differ diff --git a/src/assets/img/logo/48x48.png b/src/assets/img/logo/48x48.png new file mode 100644 index 0000000..7a7e33d Binary files /dev/null and b/src/assets/img/logo/48x48.png differ diff --git a/src/assets/img/logo/scalable.ico b/src/assets/img/logo/scalable.ico new file mode 100644 index 0000000..a55523e Binary files /dev/null and b/src/assets/img/logo/scalable.ico differ diff --git a/src/browser/chromeApp/index.js b/src/browser/chromeApp/index.js new file mode 100644 index 0000000..3883fa4 --- /dev/null +++ b/src/browser/chromeApp/index.js @@ -0,0 +1,6 @@ +chrome.app.runtime.onLaunched.addListener(function() { + chrome.app.window.create('window.html', { + 'state': 'maximized' + // More parameters: https://developer.chrome.com/apps/app_window#CreateWindowOptions + }); +}); diff --git a/src/browser/chromeApp/manifest.json b/src/browser/chromeApp/manifest.json new file mode 100644 index 0000000..b62b482 --- /dev/null +++ b/src/browser/chromeApp/manifest.json @@ -0,0 +1,18 @@ +{ + "version": "0.0.0", + "name": "browser-app", + "manifest_version": 2, + "description": "Redux counter example", + "icons": { + "16": "img/logo/16x16.png", + "48": "img/logo/48x48.png", + "128": "img/logo/128x128.png", + "scalable": "img/logo/scalable.ico" + }, + "app": { + "background": { + "page": "background.html" + } + }, + "permissions": [ "contextMenus", "storage", "https://github.com/*" ] +} \ No newline at end of file diff --git a/src/browser/extension/background/contextMenus.js b/src/browser/extension/background/contextMenus.js new file mode 100644 index 0000000..9d62583 --- /dev/null +++ b/src/browser/extension/background/contextMenus.js @@ -0,0 +1,36 @@ +let windowId = 0; +const CONTEXT_MENU_ID = 'example_context_menu'; + +function closeIfExist() { + if (windowId > 0) { + chrome.windows.remove(windowId); + windowId = chrome.windows.WINDOW_ID_NONE; + } +} + +function popWindow(type) { + closeIfExist(); + let options = { + type: 'popup', + left: 100, top: 100, + width: 800, height: 475 + }; + if (type === 'open') { + options.url = 'window.html'; + chrome.windows.create(options, (win) => { + windowId = win.id; + }); + } +} + +chrome.contextMenus.create({ + id: CONTEXT_MENU_ID, + title: 'Redux counter app', + contexts: ['all'] +}); + +chrome.contextMenus.onClicked.addListener((event) => { + if (event.menuItemId === CONTEXT_MENU_ID) { + popWindow('open'); + } +}); \ No newline at end of file diff --git a/src/browser/extension/background/index.js b/src/browser/extension/background/index.js new file mode 100644 index 0000000..a2faa64 --- /dev/null +++ b/src/browser/extension/background/index.js @@ -0,0 +1,3 @@ +import './contextMenus'; +import './inject'; +import './main'; diff --git a/src/browser/extension/background/inject.js b/src/browser/extension/background/inject.js new file mode 100644 index 0000000..3b6281f --- /dev/null +++ b/src/browser/extension/background/inject.js @@ -0,0 +1,26 @@ +const arrowURLs = [ 'https://github.com' ]; + +chrome.tabs.onUpdated.addListener(function(tabId, changeInfo, tab) { + if (changeInfo.status !== 'loading') return; + const matched = arrowURLs.every(url => !!tab.url.match(url)); + if (!matched) return; + + chrome.tabs.executeScript(tabId, { + code: 'var injected = window.reactExampleInjected; window.reactExampleInjected = true; injected;', + runAt: 'document_start' + }, (result) => { + if (chrome.runtime.lastError || result[0]) return; + + if (__DEVELOPMENT__) { + // dev: async fetch bundle + fetch('http://localhost:3000/js/inject.bundle.js').then(response => { + return response.text(); + }).then(response => { + chrome.tabs.executeScript(tabId, { code: response, runAt: 'document_start' }); + }); + } else { + // prod + chrome.tabs.executeScript(tabId, { file: '/js/inject.bundle.js', runAt: 'document_start' }); + } + }); +}); diff --git a/src/browser/extension/background/main.js b/src/browser/extension/background/main.js new file mode 100644 index 0000000..0dde506 --- /dev/null +++ b/src/browser/extension/background/main.js @@ -0,0 +1,42 @@ +import configureStore from '../../../app/store/configureStore'; + +const store = configureStore({counter: {count: 0}}, true); + +// Message listener + +chrome.runtime.onMessage.addListener( + function (req, sender, sendResponse) { + + // Update state + if (req.action === 'updateState') { + store.dispatch({ + type: req.type, + state: req.state + }); + } + + // Send current state + if (req.action === 'getState') { + sendResponse(store.getState()); + } + + }); + +// Badge + +function select(state) { + return state.counter.count; +} + +let currentValue; +function handleChange() { + let previousValue = currentValue; + currentValue = select(store.getState()); + + if (previousValue !== currentValue) { + chrome.browserAction.setBadgeText({text: '' + currentValue}); + } +} + +let unsubscribe = store.subscribe(handleChange); +handleChange(); diff --git a/src/browser/extension/inject/index.js b/src/browser/extension/inject/index.js new file mode 100644 index 0000000..ac49288 --- /dev/null +++ b/src/browser/extension/inject/index.js @@ -0,0 +1,25 @@ +import configureStore from '../../../app/store/configureStore'; + +let store = {}; + +// Get current state from Background Page +const getState = (store, next) => { + chrome.runtime.sendMessage({ + action: 'getState' + }, function (res) { + if (!res) return console.error('No response from background', chrome.runtime.lastError); + store = configureStore(res); + next(res); + }); +}; + +// Inject content in the page +const injectContent = (state) => { + let injectDiv = document.createElement('div'); + injectDiv.style.width = '100px'; injectDiv.style.margin = '5px auto'; injectDiv.style.backgroundColor = 'red'; injectDiv.style.color = 'white'; injectDiv.style.textAlign = 'center'; injectDiv.style.cursor = 'pointer'; + injectDiv.innerText = 'Clicked: ' + state.counter.count; + injectDiv.addEventListener("click", function() { injectDiv.parentNode.removeChild(injectDiv); getState(store, injectContent); }); + document.body.appendChild(injectDiv); +}; + +getState(store, injectContent); diff --git a/src/browser/extension/manifest.dev.json b/src/browser/extension/manifest.dev.json new file mode 100644 index 0000000..e43d8c9 --- /dev/null +++ b/src/browser/extension/manifest.dev.json @@ -0,0 +1,21 @@ +{ + "version": "0.0.0", + "name": "browser-extension", + "manifest_version": 2, + "description": "Redux counter example", + "browser_action": { + "default_title": "Redux counter example", + "default_popup": "popup.html" + }, + "icons": { + "16": "img/logo/16x16.png", + "48": "img/logo/48x48.png", + "128": "img/logo/128x128.png", + "scalable": "img/logo/scalable.ico" + }, + "background": { + "page": "background.html" + }, + "permissions": [ "contextMenus", "tabs", "storage", "https://github.com/*" ], + "content_security_policy": "default-src 'self'; script-src 'self' http://localhost:3000 'unsafe-eval'; connect-src http://localhost:3000 ws://localhost:3000 ws://localhost:35729; style-src * 'unsafe-inline'; img-src 'self' data:;" +} \ No newline at end of file diff --git a/src/browser/extension/manifest.prod.json b/src/browser/extension/manifest.prod.json new file mode 100644 index 0000000..32fa56e --- /dev/null +++ b/src/browser/extension/manifest.prod.json @@ -0,0 +1,21 @@ +{ + "version": "0.0.0", + "name": "browser-extension", + "manifest_version": 2, + "description": "Redux counter example", + "browser_action": { + "default_title": "Redux counter example", + "default_popup": "popup.html" + }, + "icons": { + "16": "img/logo/16x16.png", + "48": "img/logo/48x48.png", + "128": "img/logo/128x128.png", + "scalable": "img/logo/scalable.ico" + }, + "background": { + "page": "background.html" + }, + "permissions": [ "contextMenus", "tabs", "storage", "https://github.com/*" ], + "content_security_policy": "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src * 'unsafe-inline'; img-src 'self' data:;" +} \ No newline at end of file diff --git a/src/browser/extension/popup/index.js b/src/browser/extension/popup/index.js new file mode 100644 index 0000000..6390570 --- /dev/null +++ b/src/browser/extension/popup/index.js @@ -0,0 +1,13 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import Root from '../../../app/containers/Root'; +import configureStore from '../../../app/store/configureStore'; + +const store = configureStore(); + +React.render( + + {() => } + , + document.getElementById('root') +); diff --git a/src/browser/views/background.jade b/src/browser/views/background.jade new file mode 100644 index 0000000..a4600f0 --- /dev/null +++ b/src/browser/views/background.jade @@ -0,0 +1,5 @@ +doctype html + +html + head + script(src=env == 'prod' ? '/js/background.bundle.js' : 'http://localhost:3000/js/background.bundle.js') \ No newline at end of file diff --git a/src/browser/views/popup.jade b/src/browser/views/popup.jade new file mode 100644 index 0000000..afa8af1 --- /dev/null +++ b/src/browser/views/popup.jade @@ -0,0 +1,12 @@ +doctype html + +html + head + meta(charset='UTF-8') + title Redux Counter Example + style. + body { width: 150px; } + + body + #root + script(src=env == 'prod' ? '/js/popup.bundle.js' : 'http://localhost:3000/js/popup.bundle.js') \ No newline at end of file diff --git a/src/browser/views/window.jade b/src/browser/views/window.jade new file mode 100644 index 0000000..82e1e5f --- /dev/null +++ b/src/browser/views/window.jade @@ -0,0 +1,10 @@ +doctype html + +html + head + meta(charset='UTF-8') + title Redux Counter Example + + body + #root + script(src=env == 'prod' ? '/js/window.bundle.js' : 'http://localhost:3000/js/window.bundle.js') \ No newline at end of file diff --git a/src/browser/window/index.js b/src/browser/window/index.js new file mode 100644 index 0000000..beff42f --- /dev/null +++ b/src/browser/window/index.js @@ -0,0 +1,13 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import Root from '../../app/containers/Root'; +import configureStore from '../../app/store/configureStore'; + +const store = configureStore(); + +React.render( + + {() => } + , + document.getElementById('root') +); diff --git a/test/.gitkeep b/test/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/webpack/app.config.js b/webpack/app.config.js new file mode 100644 index 0000000..ca17633 --- /dev/null +++ b/webpack/app.config.js @@ -0,0 +1,42 @@ +import path from 'path'; +import webpack from 'webpack'; + +export default { + entry: { + background: [ path.join(__dirname, '../src/browser/chromeApp/index') ], + window: [ path.join(__dirname, '../src/browser/window/index') ] + }, + output: { + path: path.join(__dirname, '../build/app/js'), + filename: '[name].bundle.js', + chunkFilename: '[id].chunk.js' + }, + plugins: [ + new webpack.DefinePlugin({ + 'process.env': { + NODE_ENV: '"production"' + }, + __DEVELOPMENT__: false + }), + new webpack.optimize.DedupePlugin(), + new webpack.optimize.UglifyJsPlugin({ + comments: false, + compressor: { + warnings: false + } + }) + ], + resolve: { + extensions: ['', '.js'] + }, + module: { + loaders: [{ + test: /\.js$/, + loaders: ['babel'], + exclude: /node_modules/ + }, { + test: /\.css?$/, + loaders: ['style', 'raw'] + }] + } +}; \ No newline at end of file diff --git a/webpack/dev.config.js b/webpack/dev.config.js new file mode 100644 index 0000000..d27d03a --- /dev/null +++ b/webpack/dev.config.js @@ -0,0 +1,44 @@ +import path from 'path'; +import webpack from 'webpack'; + +const port = 3000; +const entry = [ + `webpack-dev-server/client?http://localhost:${port}`, + 'webpack/hot/only-dev-server' +]; + +export default { + devtool: 'inline-source-map', + entry: { + background: [ path.join(__dirname, '../src/browser/extension/background/index'), ...entry ], + window: [ path.join(__dirname, '../src/browser/window/index'), ...entry ], + popup: [ path.join(__dirname, '../src/browser/extension/popup/index'), ...entry ], + inject: [ path.join(__dirname, '../src/browser/extension/inject/index'), ...entry ] + }, + output: { + path: path.join(__dirname, '../dev/js'), + filename: '[name].bundle.js', + chunkFilename: '[id].chunk.js', + publicPath: 'http://localhost:${port}/js/' + }, + plugins: [ + new webpack.HotModuleReplacementPlugin(), + new webpack.NoErrorsPlugin(), + new webpack.DefinePlugin({ + __DEVELOPMENT__: true + }) + ], + resolve: { + extensions: ['', '.js'] + }, + module: { + loaders: [{ + test: /\.js$/, + loaders: ['babel'], + exclude: /node_modules/ + }, { + test: /\.css?$/, + loaders: ['style', 'raw'] + }] + } +}; \ No newline at end of file diff --git a/webpack/prod.config.js b/webpack/prod.config.js new file mode 100644 index 0000000..9694ae4 --- /dev/null +++ b/webpack/prod.config.js @@ -0,0 +1,44 @@ +import path from 'path'; +import webpack from 'webpack'; + +export default { + entry: { + background: [ path.join(__dirname, '../src/browser/extension/background/index') ], + window: [ path.join(__dirname, '../src/browser/window/index') ], + popup: [ path.join(__dirname, '../src/browser/extension/popup/index') ], + inject: [ path.join(__dirname, '../src/browser/extension/inject/index') ] + }, + output: { + path: path.join(__dirname, '../build/extension/js'), + filename: '[name].bundle.js', + chunkFilename: '[id].chunk.js' + }, + plugins: [ + new webpack.DefinePlugin({ + 'process.env': { + NODE_ENV: '"production"' + }, + __DEVELOPMENT__: false + }), + new webpack.optimize.DedupePlugin(), + new webpack.optimize.UglifyJsPlugin({ + comments: false, + compressor: { + warnings: false + } + }) + ], + resolve: { + extensions: ['', '.js'] + }, + module: { + loaders: [{ + test: /\.js$/, + loaders: ['babel'], + exclude: /node_modules/ + }, { + test: /\.css?$/, + loaders: ['style', 'raw'] + }] + } +}; \ No newline at end of file diff --git a/webpack/replace/JsonpMainTemplate.runtime.js b/webpack/replace/JsonpMainTemplate.runtime.js new file mode 100644 index 0000000..492be08 --- /dev/null +++ b/webpack/replace/JsonpMainTemplate.runtime.js @@ -0,0 +1,63 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php + Author Tobias Koppers @sokra +*/ +/*globals hotAddUpdateChunk parentHotUpdateCallback document XMLHttpRequest $require$ $hotChunkFilename$ $hotMainFilename$ */ +module.exports = function() { + function webpackHotUpdateCallback(chunkId, moreModules) { // eslint-disable-line no-unused-vars + hotAddUpdateChunk(chunkId, moreModules); + if(parentHotUpdateCallback) parentHotUpdateCallback(chunkId, moreModules); + } + + var context = this; + function evalCode(code, context) { + return (function() { return eval(code); }).call(context); + } + + context.hotDownloadUpdateChunk = function (chunkId) { // eslint-disable-line no-unused-vars + var src = __webpack_require__.p + "" + chunkId + "." + hotCurrentHash + ".hot-update.js"; + var request = new XMLHttpRequest(); + + request.onload = function() { + evalCode(this.responseText, context); + }; + request.open("get", src, true); + request.send(); + } + + function hotDownloadManifest(callback) { // eslint-disable-line no-unused-vars + if(typeof XMLHttpRequest === "undefined") + return callback(new Error("No browser support")); + try { + var request = new XMLHttpRequest(); + var requestPath = $require$.p + $hotMainFilename$; + request.open("GET", requestPath, true); + request.timeout = 10000; + request.send(null); + } catch(err) { + return callback(err); + } + request.onreadystatechange = function() { + if(request.readyState !== 4) return; + if(request.status === 0) { + // timeout + callback(new Error("Manifest request to " + requestPath + " timed out.")); + } else if(request.status === 404) { + // no update available + callback(); + } else if(request.status !== 200 && request.status !== 304) { + // other failure + callback(new Error("Manifest request to " + requestPath + " failed.")); + } else { + // success + try { + var update = JSON.parse(request.responseText); + } catch(e) { + callback(e); + return; + } + callback(null, update); + } + }; + } +}; diff --git a/webpack/replace/log-apply-result.js b/webpack/replace/log-apply-result.js new file mode 100644 index 0000000..83aaaef --- /dev/null +++ b/webpack/replace/log-apply-result.js @@ -0,0 +1,32 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php + Author Tobias Koppers @sokra +*/ +module.exports = function(updatedModules, renewedModules) { + var unacceptedModules = updatedModules.filter(function(moduleId) { + return renewedModules && renewedModules.indexOf(moduleId) < 0; + }); + + if(unacceptedModules.length > 0) { + console.warn("[HMR] The following modules couldn't be hot updated: (They would need a full reload!)"); + unacceptedModules.forEach(function(moduleId) { + console.warn("[HMR] - " + moduleId); + }); + + if(chrome && chrome.runtime && chrome.runtime.reload) { + console.warn("[HMR] extension reload"); + chrome.runtime.reload(); + } else { + console.warn("[HMR] Can't extension reload. not found chrome.runtime.reload."); + } + } + + if(!renewedModules || renewedModules.length === 0) { + console.log("[HMR] Nothing hot updated."); + } else { + console.log("[HMR] Updated modules:"); + renewedModules.forEach(function(moduleId) { + console.log("[HMR] - " + moduleId); + }); + } +};