diff --git a/app/containers/IpcContainer/index.js b/app/containers/IpcContainer/index.js index c0bca9ed3a..d5383fb5be 100644 --- a/app/containers/IpcContainer/index.js +++ b/app/containers/IpcContainer/index.js @@ -5,8 +5,9 @@ import { bindActionCreators } from 'redux'; import { ipcRenderer } from 'electron'; import * as PlayerActions from '../../actions/player'; import * as QueueActions from '../../actions/queue'; +import * as SettingsActions from '../../actions/settings'; -import { onNext, onPrevious, onPause, onPlayPause, onStop, onPlay, onSongChange} from '../../mpris'; +import { onNext, onPrevious, onPause, onPlayPause, onStop, onPlay, onSongChange, onSettings, onVolume, onSeek } from '../../mpris'; class IpcContainer extends React.Component { constructor(props) { @@ -21,6 +22,9 @@ class IpcContainer extends React.Component { ipcRenderer.on('playpause', event => onPlayPause(event, this.props.actions, this.props.player)); ipcRenderer.on('stop', event => onStop(event, this.props.actions)); ipcRenderer.on('play', event => onPlay(event, this.props.actions)); + ipcRenderer.on('settings', (event, data) => onSettings(event, data, this.props.actions)); + ipcRenderer.on('volume', (event, data) => onVolume(event, data, this.props.actions)); + ipcRenderer.on('seek', (event, data) => onSeek(event, data, this.props.actions)); } componentWillReceiveProps(nextProps){ @@ -44,7 +48,7 @@ function mapStateToProps(state) { function mapDispatchToProps(dispatch) { return { - actions: bindActionCreators(Object.assign({}, PlayerActions, QueueActions), dispatch) + actions: bindActionCreators(Object.assign({}, PlayerActions, QueueActions, SettingsActions), dispatch) }; } diff --git a/app/mpris.js b/app/mpris.js index 647598a28c..1f11cc96f5 100644 --- a/app/mpris.js +++ b/app/mpris.js @@ -24,6 +24,32 @@ export function onPlay(event, actions) { actions.startPlayback(); } +export function onSettings(event, data, actions) { + const key = Object.keys(data).pop(); + const value = Object.values(data).pop(); + + switch (typeof value) { + case 'boolean': + actions.setBooleanOption(key, value); + break; + case 'number': + actions.setNumberOption(key, value); + break; + case 'string': + default: + actions.setStringOption(key, value); + break; + } +} + +export function onVolume(event, data, actions) { + actions.updateVolume(data); +} + +export function onSeek(event, data, actions) { + actions.updateSeek(data); +} + export function onSongChange(song) { ipcRenderer.send('songChange', song); } diff --git a/package.json b/package.json index bbbe276910..6a251b9998 100644 --- a/package.json +++ b/package.json @@ -45,10 +45,13 @@ "dependencies": { "billboard-top-100": "^2.0.8", "bluebird": "^3.5.3", + "body-parser": "^1.18.3", "cheerio": "^1.0.0-rc.2", "electron-platform": "^1.2.0", "electron-store": "^2.0.0", "electron-timber": "^0.5.1", + "express": "^4.16.4", + "express-json-validator-middleware": "^1.2.3", "fast-levenshtein": "^2.0.6", "font-awesome": "^4.7.0", "get-artist-title": "^1.1.1", diff --git a/server/.eslintrc b/server/.eslintrc new file mode 100644 index 0000000000..abc95623e3 --- /dev/null +++ b/server/.eslintrc @@ -0,0 +1,6 @@ +{ + "extends": "../.eslintrc.js", + "env": { + "node": true + } +} \ No newline at end of file diff --git a/server/http/api/_schema.js b/server/http/api/_schema.js new file mode 100644 index 0000000000..5dcc00fb2b --- /dev/null +++ b/server/http/api/_schema.js @@ -0,0 +1,71 @@ +import settings from '../../../app/constants/settings'; + +export const RESTRICTED_SETTINGS = []; +export const READONLY_SETTINGS = []; + +export const getSettingsSchema = { + params: { + type: 'object', + required: ['option'], + properties: { + option: { + type: 'string', + enum: settings + .filter(({ name }) => !RESTRICTED_SETTINGS.includes(name)) + .map(({ name }) => name) + } + } + } +}; + +export const updateSettingsSchema = { + params: { + type: 'object', + required: ['option'], + properties: { + option: { + type: 'string', + enum: settings + .filter(({ name }) => !READONLY_SETTINGS.includes(name)) + .filter(({ name }) => !RESTRICTED_SETTINGS.includes(name)) + .map(({ name }) => name) + } + } + }, + body: { + type: 'object', + required: ['value'], + properties: { + value: { + type: ['string', 'boolean', 'number'] + } + } + } +}; + +export const volumeSchema = { + body: { + type: 'object', + required: ['value'], + properties: { + value: { + type: 'number', + minimum: 0, + maximum: 100 + } + } + } +}; + +export const seekSchema = { + body: { + type: 'object', + required: ['value'], + properties: { + value: { + type: 'number', + minimum: 0 + } + } + } +}; diff --git a/server/http/api/index.js b/server/http/api/index.js new file mode 100644 index 0000000000..072aea87f3 --- /dev/null +++ b/server/http/api/index.js @@ -0,0 +1,3 @@ +export * from './window'; +export * from './settings'; +export * from './player'; diff --git a/server/http/api/player.js b/server/http/api/player.js new file mode 100644 index 0000000000..f79294ffe1 --- /dev/null +++ b/server/http/api/player.js @@ -0,0 +1,63 @@ +import express from 'express'; +import { Validator } from 'express-json-validator-middleware'; + +import { + onNext, + onPrevious, + onPause, + onPlayPause, + onStop, + onPlay, + onVolume, + onSeek +} from '../../mpris'; +import { volumeSchema, seekSchema } from './_schema'; + +const { validate } = new Validator({ allErrors: true }); + +export function playerRouter() { + + const router = express.Router(); + + router.post('/next', (req, res) => { + onNext(); + res.send(); + }); + + router.post('/previous', (req, res) => { + onPrevious(); + res.send(); + }); + + router.post('/pause', (req, res) => { + onPause(); + res.send(); + }); + + router.post('/play-pause', (req, res) => { + onPlayPause(); + res.send(); + }); + + router.post('/stop', (req, res) => { + onStop(); + res.send(); + }); + + router.post('/play', (req, res) => { + onPlay(); + res.send(); + }); + + router.post('/volume', validate(volumeSchema), (req, res) => { + onVolume(req.body.value); + res.send(); + }); + + router.post('/seek', validate(seekSchema), (req, res) => { + onSeek(req.body.value); + res.send(); + }); + + return router; +} diff --git a/server/http/api/settings.js b/server/http/api/settings.js new file mode 100644 index 0000000000..9c4fbbc17c --- /dev/null +++ b/server/http/api/settings.js @@ -0,0 +1,45 @@ +import express from 'express'; +import { Validator } from 'express-json-validator-middleware'; + +import { onSettings } from '../../mpris'; +import { getOption, store } from '../../store'; +import { getSettingsSchema, updateSettingsSchema, RESTRICTED_SETTINGS } from './_schema'; +import settingsParams from '../../../app/constants/settings'; + +const { validate } = new Validator({ allErrors: true }); + +export function settingsRouter() { + + const router = express.Router(); + + router.get('/', (req, res) => { + const settings = store.get('settings'); + const filteredSettings = settingsParams + .filter(({ name }) => !RESTRICTED_SETTINGS.includes(name)) + .reduce((acc, item) => ({ + ...acc, + [item.name]: settings[item.name] || item.default + }), {}); + + + res.json(filteredSettings); + }); + + router + .route('/:option') + .get( + validate(getSettingsSchema), + (req, res) => { + res.send(getOption(req.params.option)); + } + ) + .post( + validate(updateSettingsSchema), + (req, res) => { + onSettings({ [req.params.option]: req.body.value }); + res.send(); + } + ); + + return router; +} diff --git a/server/http/api/window.js b/server/http/api/window.js new file mode 100644 index 0000000000..88db3ac65f --- /dev/null +++ b/server/http/api/window.js @@ -0,0 +1,25 @@ +import express from 'express'; +const { ipcMain } = require('electron'); + +export function windowRouter() { + + const router = express.Router(); + + router.post('/quit', (req, res) => { + ipcMain.emit('close'); + res.send(); + }); + + router.post('/maximize', (req, res) => { + ipcMain.emit('maximize'); + res.send(); + }); + + router.post('/minimize', (req, res) => { + ipcMain.emit('minimize'); + res.send(); + }); + + return router; +} + diff --git a/server/http/middlewares.js b/server/http/middlewares.js new file mode 100644 index 0000000000..4b4938deca --- /dev/null +++ b/server/http/middlewares.js @@ -0,0 +1,30 @@ +import { ValidationError } from 'express-json-validator-middleware'; + +const getValidationMessage = ({ validationErrors }) => { + if (validationErrors.params) { + const err = validationErrors.params.shift(); + return `${err.dataPath} ${err.message} ${err.params.allowedValues.toString()}`; + } else { + return `request body ${validationErrors.body.shift().message}`; + } +}; + +export function errorMiddleware(logger) { + return (err, req, res, next) => { + if (err instanceof ValidationError) { + const message = getValidationMessage(err); + + res.status(400).send(message); + next(); + } else { + logger.error(err); + res.status(500).send('Internal Server Error'); + } + }; +} + +export function notFoundMiddleware() { + return (req, res) => { + res.status(404).send('Not Found'); + }; +} diff --git a/server/http/server.js b/server/http/server.js new file mode 100644 index 0000000000..4d7703b8cb --- /dev/null +++ b/server/http/server.js @@ -0,0 +1,36 @@ +import Logger from 'electron-timber'; +import express from 'express'; +import bodyParser from 'body-parser'; + +import { windowRouter, playerRouter, settingsRouter } from './api'; +import { errorMiddleware, notFoundMiddleware } from './middlewares'; + +function runHttpServer({ + log, + port = 3000, + host = '0.0.0.0', + prefix = '/nuclear' +}) { + const app = express(); + const logger = log + ? Logger.create({ name: 'http api' }) + : { log: () => {}, error: () => {} }; + + return app + .use(bodyParser.urlencoded({ extended: false })) + .use(bodyParser.json()) + .use(`${prefix}/window`, windowRouter()) + .use(`${prefix}/player`, playerRouter()) + .use(`${prefix}/settings`, settingsRouter()) + .use(notFoundMiddleware()) + .use(errorMiddleware(logger)) + .listen(port, host, err => { + if (err) { + logger.error(err); + } else { + logger.log(`nuclear api available on port ${port}`); + } + }); +} + +module.exports = runHttpServer; diff --git a/server/main.dev.js b/server/main.dev.js index f01fdd389c..e0a90a0b28 100644 --- a/server/main.dev.js +++ b/server/main.dev.js @@ -1,8 +1,8 @@ -const { - default: installExtension, - REACT_DEVELOPER_TOOLS, - REDUX_DEVTOOLS -} = require('electron-devtools-installer'); +// const { +// default: installExtension, +// REACT_DEVELOPER_TOOLS, +// REDUX_DEVTOOLS +// } = require('electron-devtools-installer'); const { app, ipcMain, @@ -15,7 +15,9 @@ const platform = require('electron-platform'); const path = require('path'); const url = require('url'); const getOption = require('./store').getOption; +const runHttpServer = require('./http/server'); +let httpServer; let win; let tray; let icon = nativeImage.createFromPath( @@ -84,7 +86,7 @@ function createWindow () { { label: 'Quit', type: 'normal', - click: (menuItem, browserWindow, event) => { + click: () => { app.quit(); } } @@ -119,8 +121,12 @@ function createWindow () { }); } -app.on('ready', createWindow); +app.on('ready', () => { + createWindow(); + httpServer = runHttpServer({ log: true }); +}); app.on('window-all-closed', () => { + httpServer.close(); app.quit(); }); diff --git a/server/main.dev.linux.js b/server/main.dev.linux.js index cb7f811c84..b72e27e14c 100644 --- a/server/main.dev.linux.js +++ b/server/main.dev.linux.js @@ -4,9 +4,8 @@ const { app, ipcMain, nativeImage, BrowserWindow, Menu, Tray } = require('electr const platform = require('electron-platform'); const path = require('path'); const url = require('url'); -const mpris = require('./mpris'); const getOption = require('./store').getOption; -// var Player; +const runHttpServer = require('./http/server'); // GNU/Linux-specific if (!platform.isDarwin && !platform.isWin32) { @@ -14,7 +13,7 @@ if (!platform.isDarwin && !platform.isWin32) { } let win; -let player; +let httpServer; let tray; let icon = nativeImage.createFromPath(path.resolve(__dirname, 'resources', 'media', 'icon.png')); @@ -76,7 +75,7 @@ function createWindow() { const trayMenu = Menu.buildFromTemplate([ {label: 'Quit', type: 'normal', click: - (menuItem, browserWindow, event) => { + () => { app.quit(); } } @@ -158,9 +157,13 @@ function createWindow() { } } -app.on('ready', createWindow); +app.on('ready', () => { + createWindow(); + httpServer = runHttpServer({ log: true }); +}); app.on('window-all-closed', () => { logger.log('All windows closed, quitting'); + httpServer.close(); app.quit(); }); diff --git a/server/main.prod.js b/server/main.prod.js index 2f29900daf..6587874e20 100644 --- a/server/main.prod.js +++ b/server/main.prod.js @@ -4,7 +4,9 @@ const platform = require('electron-platform'); const path = require('path'); const url = require('url'); const getOption = require('./store').getOption; +const runHttpServer = require('./http/server'); +let httpServer; let win; let tray; let icon = nativeImage.createFromPath(path.resolve(__dirname, 'resources', 'media', 'icon.png')); @@ -53,7 +55,7 @@ function createWindow() { const trayMenu = Menu.buildFromTemplate([ {label: 'Quit', type: 'normal', click: - (menuItem, browserWindow, event) => { + () => { app.quit(); } } @@ -84,8 +86,12 @@ function createWindow() { }); } -app.on('ready', createWindow); +app.on('ready', () => { + createWindow(); + httpServer = runHttpServer({ port: 8080 }); +}); app.on('window-all-closed', () => { + httpServer.close(); app.quit(); }); diff --git a/server/main.prod.linux.js b/server/main.prod.linux.js index 331ef4ed59..63fffb5199 100644 --- a/server/main.prod.linux.js +++ b/server/main.prod.linux.js @@ -3,17 +3,18 @@ const { app, ipcMain, nativeImage, BrowserWindow, Menu, Tray } = require('electr const platform = require('electron-platform'); const path = require('path'); const url = require('url'); -const mpris = require('./mpris'); +// const mpris = require('./mpris'); const getOption = require('./store').getOption; +const runHttpServer = require('./http/server'); +let httpServer; let win; -let player; let tray; let icon = nativeImage.createFromPath(path.resolve(__dirname, 'resources', 'media', 'icon.png')); -function changeWindowTitle(artist, title) { - win.setTitle(`${artist} - ${title} - nuclear music player`); -} +// function changeWindowTitle(artist, title) { +// win.setTitle(`${artist} - ${title} - nuclear music player`); +// } function createWindow() { win = new BrowserWindow({ @@ -55,7 +56,7 @@ function createWindow() { const trayMenu = Menu.buildFromTemplate([ {label: 'Quit', type: 'normal', click: - (menuItem, browserWindow, event) => { + () => { app.quit(); } } @@ -79,8 +80,12 @@ function createWindow() { }); } -app.on('ready', createWindow); +app.on('ready', () => { + createWindow(); + httpServer = runHttpServer({ port: 8080 }); +}); app.on('window-all-closed', () => { + httpServer.close(); app.quit(); }); diff --git a/server/mpris.js b/server/mpris.js index 60a3bb8863..80f1167006 100644 --- a/server/mpris.js +++ b/server/mpris.js @@ -1,11 +1,11 @@ import logger from 'electron-timber'; import { ipcMain } from 'electron'; -var rendererWindow = null; +let rendererWindow = null; -var events = ['raise', 'quit', 'next', 'previous', 'pause', 'playpause', 'stop', 'play', 'seek', 'position', 'open', 'volume']; +// const events = ['raise', 'quit', 'next', 'previous', 'pause', 'playpause', 'stop', 'play', 'seek', 'position', 'open', 'volume', 'settings']; -ipcMain.on('started', (event, arg) => { +ipcMain.on('started', event => { logger.log('Renderer process started and registered.'); rendererWindow = event.sender; }); @@ -35,11 +35,26 @@ function onPlay() { rendererWindow.send('play'); } +function onVolume(volume) { + rendererWindow.send('volume', volume); +} + +function onSeek(position) { + rendererWindow.send('seek', position); +} + +function onSettings(settings) { + rendererWindow.send('settings', settings); +} + module.exports = { onNext, onPrevious, onPause, onPlayPause, onStop, - onPlay -} + onPlay, + onSettings, + onVolume, + onSeek +}; diff --git a/server/store.js b/server/store.js index 7d3a496305..d881e80798 100644 --- a/server/store.js +++ b/server/store.js @@ -1,4 +1,3 @@ -import { app } from 'electron'; import logger from 'electron-timber'; import electronStore from 'electron-store'; import _ from 'lodash'; @@ -17,4 +16,4 @@ function getOption (key) { return value; } -export { getOption }; +export { getOption, store }; diff --git a/webpack.config.electron.js b/webpack.config.electron.js index 5c8b785c0f..0cecb47f68 100644 --- a/webpack.config.electron.js +++ b/webpack.config.electron.js @@ -1,9 +1,9 @@ +/* eslint-env node */ const webpack = require('webpack'); -const path = require('path'); const HappyPack = require('happypack'); module.exports = env => { - let entry = env && env.LINUX ? './server/main.dev.linux.js' : './server/main.dev.js'; + const entry = env && env.LINUX ? './server/main.dev.linux.js' : './server/main.dev.js'; return { entry: entry, @@ -12,6 +12,9 @@ module.exports = env => { filename: 'bundle.electron.js' }, mode: 'development', + stats: { + warningsFilter: 'express' + }, module: { rules: [ { @@ -25,11 +28,11 @@ module.exports = env => { new webpack.NamedModulesPlugin(), new HappyPack({ id: 'jsx', - loaders: [ 'babel-loader' ] - }), + loaders: ['babel-loader'] + }) ], node: { - fs: "empty", + fs: 'empty', __dirname: false, __filename: false }, diff --git a/webpack.config.electron.prod.js b/webpack.config.electron.prod.js index dfd93ba025..ddbbcd5c30 100644 --- a/webpack.config.electron.prod.js +++ b/webpack.config.electron.prod.js @@ -1,5 +1,4 @@ -const webpack = require('webpack'); -const path = require('path'); +/* eslint-env node */ const HappyPack = require('happypack'); module.exports = env => { @@ -15,6 +14,9 @@ module.exports = env => { optimization: { namedModules: true }, + stats: { + warningsFilter: 'express' + }, module: { rules: [ { @@ -26,14 +28,14 @@ module.exports = env => { plugins: [ new HappyPack({ id: 'jsx', - loaders: [ 'babel-loader' ] + loaders: ['babel-loader'] }) ], externals: { dbus: 'dbus' }, node: { - fs: "empty", + fs: 'empty', __dirname: false, __filename: false },