├── .gitignore ├── .nvmrc ├── LICENSE ├── README.md ├── app ├── index.html ├── online-status.html ├── scripts │ ├── index.js │ └── webview-helper.js └── styles │ └── default.css ├── control_app ├── index.html ├── scripts │ ├── AppController.js │ ├── CreateDashboardDialog.html │ ├── DashboardController.js │ └── app.js └── styles │ └── default.css ├── main.js ├── package.json ├── server ├── Config.js ├── OnlineStatusManager.js └── Server.js └── site ├── dashboard-control-app.png ├── dashboard-webview-app.png ├── screenshots-overview.png └── screenshots.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | 13 | # Directory for instrumented libs generated by jscoverage/JSCover 14 | lib-cov 15 | 16 | # Coverage directory used by tools like istanbul 17 | coverage 18 | 19 | # nyc test coverage 20 | .nyc_output 21 | 22 | # node-waf configuration 23 | .lock-wscript 24 | 25 | # Compiled binary addons (http://nodejs.org/api/addons.html) 26 | build/Release 27 | 28 | # Dependency directories 29 | node_modules 30 | 31 | # Optional npm cache directory 32 | .npm 33 | 34 | ### JetBrains template 35 | .idea 36 | *.iml 37 | 38 | ## File-based project format: 39 | *.iws 40 | 41 | ## Plugin-specific files: 42 | 43 | # IntelliJ 44 | /out/ 45 | 46 | # mpeltonen/sbt-idea plugin 47 | .idea_modules/ 48 | 49 | # JIRA plugin 50 | atlassian-ide-plugin.xml 51 | 52 | # Crashlytics plugin (for Android Studio and IntelliJ) 53 | com_crashlytics_export_strings.xml 54 | crashlytics.properties 55 | crashlytics-build.properties 56 | fabric.properties 57 | 58 | # App 59 | /.cache 60 | 61 | # Unsorted 62 | 63 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 4.4 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # electron-remote-dashboard 2 | Remote dashboard with a control app 3 | 4 | This app starts an Electron based window showing a controlled web view. Additionally, a web server provides 5 | a control interface configuring the displayed sites/dashboards. 6 | 7 | ## Idea 8 | Imagine a hand free screen (like a TV) on which you want to show multiple sites/dashboards depending on specific 9 | constraints like time, demand or even an event. 10 | 11 | Ideally, you do not want to connect to the corresponding machine via VNC only for switching the websites. Instead of 12 | doing this, you would prefer a nice web interface for managing and controlling the display. 13 | 14 | Welcome! 15 | 16 | ![Overview](site/screenshots-overview.png) 17 | 18 | [See more](site/screenshots.md) 19 | 20 | ## Features 21 | - session data are persisted between restarts 22 | - a complete "hands free" mode: after the first configuration, all configurations can be applied by the control interface remotely 23 | - sites/dashboards can be created and removed 24 | - the active dashboard can be switched 25 | - simultaneous control app interface users (note: there is currently no security applied) 26 | - the control app provides live feedback between all connected control app users (new active dashboard, online status, ...) 27 | 28 | ## How to download? 29 | At the moment, there is **no packaging or building process available**. Sorry, no downloads. 30 | 31 | ## How to use 32 | 1. Clone the git repository and install the node dependencies: `npm install`. Please be ensure having at least NodeJS 4.4 (actually, 4.x should be fine). 33 | 2. Start the app with `npm start`. 34 | 35 | ## Configuration and data 36 | * The dashboard app will load and store information at `$userDir/.electron-remote-dashboard/.session.json`. 37 | * The dashboard app will load information at `$userDir/.electron-remote-dashboard/config.json`. 38 | 39 | ### Rules 40 | * If the file `.session.json` is available, it will be preferred. 41 | * The file `config.json` will be read only. 42 | * A configuration file can contain three entries: `dashboards`, `server` and `window`. 43 | 44 | ### Example config 45 | ```json 46 | { 47 | "dashboards": { 48 | "active": "github-knalli", 49 | "items": [ 50 | { 51 | "id": "github-knalli", 52 | "display": "GitHub knalli", 53 | "url": "https://github.com/knalli", 54 | "description": "GitHub profile of knalli" 55 | } 56 | ] 57 | } 58 | ``` 59 | 60 | ### dashboard options 61 | `active` is the current selected id of a dashboard. No value means no default, and an invalid one will be removed on startup automatically. 62 | 63 | `items` is an array of `dashboard`. 64 | 65 | `items[].dashboard` contains 66 | 67 | - `id` is something unique like `github` or `dashboard1`. 68 | - `display` is the title/display. It must not be unique, also it should be. 69 | - `url` is the actual URL of the site. Can be any valid URL a browser/webview can display. 70 | - `description` is an optional field only for the control app. 71 | 72 | ### server options 73 | `port` is the control webserver's port. Default is `33333`. 74 | 75 | ### window options 76 | `height` and `width` are the window dimensions. Defaults are `768` and `1024`. 77 | 78 | `fullscreen` controls wether the window should be displayed in full screen mode. 79 | Default is `undefined` (which results into `false` mentioned by the Electron docs). 80 | 81 | #### 82 | ## License 83 | Copyright 2016 by Jan Philipp. Licensed under MIT. 84 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Dashboard 7 | 8 | 9 |
10 |
11 |

Try the remote controller available @ #

13 |
14 |
15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/online-status.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 18 | -------------------------------------------------------------------------------- /app/scripts/index.js: -------------------------------------------------------------------------------- 1 | { 2 | const ipcRenderer = require('electron').ipcRenderer; 3 | 4 | ipcRenderer.on('server-started', (event, message) => { 5 | const wrapper = document.getElementsByClassName('available-url-wrapper')[0]; 6 | if (wrapper) { 7 | const link = wrapper.getElementsByClassName('available-url')[0]; 8 | link.href = message.url; 9 | link.innerHTML = message.url; 10 | wrapper.style.display = 'inline'; 11 | } 12 | }); 13 | 14 | // Incoming request opening an url 15 | ipcRenderer.on('open-url', (event, url) => { 16 | document.getElementById('splashscreen').style.display = 'none'; 17 | document.getElementById('webview').src = url; 18 | document.getElementById('webview').style.display = 'flex'; 19 | 20 | }); 21 | 22 | // Incoming request making a screenshot 23 | ipcRenderer.on('screenshot-request', () => { 24 | console.log('Requesting screenshot...'); 25 | var screenshot = require('electron-screenshot'); 26 | const filename = '.cache/current-view.png'; 27 | screenshot({filename : filename, delay : 2000}, () => { 28 | console.log('Screenshot taken'); 29 | ipcRenderer.emit('screenshot-response', filename); 30 | }); 31 | }); 32 | 33 | } 34 | -------------------------------------------------------------------------------- /app/scripts/webview-helper.js: -------------------------------------------------------------------------------- 1 | { 2 | const onLoad = () => { 3 | const ipcRenderer = require('electron').ipcRenderer; 4 | 5 | const webview = document.getElementById('webview'); 6 | 7 | const getWebviewData = (error = {}) => { 8 | return { 9 | url : webview.getURL(), 10 | title : webview.getTitle(), 11 | statesLoading : webview.isLoading(), 12 | statesCrashed : webview.isCrashed(), 13 | statesWaitingForResponse : webview.isWaitingForResponse(), 14 | statesFailed : error.errorCode ? true : false, 15 | errorDescription : error.errorDescription, 16 | }; 17 | }; 18 | 19 | webview.addEventListener('did-finish-load', () => { 20 | const data = getWebviewData(); 21 | ipcRenderer.send('webview-refreshed', data); 22 | }); 23 | webview.addEventListener('did-start-loading', () => { 24 | const data = getWebviewData(); 25 | ipcRenderer.send('webview-refreshed', data); 26 | }); 27 | webview.addEventListener('did-stop-loading', () => { 28 | const data = getWebviewData(); 29 | ipcRenderer.send('webview-refreshed', data); 30 | }); 31 | webview.addEventListener('page-title-updated', () => { 32 | const data = getWebviewData(); 33 | ipcRenderer.send('webview-refreshed', data); 34 | }); 35 | webview.addEventListener('did-fail-load', (error) => { 36 | const data = getWebviewData(error); 37 | ipcRenderer.send('webview-refreshed', data); 38 | }); 39 | webview.addEventListener('page-favicon-updated', (favicons) => { 40 | ipcRenderer.send('webview-favicons-refreshed', favicons); 41 | }); 42 | webview.addEventListener('did-get-response-details', (response) => { 43 | if (response.resourceType === 'mainFrame') { 44 | ipcRenderer.send('webview-response-refreshed', response); 45 | } 46 | }); 47 | 48 | }; 49 | 50 | onLoad(); 51 | } 52 | -------------------------------------------------------------------------------- /app/styles/default.css: -------------------------------------------------------------------------------- 1 | body > main#splashscreen { 2 | box-sizing: border-box; 3 | } 4 | 5 | body > main#splashscreen *, html > body > main#splashscreen *:before, html > body > main#splashscreen *:after { 6 | box-sizing: inherit; 7 | } 8 | 9 | html > body { 10 | background-color: tan; 11 | font-family: -apple-system, "Helvetica Neue", "Lucida Grande"; 12 | color: black; 13 | } 14 | 15 | html > body > main#splashscreen article.welcome-teaser { 16 | box-sizing: border-box; 17 | text-align: center; 18 | font-size: 1.4em; 19 | margin-top: 30%; 20 | } 21 | 22 | html > body > main#splashscreen article.welcome-teaser h1 em { 23 | color: brown; 24 | } 25 | 26 | html > body > main#splashscreen article.welcome-teaser .available-url-wrapper { 27 | display: none; 28 | } 29 | 30 | html > body > main#splashscreen article.welcome-teaser .available-url { 31 | color: darkslateblue; 32 | text-decoration: underline; 33 | } 34 | 35 | html > body > webview { 36 | position:absolute; 37 | width:100%; 38 | height:100%; 39 | display:none; 40 | top: 0; 41 | bottom: 0; 42 | left: 0; 43 | right: 0; 44 | } 45 | -------------------------------------------------------------------------------- /control_app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Dashboard Control Unit 6 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 |
26 |

27 | Dashboard Control Unit 28 |

29 | 30 | 34 | Connected 35 | Not connected 36 | 37 | 42 | Online 43 | Offline 44 | 45 | 49 | Unknown 50 | 51 | 56 | View on GitHub 57 | 58 |
59 |
60 | 61 |
62 | 63 | 64 | 65 | 66 | 70 | check_circle 73 | error 76 | {{ (dashboardCtrl.webview.title || dashboardCtrl.activeDashboard) }} 77 | 78 | 80 | HTTP response: {{ dashboardCtrl.webview.lastResponse.httpResponseCode }} 81 | 82 | 83 |
84 | 85 |
86 |
87 |
88 | 89 | Edit 90 | Reload 91 | 95 | remove_circle Delete 96 | 97 | 98 |
99 |
100 |
101 | 105 | add 106 | 107 | 111 | launch 112 | 113 | 115 | 117 | {{ item.display }} 118 | 119 |
120 | {{ item.description }} 121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 | 129 | -------------------------------------------------------------------------------- /control_app/scripts/AppController.js: -------------------------------------------------------------------------------- 1 | angular.module('app') 2 | .controller('AppController', function () { 3 | }); -------------------------------------------------------------------------------- /control_app/scripts/CreateDashboardDialog.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |
5 |

Create dashboard

6 |

Edit dashboard

7 | 8 | 9 | close 10 | 11 |
12 |
13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 |
35 |
36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 |
45 |
46 | 47 |
48 | 49 | 50 | 51 | Cancel 52 | 53 | 54 | Create 55 | 56 | 57 | Edit 58 | 59 | 60 |
61 |
-------------------------------------------------------------------------------- /control_app/scripts/DashboardController.js: -------------------------------------------------------------------------------- 1 | angular.module('app') 2 | .controller('DashboardController', function ($rootScope, $mdDialog, $mdMedia, $mdToast, $scope, $sce) { 3 | 4 | var me = this; 5 | 6 | this.states = { 7 | online: false 8 | }; 9 | 10 | this.webview = { 11 | loading: false, 12 | failed: false, 13 | title: '', 14 | url: null, 15 | fullscreen: false 16 | }; 17 | 18 | this.activeDashboard = ''; 19 | this.pendingDashboard = ''; 20 | this.items = []; 21 | 22 | var socket = io(undefined, { 23 | timeout: 5000 24 | }); 25 | socket.on('connect', function () { 26 | $scope.$apply(function () { 27 | $rootScope.$emit('server-connected'); 28 | }); 29 | }); 30 | socket.on('disconnect', function () { 31 | $scope.$apply(function () { 32 | $rootScope.$emit('server-disconnected'); 33 | }); 34 | }); 35 | socket.on('error', function () { 36 | $scope.$apply(function () { 37 | $rootScope.$emit('server-disconnected'); 38 | }); 39 | }); 40 | socket.on('reconnect_error', function () { 41 | $scope.$apply(function () { 42 | $rootScope.$emit('server-disconnected'); 43 | }); 44 | }); 45 | socket.on('dashboard-changed', function (dashboard) { 46 | $scope.$apply(function () { 47 | me.activeDashboard = dashboard.id; 48 | me.pendingDashboard = dashboard.id; 49 | }); 50 | }); 51 | socket.on('dashboard-updated', function (dashboard) { 52 | $scope.$apply(function () { 53 | for (var i = 0; i < me.items.length; i++) { 54 | if (me.items[i].id === dashboard.id) { 55 | me.items[i] = dashboard; 56 | } 57 | } 58 | }); 59 | }); 60 | socket.on('dashboards-updated', function (dashboards) { 61 | $scope.$apply(function () { 62 | me.activeDashboard = dashboards.active; 63 | me.pendingDashboard = dashboards.active; 64 | me.items = dashboards.items; 65 | }); 66 | }); 67 | socket.on('states-updated', function (args) { 68 | $scope.$apply(function () { 69 | if (typeof args.online !== 'undefined') { 70 | me.states.online = args.online; 71 | } 72 | }); 73 | }); 74 | socket.on('view-updated', function (data) { 75 | console.log('view updated', data); 76 | $scope.$apply(function () { 77 | var webview = me.webview; 78 | webview.favicon = data.favicon; 79 | webview.failed = data.statesFailed || (data.lastResponse && data.lastResponse.httpResponseCode >= 400); 80 | webview.loading = data.statesLoading; 81 | webview.loadingShow = data.statesLoading === true; 82 | webview.title = data.title; 83 | webview.description = $sce.trustAsHtml(data.description); 84 | webview.url = data.url; 85 | webview.lastResponse = data.lastResponse; 86 | }); 87 | }); 88 | 89 | $rootScope.$on('server-connected', function () { 90 | me.states.connected = true; 91 | }); 92 | 93 | $rootScope.$on('server-disconnected', function () { 94 | me.states.connected = false; 95 | }); 96 | 97 | $rootScope.$on('server-connected', function () { 98 | socket.emit('list-dashboards', function (dashboards) { 99 | $scope.$apply(function () { 100 | me.activeDashboard = dashboards.active; 101 | me.pendingDashboard = dashboards.active; 102 | me.items = dashboards.items; 103 | }); 104 | }); 105 | }); 106 | 107 | this.applyActive = function (dashboardId) { 108 | socket.emit('change-dashboard', dashboardId, function (result) { 109 | $scope.$apply(function () { 110 | if (!result.success) { 111 | me.pendingDashboard = me.activeDashboard; 112 | $mdDialog.show( 113 | $mdDialog.alert() 114 | .title('Failed') 115 | .textContent(result.message || 'Could not apply the dashboard.') 116 | .ok('Dismiss') 117 | ); 118 | } else { 119 | me.activeDashboard = dashboardId; 120 | $mdToast.show( 121 | $mdToast.simple() 122 | .textContent('Changed.') 123 | .position('bottom right') 124 | .hideDelay(2000) 125 | ); 126 | 127 | // hack 128 | setTimeout(function () { 129 | var img = document.getElementById('tabPreview'); 130 | img.src = img.src; // reload hack 131 | }, 2000); 132 | } 133 | }); 134 | }); 135 | }; 136 | 137 | this.showCreateDashboardDialog = function (ev) { 138 | var useFullScreen = ($mdMedia('sm') || $mdMedia('xs')) && $scope.customFullscreen; 139 | $mdDialog.show({ 140 | controller: function ($scope, $mdDialog) { 141 | $scope.hide = function () { 142 | $mdDialog.hide(); 143 | }; 144 | $scope.cancel = function () { 145 | $mdDialog.cancel(); 146 | }; 147 | $scope.answer = function (answer) { 148 | $mdDialog.hide(answer); 149 | }; 150 | }, 151 | templateUrl: 'scripts/CreateDashboardDialog.html', 152 | parent: angular.element(document.body), 153 | targetEvent: ev, 154 | clickOutsideToClose: true, 155 | fullscreen: useFullScreen 156 | }) 157 | .then(function (dashboard) { 158 | if (dashboard) { 159 | me.createDashboard(dashboard); 160 | } 161 | }, function () { 162 | // TODO 163 | }); 164 | }; 165 | 166 | this.showRemoveDashboardDialog = function (ev, dashboardId) { 167 | var confirm = $mdDialog.confirm() 168 | .title('Would you like to delete this dashboard?') 169 | .ariaLabel('Yes') 170 | .targetEvent(ev) 171 | .ok('Yes, delete.') 172 | .cancel('Cancel'); 173 | $mdDialog.show(confirm).then(function () { 174 | me.removeDashboard(dashboardId); 175 | $scope.status = 'You decided to get rid of your debt.'; 176 | }, function () { 177 | // FIXME 178 | }); 179 | }; 180 | 181 | this.showEditDashboardDialog = function (ev, dashboardId) { 182 | 183 | var dashboard; 184 | for (var i = 0; i < me.items.length; i++) { 185 | if (me.items[i].id === dashboardId) { 186 | dashboard = me.items[i]; 187 | break; 188 | } 189 | } 190 | 191 | if (!dashboard) { 192 | return; 193 | } 194 | 195 | var useFullScreen = ($mdMedia('sm') || $mdMedia('xs')) && $scope.customFullscreen; 196 | $mdDialog.show({ 197 | controller: function ($scope, $mdDialog) { 198 | $scope.hide = function () { 199 | $mdDialog.hide(); 200 | }; 201 | $scope.cancel = function () { 202 | $mdDialog.cancel(); 203 | }; 204 | $scope.answer = function (answer) { 205 | $mdDialog.hide(answer); 206 | }; 207 | $scope.dashboard = angular.copy(dashboard); 208 | $scope.editMode = true; 209 | }, 210 | templateUrl: 'scripts/CreateDashboardDialog.html', 211 | parent: angular.element(document.body), 212 | targetEvent: ev, 213 | clickOutsideToClose: true, 214 | fullscreen: useFullScreen 215 | }) 216 | .then(function (dashboard) { 217 | if (dashboard) { 218 | me.updateDashboard(dashboard); 219 | } 220 | }, function () { 221 | // TODO 222 | }); 223 | }; 224 | 225 | this.createDashboard = function (dashboard) { 226 | socket.emit('create-dashboard', dashboard, function (result) { 227 | $scope.$apply(function () { 228 | if (!result.success) { 229 | $mdDialog.show( 230 | $mdDialog.alert() 231 | .title('Failed') 232 | .textContent(result.message || 'Could not create the dashboard.') 233 | .ok('Dismiss') 234 | ); 235 | } else { 236 | $mdToast.show( 237 | $mdToast.simple() 238 | .textContent('Created.') 239 | .position('bottom right') 240 | .hideDelay(2000) 241 | ); 242 | } 243 | }); 244 | }); 245 | }; 246 | 247 | this.removeDashboard = function (dashboardId) { 248 | socket.emit('remove-dashboard', dashboardId, function (result) { 249 | $scope.$apply(function () { 250 | if (!result.success) { 251 | $mdDialog.show( 252 | $mdDialog.alert() 253 | .title('Failed') 254 | .textContent(result.message || 'Could not remove the dashboard.') 255 | .ok('Dismiss') 256 | ); 257 | } else { 258 | $mdToast.show( 259 | $mdToast.simple() 260 | .textContent('Removed.') 261 | .position('bottom right') 262 | .hideDelay(2000) 263 | ); 264 | } 265 | }); 266 | }); 267 | }; 268 | 269 | this.toggleFullscreen = function () { 270 | socket.emit('toggle-fullscreen', function (result) { 271 | $scope.$apply(function () { 272 | if (!result.success) { 273 | $mdDialog.show( 274 | $mdDialog.alert() 275 | .title('Failed') 276 | .textContent(result.message || 'Could not switch fullscreen.') 277 | .ok('Dismiss') 278 | ); 279 | } else { 280 | $mdToast.show( 281 | $mdToast.simple() 282 | .textContent('switch fullscreen.') 283 | .position('bottom right') 284 | .hideDelay(2000) 285 | ); 286 | } 287 | }); 288 | }); 289 | }; 290 | 291 | this.updateDashboard = function (dashboard) { 292 | socket.emit('update-dashboard', dashboard, function (result) { 293 | $scope.$apply(function () { 294 | if (!result.success) { 295 | $mdDialog.show( 296 | $mdDialog.alert() 297 | .title('Failed') 298 | .textContent(result.message || 'Could not edit the dashboard.') 299 | .ok('Dismiss') 300 | ); 301 | } else { 302 | $mdToast.show( 303 | $mdToast.simple() 304 | .textContent('edit complete.') 305 | .position('bottom right') 306 | .hideDelay(2000) 307 | ); 308 | } 309 | }); 310 | }); 311 | }; 312 | 313 | this.reload = function () { 314 | me.applyActive(me.activeDashboard); 315 | }; 316 | }); 317 | -------------------------------------------------------------------------------- /control_app/scripts/app.js: -------------------------------------------------------------------------------- 1 | angular.module('app', [ 2 | 'ng', 3 | 'ngMaterial' 4 | ]); -------------------------------------------------------------------------------- /control_app/styles/default.css: -------------------------------------------------------------------------------- 1 | ._md-label .description { 2 | color: saddlebrown; 3 | font-size: 0.9em; 4 | } 5 | 6 | md-card-title-media img.preview { 7 | max-height: 100%; 8 | max-width: 100%; 9 | } 10 | 11 | .md-headline-with-spinner md-progress-circular + span { 12 | padding-left: 26px; 13 | } 14 | 15 | md-card.md-warn { 16 | background-color: #ffbdc6; 17 | } 18 | 19 | .md-button.md-raised.md-hue-gh { 20 | color: rgb(255,255,255); 21 | background-color: rgb(47, 44, 43); 22 | } 23 | 24 | .md-button.md-fullscreen{ 25 | background-color: rgb(0, 0, 0); 26 | } 27 | 28 | .material-icons.md-light { 29 | color: rgba(255, 255, 255, 1); 30 | } 31 | 32 | .md-fab.md-mini { 33 | background-color: rgb(0, 0, 0); 34 | } 35 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | const {BrowserWindow, app, ipcMain} = require('electron'); 2 | const Server = require('./server/Server'); 3 | const OnlineStatusManager = require('./server/OnlineStatusManager'); 4 | const Config = require('./server/Config'); 5 | 6 | // Load config manager (can persist and reload) 7 | const config = new Config(__dirname); 8 | 9 | const server = new Server(config); 10 | 11 | // Online-status tracking 12 | new OnlineStatusManager(__dirname).on('changed', (isOnline) => server.emit('state-changed', 'online', isOnline)); 13 | 14 | // Keep a global reference of the window object, if you don't, the window will 15 | // be closed automatically when the JavaScript object is garbage collected. 16 | let mainWindow; 17 | 18 | const createWindow = () => { 19 | // Create the browser window. 20 | mainWindow = new BrowserWindow({ 21 | width : config.get('window', 'width', 1024), 22 | height : config.get('window', 'height', 768), 23 | fullscreen : config.get('window', 'fullscreen'), 24 | titleBarStyle : config.get('window', 'titleBarStyle', 'hidden') 25 | }); 26 | 27 | // and load the index.html of the app. 28 | mainWindow.loadURL(`file://${__dirname}/app/index.html`); 29 | 30 | // Open the DevTools. 31 | if (config.get('window', 'devtools')) { 32 | mainWindow.webContents.openDevTools(); 33 | } 34 | 35 | mainWindow.webContents.on('did-finish-load', () => { 36 | mainWindow.webContents.send('server-started', {url : server.getControlServerUrl()}); 37 | 38 | // forward webview event metrics 39 | ipcMain.on('webview-refreshed', (event, data) => server.emit('view-updated', data)); 40 | ipcMain.on('webview-favicons-refreshed', (event, data) => server.emit('view-favicons-updated', data)); 41 | ipcMain.on('webview-response-refreshed', (event, data) => server.emit('view-response-updated', data)); 42 | }); 43 | 44 | // Emitted when the window is closed. 45 | mainWindow.on('closed', () => { 46 | // Dereference the window object, usually you would store windows 47 | // in an array if your app supports multi windows, this is the time 48 | // when you should delete the corresponding element. 49 | mainWindow = null; 50 | }); 51 | }; 52 | 53 | // This method will be called when Electron has finished 54 | // initialization and is ready to create browser windows. 55 | // Some APIs can only be used after this event occurs. 56 | app.on('ready', createWindow); 57 | 58 | // Quit when all windows are closed. 59 | app.on('window-all-closed', () => { 60 | // On OS X it is common for applications and their menu bar 61 | // to stay active until the user quits explicitly with Cmd + Q 62 | if (process.platform !== 'darwin') { 63 | app.quit(); 64 | } 65 | }); 66 | 67 | app.on('activate', () => { 68 | // On OS X it's common to re-create a window in the app when the 69 | // dock icon is clicked and there are no other windows open. 70 | if (mainWindow === null) { 71 | createWindow(); 72 | } 73 | }); 74 | 75 | // In this file you can include the rest of your app's specific main process 76 | // code. You can also put them in separate files and require them here. 77 | 78 | server.on('view-set-url', ({url}) => { 79 | //mainWindow.loadURL(url); 80 | mainWindow.webContents.send('open-url', url); 81 | mainWindow.webContents.send('screenshot-request'); 82 | }); 83 | 84 | server.on('server-started', ({portStarted}) => { 85 | console.log(`Server started @ ${portStarted}`); 86 | }); 87 | 88 | server.on('dashboard-updated', (dashboards) => { 89 | //app.clearRecentDocuments(); 90 | //app.addRecentDocument('/Users/USERNAME/Desktop/work.type'); 91 | }); 92 | 93 | server.on('toggle-fullscreen', () => { 94 | mainWindow.setFullScreen(!mainWindow.isFullScreen()); 95 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "electron-remote-dashboard", 3 | "version": "1.0.0", 4 | "description": "Remote dashboard with a control app", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/knalli/electron-remote-dashboard" 8 | }, 9 | "main": "main.js", 10 | "scripts": { 11 | "start": "electron main.js", 12 | "build-windows": "electron-packager ./ --platform=win32 --arch=x64", 13 | "build-linux": "electron-packager ./ --platform=linux --arch=x64" 14 | }, 15 | "author": { 16 | "name": "Jan Philipp", 17 | "email": "knallisworld@googlemail.com" 18 | }, 19 | "license": "MIT", 20 | "dependencies": { 21 | "angular": "~1.5.9", 22 | "angular-animate": "~1.5.9", 23 | "angular-aria": "~1.5.9", 24 | "angular-material": "~1.1.3", 25 | "electron": "^1.4.15", 26 | "electron-screenshot": "^1.0.3", 27 | "eventemitter3": "^2.0.2", 28 | "express": "^4.14.1", 29 | "socket.io": "^1.7.3" 30 | }, 31 | "engines": { 32 | "node": "^4.7", 33 | "npm": "^3" 34 | }, 35 | "devDependencies": { 36 | "electron-packager": "^8.5.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /server/Config.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const os = require('os'); 3 | 4 | const EventEmitter = require('eventemitter3'); 5 | 6 | class Config extends EventEmitter { 7 | 8 | constructor(basePath) { 9 | super(); 10 | this.basePath = basePath; 11 | 12 | // ensure settings exist 13 | const projectName = require(`${this.basePath}/package.json`).name; 14 | this._configDirPath = `${os.homedir()}/.${projectName}`; 15 | try { 16 | const stats = fs.lstatSync(this._configDirPath); 17 | if (!stats.isDirectory()) { 18 | console.error(`Cannot create config directory at '${this._configDirPath}' because a file already exist`); 19 | process.exit(1); 20 | } 21 | console.log('Config dir is: ' + this._configDirPath); 22 | } catch (e) { 23 | try { 24 | fs.mkdirSync(this._configDirPath); 25 | } catch (e) { 26 | console.error(`Cannot create config directory at '${this._configDirPath}' because: ${e.message}`); 27 | process.exit(1); 28 | } 29 | } 30 | 31 | this.load(); 32 | } 33 | 34 | get(group, key, defaultValue) { 35 | if (!this.data[group]) { 36 | this.data[group] = {}; 37 | } 38 | const value = this.data[group][key]; 39 | return value !== undefined ? value : defaultValue; 40 | } 41 | 42 | put(group, key, value) { 43 | if (!this.data[group]) { 44 | this.data[group] = {}; 45 | } 46 | this.data[group][key] = value; 47 | // TODO: autosave in future (throttle-aware) 48 | return this; 49 | } 50 | 51 | load() { 52 | try { 53 | this.data = require(`${this._configDirPath}/.session.json`); 54 | } catch (ignored) { 55 | console.log('Either no session or an invalid/corrupted one, try initial config...'); 56 | try { 57 | this.data = require(`${this._configDirPath}/config.json`); 58 | } catch (ignored) { 59 | console.log('Either no config or an invalid/corrupted one, using internal defaults...'); 60 | this.data = {}; 61 | } 62 | } 63 | 64 | if (!this.data.dashboards) { 65 | this.data.dashboards = {}; 66 | } 67 | 68 | if (!this.data.server) { 69 | this.data.server = {}; 70 | } 71 | 72 | if (!this.data.window) { 73 | this.data.window = {}; 74 | } 75 | 76 | } 77 | 78 | save() { 79 | //console.log('Writing session file'); 80 | const current = { 81 | dashboards : { 82 | active : this.get('dashboards', 'active'), 83 | items : this.get('dashboards', 'items', []).map((item) => { 84 | return { 85 | id : item.id, 86 | display : item.display, 87 | url : item.url, 88 | description : item.description, 89 | username : item.username, 90 | password : item.password 91 | }; 92 | }) 93 | }, 94 | server : this.data.server, 95 | window : this.data.window 96 | }; 97 | // write out json with two spaces 98 | fs.writeFile(`${this._configDirPath}/.session.json`, JSON.stringify(current, null, 2), (err) => { 99 | if (err) { 100 | console.warn(`Could not write session file: ${err.message}`); 101 | } 102 | }); 103 | } 104 | 105 | } 106 | 107 | module.exports = Config; 108 | -------------------------------------------------------------------------------- /server/OnlineStatusManager.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('eventemitter3'); 2 | const {BrowserWindow, app, ipcMain} = require('electron'); 3 | 4 | // Online-status tracking 5 | class OnlineStatusManager extends EventEmitter { 6 | 7 | constructor(baseDir) { 8 | super(); 9 | this.init(baseDir); 10 | } 11 | 12 | init(baseDir) { 13 | app.on('ready', () => { 14 | let window = new BrowserWindow({width : 0, height : 0, show : false}); 15 | window.loadURL(`file://${baseDir}/app/online-status.html`); 16 | //window.webContents.openDevTools(); 17 | ipcMain.on('online-status-changed', (event, status) => { 18 | switch (status) { 19 | case 'online': 20 | this.emit('changed', true); 21 | break; 22 | case 'offline': 23 | this.emit('changed', false); 24 | break; 25 | } 26 | }); 27 | }); 28 | } 29 | 30 | } 31 | 32 | module.exports = OnlineStatusManager; 33 | -------------------------------------------------------------------------------- /server/Server.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('eventemitter3'); 2 | 3 | const express = require('express'); 4 | const http = require('http'); 5 | const socketIo = require('socket.io'); 6 | 7 | class Server extends EventEmitter { 8 | 9 | constructor(config) { 10 | super(); 11 | this.basePath = config.basePath || `${__dirname}/..`; 12 | this.config = config; 13 | this.serverPort = config.get('server', 'port', 33333); 14 | this.appServer = express(); 15 | this.httpServer = http.Server(this.appServer); 16 | this.ioServer = socketIo(this.httpServer); 17 | this.initialize(); 18 | this.start(); 19 | } 20 | 21 | initialize() { 22 | let appBasePath = `${__dirname}/../control_app`; 23 | let dependenciesPath = `${__dirname}/../node_modules`; 24 | let cachePath = `${__dirname}/../.cache`; 25 | this.appServer.use('/', express.static(appBasePath)); 26 | this.appServer.use('/node_modules', express.static(dependenciesPath)); 27 | 28 | // Configure cache directory 29 | this.appServer.use('/.cache', express.static(cachePath, { 30 | setHeaders : (res, path, stat) => { 31 | res.setHeader('Cache-Control', 'no-cache, no-store, max-age=0, must-revalidate'); 32 | res.setHeader('Expires', '0'); 33 | res.setHeader('Pragma', 'no-cache'); 34 | } 35 | })); 36 | 37 | this.states = {}; 38 | this.webviewData = {}; 39 | 40 | // bridge Socket.IO events 41 | this.ioServer.on('connection', (socket) => { 42 | socket.on('list-dashboards', (fn) => { 43 | fn(this.getDashboards()); 44 | }); 45 | socket.on('change-dashboard', (dashboardId, fn) => { 46 | this.changeDashboard(dashboardId, fn); 47 | }); 48 | socket.on('create-dashboard', (dashboard, fn) => { 49 | this.createDashboard(dashboard, fn); 50 | }); 51 | socket.on('remove-dashboard', (dashboardId, fn) => { 52 | this.removeDashboard(dashboardId, fn); 53 | }); 54 | socket.on('toggle-fullscreen', (fn) => { 55 | this.toggleFullscreen(fn); 56 | }); 57 | socket.on('update-dashboard', (dashboard, fn) => { 58 | this.updateDashboard(dashboard, fn); 59 | }); 60 | 61 | if (this.states) { 62 | socket.emit('states-updated', this.states); 63 | } 64 | if (this.webviewData) { 65 | socket.emit('view-updated', this.webviewData); 66 | } 67 | }); 68 | this.on('state-changed', (name, value) => { 69 | this.states[name] = value; 70 | this.ioServer.emit('states-updated', this.states); 71 | }); 72 | this.on('states-changed', (data) => { 73 | for (let key of Object.keys(data)) { 74 | this.states[key] = data[key]; 75 | } 76 | this.ioServer.emit('states-updated', this.states); 77 | }); 78 | this.on('view-updated', (data) => { 79 | for (let key of Object.keys(data)) { 80 | this.webviewData[key] = data[key]; 81 | } 82 | this.ioServer.emit('view-updated', this.webviewData); 83 | }); 84 | this.on('view-favicons-updated', (favicons) => { 85 | this.webviewData.favicon = favicons[0]; 86 | this.ioServer.emit('view-updated', this.webviewData); 87 | }); 88 | this.on('view-response-updated', (response) => { 89 | this.webviewData.lastResponse = response; 90 | this.ioServer.emit('view-updated', this.webviewData); 91 | }); 92 | } 93 | 94 | start() { 95 | this.httpServer.listen(this.serverPort, () => { 96 | this.emit('server-started', {http : this.httpServer, portStarted : this.serverPort}); 97 | }); 98 | //this.emit('dashboards-updated', this.getDashboards()); 99 | 100 | if (this.config.get('dashboards', 'active')) { 101 | console.log(`Loading dashboard "${this.config.get('dashboards', 'active')}"...`); 102 | setTimeout(() => { 103 | this.changeDashboard(this.config.get('dashboards', 'active'), ({success}) => { 104 | if (!success) { 105 | this.config.put('dashboards', 'active', undefined); 106 | } 107 | }); 108 | }, 1000); 109 | } else { 110 | this.config.save(); 111 | } 112 | } 113 | 114 | changeDashboard(dashboardId, fn) { 115 | let dashboard = this.config.get('dashboards', 'items', []).filter((db) => db.id === dashboardId)[0]; 116 | if (!dashboard) { 117 | if (fn) { 118 | console.warn(`Dashboard ${dashboardId} not found`); 119 | fn({success : false, message : 'Bad luck'}); 120 | } 121 | this.config.save(); 122 | } else { 123 | this.config.put('dashboards', 'active', dashboard.id); 124 | this.applyViewUrl({url : dashboard.url, username: dashboard.username , password: dashboard.password}); 125 | if (fn) { 126 | fn({success : true}); 127 | } 128 | this.ioServer.emit('dashboard-changed', dashboard); 129 | this.webviewData.description = dashboard.description; 130 | this.config.save(); 131 | } 132 | } 133 | 134 | createDashboard(dashboard, fn) { 135 | if (!(dashboard && dashboard.id && dashboard.display && dashboard.url)) { 136 | if (fn) { 137 | console.warn(`Dashboard ${dashboard.id} not complete`); 138 | fn({success : false, message : 'Bad luck'}); 139 | } 140 | } else { 141 | if (this.config.get('dashboards', 'items', []).filter((db) => db.id === dashboard.id)[0]) { 142 | if (fn) { 143 | console.warn(`Dashboard ${dashboard.id} already present`); 144 | fn({success : false, message : 'Bad luck'}); 145 | } 146 | } else { 147 | const items = this.config.get('dashboards', 'items', []); 148 | items.push(dashboard); 149 | this.config.put('dashboards', 'items', items); 150 | if (fn) { 151 | fn({success : true}); 152 | } 153 | this.ioServer.emit('dashboards-updated', this.getDashboards()); 154 | this.config.save(); 155 | } 156 | } 157 | } 158 | 159 | removeDashboard(dashboardId, fn) { 160 | let dashboard = this.config.get('dashboards', 'items', []).filter((db) => db.id === dashboardId)[0]; 161 | if (!dashboard) { 162 | if (fn) { 163 | console.warn(`Dashboard ${dashboardId} not found`); 164 | fn({success : false, message : 'Bad luck'}); 165 | } 166 | } else { 167 | this.config.put('dashboards', 'items', this.config.get('dashboards', 'items', []).filter((db) => db.id !== dashboardId)); 168 | if (fn) { 169 | fn({success : true}); 170 | } 171 | this.ioServer.emit('dashboards-updated', this.getDashboards()); 172 | 173 | if (this.config.get('dashboards', 'active') === dashboardId) { 174 | const firstDashboard = this.config.get('dashboards', 'items', [])[0]; 175 | if (firstDashboard) { 176 | this.changeDashboard(firstDashboard.id); 177 | } else { 178 | // TODO: fix use case no dashboard left 179 | this.config.save(); 180 | } 181 | } else { 182 | this.config.save(); 183 | } 184 | } 185 | } 186 | 187 | updateDashboard(inDashboardUpdate, fn) { 188 | let dashboardId = inDashboardUpdate.id; 189 | let dashboard = this.config.get('dashboards', 'items', []).filter((db) => db.id === dashboardId)[0]; 190 | if (!dashboard) { 191 | if (fn) { 192 | console.warn(`Dashboard ${dashboardId} not found`); 193 | fn({success : false, message : 'Bad luck'}); 194 | } 195 | } else { 196 | const items = this.config.get('dashboards', 'items', []); 197 | // update 198 | items.filter((db) => db.id === dashboardId) 199 | .map((db) => { 200 | db.display = inDashboardUpdate.display; 201 | db.url = inDashboardUpdate.url; 202 | db.description = inDashboardUpdate.description; 203 | db.username = inDashboardUpdate.username; 204 | db.password = inDashboardUpdate.password; 205 | }); 206 | this.config.put('dashboards', 'items', items); 207 | if (fn) { 208 | fn({success: true}); 209 | } 210 | this.ioServer.emit('dashboards-updated', this.getDashboards()); 211 | this.config.save(); 212 | 213 | // send update of new url 214 | if (this.config.get('dashboards', 'active') === dashboardId) { 215 | this.applyViewUrl({url: inDashboardUpdate.url, username: inDashboardUpdate.username , password: inDashboardUpdate.password}); 216 | } 217 | } 218 | } 219 | 220 | stop() { 221 | this.emit('server-stopped'); 222 | } 223 | 224 | applyViewUrl({url, username, password}) { 225 | if (username && password) { 226 | var indexOfLink = url.indexOf('://') + 3; 227 | url = url.substring(0, indexOfLink) + username + ":" + password + "@" + url.substring(indexOfLink); 228 | } 229 | this.emit('view-set-url', {url}); 230 | } 231 | 232 | getControlServerUrl() { 233 | return `http://localhost:${this.serverPort}/`; 234 | } 235 | 236 | getDashboards() { 237 | return { 238 | active : this.config.get('dashboards', 'active'), 239 | items : this.config.get('dashboards', 'items', []) 240 | }; 241 | } 242 | 243 | // Incoming fullscreen request 244 | toggleFullscreen(fn){ 245 | this.emit('toggle-fullscreen'); 246 | if (fn) { 247 | fn({success : true}); 248 | } 249 | } 250 | 251 | } 252 | 253 | module.exports = Server; 254 | -------------------------------------------------------------------------------- /site/dashboard-control-app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knalli/electron-remote-dashboard/5e4905ff8b5a14acc5234cc80cc1382d749828c3/site/dashboard-control-app.png -------------------------------------------------------------------------------- /site/dashboard-webview-app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knalli/electron-remote-dashboard/5e4905ff8b5a14acc5234cc80cc1382d749828c3/site/dashboard-webview-app.png -------------------------------------------------------------------------------- /site/screenshots-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knalli/electron-remote-dashboard/5e4905ff8b5a14acc5234cc80cc1382d749828c3/site/screenshots-overview.png -------------------------------------------------------------------------------- /site/screenshots.md: -------------------------------------------------------------------------------- 1 | # Screenshots 2 | 3 | ## Dashboard app 4 | ![Dashboard app](dashboard-webview-app.png) 5 | 6 | ## Control app 7 | ![Control App](dashboard-control-app.png) 8 | --------------------------------------------------------------------------------