├── .brackets.json ├── .editorconfig ├── .eslintrc.json ├── .gitattributes ├── .github ├── funding.yml └── workflows │ └── ci.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── README.md ├── bin └── cli.js ├── fixtures ├── index.html ├── main-error.js ├── main.js ├── package.json └── renderer.js ├── package.json ├── src ├── electronmon.js ├── hook.js ├── log.js ├── message-queue.js ├── package.js ├── signal.js └── watch.js └── test ├── .eslintrc.json └── integration.test.js /.brackets.json: -------------------------------------------------------------------------------- 1 | { 2 | "spaceUnits": 2, 3 | "useTabChar": false, 4 | "language": { 5 | "javascript": { 6 | "linting.prefer": [ 7 | "ESLint" 8 | ], 9 | "linting.usePreferredOnly": true 10 | }, 11 | "markdown": { 12 | "wordWrap": true 13 | } 14 | }, 15 | "language.fileNames": { 16 | "Jenkinsfile": "groovy" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [{package.json,*.yml}] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "env": { 4 | "es6": true, 5 | "node": true 6 | }, 7 | "parserOptions": { 8 | "ecmaVersion": 8 9 | }, 10 | "rules": { 11 | "indent": ["error", 2, { 12 | "SwitchCase": 1 13 | }], 14 | "linebreak-style": ["error", "unix"], 15 | "quotes": ["error", "single"], 16 | "semi": ["error", "always"] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | *.js text eol=lf 5 | *.jsx text eol=lf 6 | *.mjs text eol=lf 7 | *.json text eol=lf 8 | *.html text eol=lf 9 | *.md text eol=lf 10 | *.yml text eol=lf 11 | *.css text eol=lf 12 | *.less text eol=lf 13 | *.scss text eol=lf 14 | *.sass text eol=lf 15 | *.svg text eol=lf 16 | *.xml text eol=lf 17 | *.sh text eol=lf 18 | 19 | # Custom for Visual Studio 20 | *.cs diff=csharp 21 | 22 | # Standard to msysgit 23 | *.doc diff=astextplain 24 | *.DOC diff=astextplain 25 | *.docx diff=astextplain 26 | *.DOCX diff=astextplain 27 | *.dot diff=astextplain 28 | *.DOT diff=astextplain 29 | *.pdf diff=astextplain 30 | *.PDF diff=astextplain 31 | *.rtf diff=astextplain 32 | *.RTF diff=astextplain 33 | -------------------------------------------------------------------------------- /.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: catdad 2 | custom: ["https://www.paypal.me/kirilvatev", "https://venmo.com/Kiril-Vatev"] 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: [ master ] 7 | 8 | env: 9 | FORCE_COLOR: 1 10 | 11 | jobs: 12 | test: 13 | strategy: 14 | matrix: 15 | os: [macos-latest, windows-latest, ubuntu-22.04] 16 | node: [20, 18] 17 | electron: [30, 28, 23] 18 | exclude: 19 | # there's an issue with signals in retry-cli on linux in node 20 🤷‍♀️ 20 | - os: ubuntu-22.04 21 | node: 20 22 | include: 23 | - os: ubuntu-22.04 24 | node: 16 25 | electron: 20 26 | - os: ubuntu-22.04 27 | node: 16 28 | electron: 18 29 | - os: ubuntu-22.04 30 | node: 16 31 | electron: 16 32 | - os: windows-latest 33 | node: 14 34 | electron: 14 35 | - os: ubuntu-22.04 36 | node: 14 37 | electron: 12 38 | - os: windows-latest 39 | node: 12 40 | electron: 10 41 | - os: ubuntu-22.04 42 | node: 10 43 | electron: 8 44 | runs-on: ${{ matrix.os }} 45 | name: test (${{matrix.os}}, node@${{matrix.node}}, electron@${{matrix.electron}}) 46 | steps: 47 | - uses: actions/checkout@v3 48 | - if: ${{ matrix.os == 'ubuntu-22.04' }} 49 | run: sudo apt-get install xvfb 50 | - uses: actions/setup-node@v3 51 | with: 52 | node-version: ${{ matrix.node }} 53 | - run: npm install 54 | - run: npm install electron@${{ matrix.electron }} 55 | - run: node --version 56 | - run: npx electron --version || echo well that went poorly 57 | - name: npm test 58 | run: npm run citest 59 | 60 | lint: 61 | runs-on: ubuntu-latest 62 | steps: 63 | - uses: actions/checkout@v3 64 | - uses: actions/setup-node@v3 65 | with: 66 | node-version: 16 67 | - run: npm install 68 | - run: node --version 69 | - run: npm run lint 70 | 71 | publish: 72 | runs-on: ubuntu-latest 73 | needs: [test, lint] 74 | if: startsWith(github.ref, 'refs/tags/') && github.event_name != 'pull_request' 75 | steps: 76 | - uses: actions/checkout@v3 77 | - uses: actions/setup-node@v3 78 | with: 79 | node-version: 14 80 | registry-url: https://registry.npmjs.org/ 81 | - run: npm publish 82 | env: 83 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 84 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Windows image file caches 2 | Thumbs.db 3 | ehthumbs.db 4 | Desktop.ini 5 | 6 | # Recycle Bin used on file shares 7 | $RECYCLE.BIN/ 8 | 9 | # Windows shortcuts 10 | *.lnk 11 | 12 | # OSX 13 | .DS_Store 14 | .AppleDouble 15 | .LSOverride 16 | 17 | # Thumbnails 18 | ._* 19 | 20 | # Files that might appear in the root of a volume 21 | .DocumentRevisions-V100 22 | .fseventsd 23 | .Spotlight-V100 24 | .TemporaryItems 25 | .Trashes 26 | .VolumeIcon.icns 27 | 28 | # Directories potentially created on remote AFP share 29 | .AppleDB 30 | .AppleDesktop 31 | Network Trash Folder 32 | Temporary Items 33 | .apdisk 34 | 35 | # Node stuff 36 | node_modules/ 37 | coverage/ 38 | .nyc_output/ 39 | test-dir* 40 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Windows image file caches 2 | Thumbs.db 3 | ehthumbs.db 4 | Desktop.ini 5 | 6 | # Recycle Bin used on file shares 7 | $RECYCLE.BIN/ 8 | 9 | # Windows shortcuts 10 | *.lnk 11 | 12 | # OSX 13 | .DS_Store 14 | .AppleDouble 15 | .LSOverride 16 | 17 | # Thumbnails 18 | ._* 19 | 20 | # Files that might appear in the root of a volume 21 | .DocumentRevisions-V100 22 | .fseventsd 23 | .Spotlight-V100 24 | .TemporaryItems 25 | .Trashes 26 | .VolumeIcon.icns 27 | 28 | # Directories potentially created on remote AFP share 29 | .AppleDB 30 | .AppleDesktop 31 | Network Trash Folder 32 | Temporary Items 33 | .apdisk 34 | 35 | # Node stuff 36 | node_modules/ 37 | coverage/ 38 | .nyc_output/ 39 | 40 | # test suff 41 | test/ 42 | fixtures/ 43 | fixtures* 44 | .* 45 | appveyor.yml 46 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # electronmon 2 | 3 | [![electronmon logo](https://cdn.jsdelivr.net/gh/catdad-experiments/catdad-experiments-org@c17b82/electronmon/logo.jpg)](https://github.com/catdad/electronmon/) 4 | 5 | Watch and reload your electron app the easy way! 6 | 7 | [![GitHub Actions CI][github-actions.svg]][github-actions.link] 8 | [![npm-downloads][npm-downloads.svg]][npm.link] 9 | [![npm-version][npm-version.svg]][npm.link] 10 | 11 | [github-actions.svg]: https://img.shields.io/github/actions/workflow/status/catdad/electronmon/ci.yml?logo=github&branch=master 12 | [github-actions.link]: https://github.com/catdad/electronmon/actions/workflows/ci.yml 13 | [npm-downloads.svg]: https://img.shields.io/npm/dm/electronmon.svg 14 | [npm.link]: https://www.npmjs.com/package/electronmon 15 | [npm-version.svg]: https://img.shields.io/npm/v/electronmon.svg 16 | 17 | This is the simplest way to watch and restart/reload [electron](https://github.com/electron/electron) applications. It requires no quessing, no configuration, and no changing your application or conditionally requiring dependencies. And best of all, it keeps everything in-process, and will not exit on the first application relaunch. 18 | 19 | It was inspired by [nodemon](https://github.com/remy/nodemon) and largely works the same way (_by magic_ 🧙). 20 | 21 | To use it, you don't have to change your application at all. Just use `electronmon` instead of `electron` to launch your application, using all the same arguments you would pass to the `electron` cli: 22 | 23 | ```bash 24 | npx electronmon . 25 | ``` 26 | 27 | That's it! Now, all your files are watched. Changes to main process files will cause the application to restart entirely, while changes to any of the renderer process files will simply reload the application browser windows. 28 | 29 | All you have to do now is write your application code. 30 | 31 | ## Configuration 32 | 33 | Okay, okay... so it's not exactly magic. While `electronmon` will usually work exactly the way you want it to, you might find a need to contigure it. You can do so by providing extra values in your `package.json` in the an `electronmon` object. The following options are available: 34 | 35 | * **`patterns`** _`{Array}`_ - Additional patterns to watch, in glob form. The default patterns are `['**/*', '!node_modules', '!node_modules/**/*', '!.*', '!**/*.map']`, and this property will add to that. If you want to ignore some files, start the glob with `!`. 36 | 37 | **Example:** 38 | 39 | ```json 40 | { 41 | "electronmon": { 42 | "patterns": ["!test/**"] 43 | } 44 | } 45 | ``` 46 | 47 | ## Supported environments 48 | 49 | This module is tested and supported on Windows, MacOS, and Linux, using node versions 10 - 18 and electron versions 8 - 23. Considering it still works after all these versions, there's a good chance it works with newer versions as well. 50 | 51 | ## API Usage 52 | 53 | You will likely never need to use this, but in case you do, this module can be required and exposes an API for interacting with the monitor process. 54 | 55 | ```javascript 56 | const electronmon = require('electronmon'); 57 | 58 | (async () => { 59 | const options = {...}; 60 | const app = await electronmon(options); 61 | })(); 62 | ``` 63 | 64 | All options are optional with reasonable defaults (_again, magic_ 🧙), but the following options are available: 65 | 66 | * **`cwd`** _`{String}`_ - The root directory of your application. 67 | * **`args`** _`{Array}`_ - The arguments that you want to pass to `electron`. 68 | * **`env`** _`{Object}`_ - Any additional environment variables you would like to specically provide to your `electron` process. 69 | * **`patterns`** _`{Array}`_ - Additional patterns to watch, in glob form. The default patterns are `['**/*', '!node_modules', '!node_modules/**/*', '!.*', '!**/*.map']`, and this property will add to that. If you want to ignore some files, start the glob with `!`. 70 | * **`logLevel`** _`{String}`_ - The level of logging you would like. Possible values are `verbose`, `info`, ` error`, and `quiet`. 71 | * **`electronPath`** _`{String}`_ - The path to the `electron` binary. 72 | 73 | When the monitor is started, it will start your application and the monitoring process. It exposes the following methods for interacting with the monitoring process (all methods are asynchronous and return a Promise): 74 | 75 | * **`app.reload()`** → `Promise` - reloads all open web views of your application 76 | * **`app.restart()`** → `Promise` - restarts the entire electron process of your application 77 | * **`app.close()`** → `Promise` - closes the entire electron process of your application and waits for file changes in order to restart it 78 | * **`app.destroy()`** → `Promise` - closes the entire electron process and stops monitoring 79 | -------------------------------------------------------------------------------- /bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | const importFrom = require('import-from'); 7 | const electronPath = importFrom(path.resolve('.'), 'electron'); 8 | 9 | const pkg = require('../src/package.js'); 10 | 11 | const cwd = fs.realpathSync(path.resolve('.')); 12 | const args = process.argv.slice(2); 13 | const logLevel = process.env.ELECTRONMON_LOGLEVEL || 'info'; 14 | const patterns = Array.isArray(pkg.electronmon.patterns) ? pkg.electronmon.patterns : []; 15 | 16 | if (pkg.name) { 17 | process.title = `${pkg.name} - electronmon`; 18 | } else { 19 | process.title = 'electronmon'; 20 | } 21 | 22 | require('../')({ cwd, args, logLevel, electronPath, patterns }); 23 | -------------------------------------------------------------------------------- /fixtures/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Electronmon Fixture 6 | 19 | 20 | 21 |

I'm a pizza

22 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /fixtures/main-error.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | if (process.env.TEST_ERROR) { 3 | throw new Error(process.env.TEST_ERROR); 4 | } 5 | 6 | module.exports = 'no error'; 7 | -------------------------------------------------------------------------------- /fixtures/main.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | const { app, BrowserWindow } = require('electron'); 4 | 5 | let mainWindow; 6 | 7 | require('./main-error.js'); 8 | 9 | function createWindow () { 10 | mainWindow = new BrowserWindow({width: 400, height: 400}); 11 | mainWindow.loadURL(`file://${__dirname}/index.html`); 12 | 13 | mainWindow.on('closed', () => { 14 | mainWindow = null; 15 | }); 16 | 17 | console.log('main window open'); 18 | } 19 | 20 | app.on('ready', createWindow); 21 | 22 | app.on('window-all-closed', () => { 23 | app.quit(); 24 | }); 25 | 26 | app.on('activate', () => { 27 | if (mainWindow === null) { 28 | createWindow(); 29 | } 30 | }); 31 | -------------------------------------------------------------------------------- /fixtures/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fixture", 3 | "productName": "I am a Test Fixture", 4 | "main": "main.js" 5 | } 6 | -------------------------------------------------------------------------------- /fixtures/renderer.js: -------------------------------------------------------------------------------- 1 | console.log('renderer thread'); 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "electronmon", 3 | "version": "2.0.3", 4 | "description": "watch and reload your electron app the easy way", 5 | "main": "src/electronmon.js", 6 | "bin": { 7 | "electronmon": "./bin/cli.js" 8 | }, 9 | "scripts": { 10 | "lint": "eslint bin src", 11 | "test": "mocha --timeout 20000 --retries=1", 12 | "ciinspect": "xvfb-maybe electron --version", 13 | "citest": "xvfb-maybe retry -n 3 -- npm test", 14 | "fixture": "cd fixtures && node ../bin/cli.js ." 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/catdad/electronmon.git" 19 | }, 20 | "author": "Kiril Vatev ", 21 | "license": "ISC", 22 | "bugs": { 23 | "url": "https://github.com/catdad/electronmon/issues" 24 | }, 25 | "homepage": "https://github.com/catdad/electronmon#readme", 26 | "engines": { 27 | "node": ">=10.0.0" 28 | }, 29 | "devDependencies": { 30 | "chai": "^4.2.0", 31 | "electron": "^23.1.0", 32 | "eslint": "^5.16.0", 33 | "fs-extra": "^8.1.0", 34 | "mocha": "^6.2.2", 35 | "node-stream": "^1.7.0", 36 | "retry-cli": "0.6.0", 37 | "symlink-dir": "^3.1.1", 38 | "unstyle": "^0.1.0", 39 | "xvfb-maybe": "^0.2.1" 40 | }, 41 | "dependencies": { 42 | "chalk": "^3.0.0", 43 | "import-from": "^3.0.0", 44 | "runtime-required": "^1.1.0", 45 | "watchboy": "^0.4.3" 46 | }, 47 | "keywords": [ 48 | "electron", 49 | "reload", 50 | "reloader", 51 | "livereload", 52 | "auto-reload", 53 | "live-reload", 54 | "refresh", 55 | "restart", 56 | "watch", 57 | "watcher", 58 | "watching", 59 | "monitor", 60 | "hot", 61 | "files", 62 | "fs", 63 | "dev", 64 | "development", 65 | "node" 66 | ] 67 | } 68 | -------------------------------------------------------------------------------- /src/electronmon.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { spawn } = require('child_process'); 3 | 4 | const logger = require('./log.js'); 5 | const watch = require('./watch.js'); 6 | 7 | const SIGNAL = require('./signal.js'); 8 | const ERRORED = -1; 9 | 10 | const isStdReadable = stream => stream === process.stdin; 11 | const isStdWritable = stream => stream === process.stdout || stream === process.stderr; 12 | 13 | module.exports = ({ 14 | cwd = process.cwd(), 15 | args = ['.'], 16 | env = {}, 17 | logLevel = 'info', 18 | electronPath, 19 | stdio = [process.stdin, process.stdout, process.stderr], 20 | patterns = [] 21 | } = {}) => { 22 | const executable = electronPath || require('electron'); 23 | 24 | const isTTY = stdio[1].isTTY; 25 | const getEnv = (env) => Object.assign( 26 | isTTY ? { FORCE_COLOR: '1' } : {}, 27 | process.env, 28 | { ELECTRONMON_LOGLEVEL: logLevel }, 29 | env 30 | ); 31 | const log = logger(stdio[1], logLevel); 32 | 33 | const appfiles = {}; 34 | let globalWatcher; 35 | let globalApp; 36 | let overrideSignal; 37 | 38 | function onTerm() { 39 | if (globalApp) { 40 | globalApp.kill('SIGINT'); 41 | } 42 | 43 | process.exit(0); 44 | } 45 | 46 | function onMessage({ type, file }) { 47 | if (type === 'discover') { 48 | appfiles[file] = true; 49 | } else if (type === 'uncaught-exception') { 50 | log.info('uncaught exception occured'); 51 | log.info('waiting for any change to restart the app'); 52 | overrideSignal = ERRORED; 53 | } 54 | } 55 | 56 | function startApp() { 57 | return new Promise((resolve) => { 58 | overrideSignal = null; 59 | 60 | const hook = path.resolve(__dirname, 'hook.js'); 61 | const argv = ['--require', hook].concat(args); 62 | 63 | const stdioArg = [ 64 | isStdReadable(stdio[0]) ? 'inherit' : 'pipe', 65 | isStdWritable(stdio[1]) ? 'inherit' : 'pipe', 66 | isStdWritable(stdio[2]) ? 'inherit' : 'pipe', 67 | 'ipc' 68 | ]; 69 | 70 | const app = spawn(executable, argv, { 71 | stdio: stdioArg, 72 | env: getEnv(env), 73 | cwd, 74 | windowsHide: false, 75 | }); 76 | 77 | stdioArg.forEach((val, idx) => { 78 | if (val !== 'pipe') { 79 | return; 80 | } 81 | 82 | if (idx === 0) { 83 | stdio[0].pipe(app.stdin); 84 | } else if (idx === 1) { 85 | app.stdout.pipe(stdio[1], { end: false }); 86 | } else if (idx === 2) { 87 | app.stderr.pipe(stdio[2], { end: false }); 88 | } 89 | }); 90 | 91 | app.on('message', onMessage); 92 | 93 | app.once('exit', code => { 94 | process.removeListener('SIGTERM', onTerm); 95 | process.removeListener('SIGHUP', onTerm); 96 | globalApp = null; 97 | 98 | if (overrideSignal === ERRORED) { 99 | log.info(`ignoring exit with code ${code}`); 100 | return; 101 | } 102 | 103 | if (overrideSignal === SIGNAL || code === SIGNAL) { 104 | log.info('restarting app due to file change'); 105 | startApp(); 106 | return; 107 | } 108 | 109 | log.info(`app exited with code ${code}, waiting for change to restart it`); 110 | }); 111 | 112 | process.once('SIGTERM', onTerm); 113 | process.once('SIGHUP', onTerm); 114 | globalApp = app; 115 | 116 | const send = app.send.bind(app); 117 | globalApp.send = (signal) => { 118 | send(signal); 119 | 120 | if (signal === 'reset') { 121 | // app is being killed, ignore all future messages 122 | globalApp.send = () => {}; 123 | } 124 | }; 125 | 126 | resolve(app); 127 | }); 128 | } 129 | 130 | function closeApp() { 131 | return new Promise((resolve) => { 132 | if (!globalApp) { 133 | return resolve(); 134 | } 135 | 136 | globalApp.once('exit', () => { 137 | globalApp = null; 138 | resolve(); 139 | }); 140 | 141 | globalApp.kill('SIGINT'); 142 | }); 143 | } 144 | 145 | function restartApp() { 146 | return closeApp().then(() => init()); 147 | } 148 | 149 | function reloadApp() { 150 | // this is a convenience method to reload the renderer 151 | // thread in the app... also, everything is a promise 152 | if (!globalApp) { 153 | return restartApp(); 154 | } 155 | 156 | return new Promise((resolve) => { 157 | globalApp.send('reload'); 158 | resolve(); 159 | }); 160 | } 161 | 162 | function startWatcher() { 163 | return new Promise((resolve) => { 164 | const watcher = watch({ root: cwd, patterns }); 165 | globalWatcher = watcher; 166 | 167 | watcher.on('change', ({ path: fullpath }) => { 168 | const relpath = path.relative(cwd, fullpath); 169 | const filepath = path.resolve(cwd, relpath); 170 | const type = 'change'; 171 | 172 | if (overrideSignal === ERRORED) { 173 | log.info(`file ${type}: ${relpath}`); 174 | return restartApp(); 175 | } 176 | 177 | if (!globalApp) { 178 | log.info(`file ${type}: ${relpath}`); 179 | return startApp(); 180 | } 181 | 182 | if (appfiles[filepath]) { 183 | log.info(`main file ${type}: ${relpath}`); 184 | globalApp.send('reset'); 185 | } else { 186 | log.info(`renderer file ${type}: ${relpath}`); 187 | globalApp.send('reload'); 188 | } 189 | }); 190 | 191 | watcher.on('add', ({ path: fullpath }) => { 192 | const relpath = path.relative(cwd, fullpath); 193 | log.verbose('watching new file:', relpath); 194 | }); 195 | 196 | watcher.once('ready', () => { 197 | log.info('waiting for a change to restart it'); 198 | resolve(); 199 | }); 200 | }); 201 | } 202 | 203 | function destroyApp() { 204 | return Promise.all([ 205 | closeApp(), 206 | globalWatcher.close() 207 | ]).then(() => { 208 | globalApp = globalWatcher = null; 209 | }); 210 | } 211 | 212 | function init() { 213 | return Promise.all([ 214 | globalWatcher ? Promise.resolve() : startWatcher(), 215 | globalApp ? Promise.resolve() : startApp() 216 | ]).then(() => undefined); 217 | } 218 | 219 | return init().then(() => ({ 220 | close: closeApp, 221 | destroy: destroyApp, 222 | reload: reloadApp, 223 | restart: restartApp 224 | })); 225 | }; 226 | -------------------------------------------------------------------------------- /src/hook.js: -------------------------------------------------------------------------------- 1 | const electron = require('electron'); 2 | const required = require('runtime-required'); 3 | const path = require('path'); 4 | 5 | const logLevel = process.env.ELECTRONMON_LOGLEVEL || 'info'; 6 | const log = require('./log.js')(process.stdout, logLevel); 7 | const signal = require('./signal.js'); 8 | const queue = require('./message-queue.js'); 9 | 10 | const pathmap = {}; 11 | 12 | // we can get any number of arguments... best we can do 13 | // is check if all of them resolve to a file, and if they do 14 | // assume that file is a main process file 15 | (function addMainFile(args) { 16 | for (const arg of args) { 17 | try { 18 | const argPath = path.resolve(arg); 19 | const file = require.resolve(argPath); 20 | pathmap[file] = true; 21 | queue({ type: 'discover', file }); 22 | } catch (e) { 23 | // you know... because lint 24 | e; 25 | } 26 | } 27 | })(process.argv.slice(3)); 28 | // we run `electron --require hook.js ...` 29 | // so remove the first 3 arguments 30 | 31 | function exit(code) { 32 | electron.app.on('will-quit', () => { 33 | electron.app.exit(code); 34 | }); 35 | 36 | electron.app.quit(); 37 | } 38 | 39 | function reset() { 40 | exit(signal); 41 | } 42 | 43 | function reload() { 44 | const windows = electron.BrowserWindow.getAllWindows(); 45 | 46 | if (windows && windows.length) { 47 | for (const win of windows) { 48 | win.webContents.reloadIgnoringCache(); 49 | } 50 | } 51 | } 52 | 53 | required.on('file', ({ type, id }) => { 54 | if (type !== 'file') { 55 | return; 56 | } 57 | 58 | if (pathmap[id]) { 59 | // we are already watching this file, skip it 60 | return; 61 | } 62 | 63 | log.verbose('found new main thread file:', id); 64 | 65 | pathmap[id] = true; 66 | queue({ type: 'discover', file: id }); 67 | }); 68 | 69 | process.on('message', msg => { 70 | if (msg === 'reset') { 71 | return reset(); 72 | } 73 | 74 | if (msg === 'reload') { 75 | return reload(); 76 | } 77 | 78 | log.verbose('unknown hook message:', msg); 79 | }); 80 | 81 | process.on('uncaughtException', err => { 82 | const name = 'name' in electron.app ? electron.app.name : 83 | 'getName' in electron.app ? electron.app.getName() : 84 | 'the application'; 85 | 86 | const onHandled = () => { 87 | electron.dialog.showErrorBox(`${name} encountered an error`, err.stack); 88 | exit(1); 89 | }; 90 | 91 | if (process.send) { 92 | queue({ type: 'uncaught-exception' }, () => onHandled()); 93 | } else { 94 | onHandled(); 95 | } 96 | }); 97 | -------------------------------------------------------------------------------- /src/log.js: -------------------------------------------------------------------------------- 1 | const { format } = require('util'); 2 | const chalk = require('chalk'); 3 | 4 | const levels = { 5 | verbose: 1, 6 | info: 2, 7 | error: 3, 8 | quiet: 4 9 | }; 10 | 11 | const logger = (level, maxLevel, stream) => { 12 | const isTTY = stream.isTTY || Number(process.env.FORCE_COLOR) > 1; 13 | const color = new chalk.Instance({ level: isTTY ? 1 : 0 }); 14 | 15 | const thisLevel = levels[level]; 16 | 17 | return (...args) => { 18 | if (thisLevel < maxLevel) { 19 | return; 20 | } 21 | 22 | stream.write(`${format(color.grey('[electronmon]'), ...args.map(a => color.yellow(a)))}\n`); 23 | }; 24 | }; 25 | 26 | module.exports = (stream, maxLevel) => { 27 | return { 28 | error: logger('error', levels[maxLevel], stream), 29 | info: logger('info', levels[maxLevel], stream), 30 | verbose: logger('verbose', levels[maxLevel], stream) 31 | }; 32 | }; 33 | -------------------------------------------------------------------------------- /src/message-queue.js: -------------------------------------------------------------------------------- 1 | const queue = (() => { 2 | const pending = []; 3 | let inFlight = false; 4 | 5 | const send = (msg, cb) => { 6 | if (inFlight) { 7 | pending.push([msg, cb]); 8 | return; 9 | } 10 | 11 | inFlight = true; 12 | 13 | process.send(msg, (e) => { 14 | inFlight = false; 15 | 16 | if (cb) { 17 | cb(e); 18 | } 19 | 20 | if (pending.length) { 21 | send(...pending.shift()); 22 | } 23 | }); 24 | }; 25 | 26 | return send; 27 | })(); 28 | 29 | module.exports = queue; 30 | -------------------------------------------------------------------------------- /src/package.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | const read = () => { 4 | try { 5 | const packageText = fs.readFileSync('./package.json', 'utf8'); 6 | return JSON.parse(packageText || '{}'); 7 | } catch (e) { 8 | return {}; 9 | } 10 | }; 11 | 12 | module.exports = (() => { 13 | const pkg = read(); 14 | 15 | pkg.electronmon = Object.assign({ 16 | patterns: [] 17 | }, pkg.electronmon); 18 | 19 | return pkg; 20 | })(); 21 | -------------------------------------------------------------------------------- /src/signal.js: -------------------------------------------------------------------------------- 1 | module.exports = Number(process.env.ELECTRONMON_SPECIAL_SIGNAL || 37); 2 | -------------------------------------------------------------------------------- /src/watch.js: -------------------------------------------------------------------------------- 1 | const watchboy = require('watchboy'); 2 | 3 | const PATTERNS = ['**/*', '!node_modules', '!node_modules/**/*', '!.*', '!**/*.map']; 4 | 5 | module.exports = ({ root, patterns }) => { 6 | const watcher = watchboy([...PATTERNS, ...patterns.map(s => `${s}`)], { 7 | cwd: root 8 | }); 9 | 10 | return watcher; 11 | }; 12 | -------------------------------------------------------------------------------- /test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "mocha": true, 5 | "es6": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /test/integration.test.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs-extra'); 3 | const { PassThrough } = require('stream'); 4 | const { spawn } = require('child_process'); 5 | const ns = require('node-stream'); 6 | const unstyle = require('unstyle'); 7 | const symlink = require('symlink-dir'); 8 | const { expect } = require('chai'); 9 | 10 | describe('integration', () => { 11 | let stdout; 12 | 13 | const touch = async file => { 14 | const content = await fs.readFile(file); 15 | await fs.writeFile(file, content); 16 | }; 17 | 18 | const wrap = stream => { 19 | return stream 20 | .pipe(unstyle()) 21 | .pipe(ns.split()) 22 | .pipe(ns.map(s => s.trim())); 23 | }; 24 | 25 | const collect = stream => { 26 | const lines = []; 27 | 28 | stream._getLines = () => [].concat(lines); 29 | stream.on('data', line => lines.push(line)); 30 | stream.pause(); 31 | 32 | // make it availabel for afterEach on failed tests 33 | stdout = stream; 34 | 35 | return stream; 36 | }; 37 | 38 | const waitFor = (stream, regex) => { 39 | const err = new Error(`did not find ${regex.toString()}`); 40 | 41 | return new Promise((resolve, reject) => { 42 | const onReadable = () => { 43 | stream.resume(); 44 | }; 45 | 46 | const timer = setTimeout(() => { 47 | stream.pause(); 48 | stream.removeListener('readable', onReadable); 49 | stream.removeListener('data', onLine); 50 | reject(err); 51 | }, 5000); 52 | 53 | const onLine = line => { 54 | stream.pause(); 55 | 56 | if (regex.test(line)) { 57 | clearTimeout(timer); 58 | stream.removeListener('readable', onReadable); 59 | stream.removeListener('data', onLine); 60 | return resolve(); 61 | } 62 | 63 | stream.resume(); 64 | }; 65 | 66 | stream.on('readable', onReadable); 67 | stream.on('data', onLine); 68 | stream.resume(); 69 | }); 70 | }; 71 | 72 | const ready = (stream, { main = true, renderer = true, index = true } = {}) => { 73 | return Promise.all([ 74 | waitFor(stream, /main window open/), 75 | main ? waitFor(stream, /watching new file: main\.js/) : Promise.resolve(), 76 | renderer ? waitFor(stream, /watching new file: renderer\.js/) : Promise.resolve(), 77 | index ? waitFor(stream, /watching new file: index\.html/) : Promise.resolve() 78 | ]); 79 | }; 80 | 81 | const createCopy = async () => { 82 | const root = path.resolve(__dirname, '../fixtures'); 83 | const copyDir = path.resolve(__dirname, '..', `test-dir-${Math.random().toString(36).slice(2)}`); 84 | 85 | await fs.ensureDir(copyDir); 86 | await fs.copy(root, copyDir); 87 | 88 | return copyDir; 89 | }; 90 | 91 | function runIntegrationTests(realRoot, cwd, start, file) { 92 | it('watches files for restarts or refreshes', async () => { 93 | const app = await start({ 94 | args: ['main.js'], 95 | cwd, 96 | env: Object.assign({}, process.env, { 97 | ELECTRONMON_LOGLEVEL: 'verbose' 98 | }) 99 | }); 100 | 101 | const stdout = collect(wrap(app.stdout)); 102 | 103 | await ready(stdout); 104 | 105 | await Promise.all([ 106 | waitFor(stdout, /renderer file change: index\.html/), 107 | touch(file('index.html')) 108 | ]); 109 | 110 | await Promise.all([ 111 | waitFor(stdout, /renderer file change: renderer\.js/), 112 | touch(file('renderer.js')) 113 | ]); 114 | 115 | await Promise.all([ 116 | waitFor(stdout, /main file change: main\.js/), 117 | waitFor(stdout, /restarting app due to file change/), 118 | waitFor(stdout, /main window open/), 119 | touch(file('main.js')) 120 | ]); 121 | }); 122 | 123 | if (process.platform === 'win32') { 124 | it('restarts apps on a change after they crash and the dialog is still open', async () => { 125 | const app = await start({ 126 | args: ['main.js'], 127 | cwd, 128 | env: Object.assign({}, process.env, { 129 | ELECTRONMON_LOGLEVEL: 'verbose', 130 | TEST_ERROR: 'pineapples' 131 | }) 132 | }); 133 | 134 | const stdout = collect(wrap(app.stdout)); 135 | const stderr = collect(wrap(app.stderr)); 136 | 137 | // in recent versions of electron (e.g. 23), the error appears in stderr 138 | // but older versions (e.g. 14) print the error to stdout 139 | // I'm just going to go ahead and not care, because the user 140 | // will see it anyway, and this was an electron change I have no control over anyway 141 | const waitForError = async (expression) => await Promise.race([ 142 | waitFor(stdout, expression), 143 | waitFor(stderr, expression) 144 | ]); 145 | 146 | await waitForError(/pineapples/); 147 | await waitFor(stdout, /waiting for any change to restart the app/); 148 | 149 | await Promise.all([ 150 | waitFor(stdout, /file change: main\.js/), 151 | waitForError(/pineapples/), 152 | waitFor(stdout, /waiting for any change to restart the app/), 153 | touch(file('main.js')) 154 | ]); 155 | 156 | await Promise.all([ 157 | waitFor(stdout, /file change: renderer\.js/), 158 | waitForError(/pineapples/), 159 | waitFor(stdout, /waiting for any change to restart the app/), 160 | touch(file('renderer.js')) 161 | ]); 162 | }); 163 | } else { 164 | it('restarts apps on a change after they crash at startup', async () => { 165 | const app = await start({ 166 | args: ['main.js'], 167 | cwd, 168 | env: Object.assign({}, process.env, { 169 | ELECTRONMON_LOGLEVEL: 'verbose', 170 | TEST_ERROR: 'pineapples' 171 | }) 172 | }); 173 | 174 | const stdout = collect(wrap(app.stdout)); 175 | 176 | await waitFor(stdout, /uncaught exception occured/), 177 | await waitFor(stdout, /waiting for any change to restart the app/); 178 | 179 | await Promise.all([ 180 | waitFor(stdout, /file change: main\.js/), 181 | waitFor(stdout, /uncaught exception occured/), 182 | waitFor(stdout, /waiting for any change to restart the app/), 183 | touch(file('main.js')) 184 | ]); 185 | 186 | await Promise.all([ 187 | waitFor(stdout, /file change: renderer\.js/), 188 | waitFor(stdout, /uncaught exception occured/), 189 | waitFor(stdout, /waiting for any change to restart the app/), 190 | touch(file('renderer.js')) 191 | ]); 192 | }); 193 | } 194 | } 195 | 196 | function runIntegrationSuite(start) { 197 | const root = path.resolve(__dirname, '../fixtures'); 198 | 199 | const file = fixturename => { 200 | return path.resolve(root, fixturename); 201 | }; 202 | 203 | describe('when running the app from project directory', () => { 204 | runIntegrationTests(root, root, start, file); 205 | }); 206 | 207 | describe('when running the app from a linked directory', () => { 208 | const linkDir = path.resolve(__dirname, '..', `test-dir-${Math.random().toString(36).slice(2)}`); 209 | 210 | before(async () => { 211 | await symlink(root, linkDir); 212 | }); 213 | after(async () => { 214 | await fs.unlink(linkDir); 215 | }); 216 | 217 | it(`making sure link exists at ${linkDir}`, async () => { 218 | const realPath = await fs.realpath(linkDir); 219 | expect(realPath).to.equal(root); 220 | }); 221 | 222 | runIntegrationTests(root, linkDir, start, file); 223 | }); 224 | 225 | describe('when providing watch patterns', () => { 226 | let dir; 227 | 228 | const fileLocal = fixturename => { 229 | return path.resolve(dir, fixturename); 230 | }; 231 | 232 | before(async () => { 233 | dir = await createCopy(); 234 | }); 235 | after(async function () { 236 | // this can take a bit of time because the folder remains locked 237 | // for a little while (usually round 15 seconds) 238 | this.timeout(45000); 239 | let remainingMilliseconds = 30 * 1000; 240 | const end = Date.now() + remainingMilliseconds; 241 | 242 | while (remainingMilliseconds > 0) { 243 | try { 244 | await fs.remove(dir); 245 | remainingMilliseconds = 0; 246 | } catch (e) { 247 | remainingMilliseconds = end - Date.now(); 248 | if (e.code !== 'EBUSY' && remainingMilliseconds > 0) { 249 | throw e; 250 | } 251 | } 252 | } 253 | }); 254 | 255 | it('ignores files defined by negative patterns', async () => { 256 | const app = await start({ 257 | args: ['main.js'], 258 | cwd: dir, 259 | env: Object.assign({}, process.env, { 260 | ELECTRONMON_LOGLEVEL: 'verbose' 261 | }), 262 | patterns: ['!main-error.js', '!*.html'] 263 | }); 264 | 265 | const stdout = collect(wrap(app.stdout)); 266 | 267 | await ready(stdout, { index: false }); 268 | 269 | await Promise.all([ 270 | waitFor(stdout, /renderer file change: renderer\.js/), 271 | touch(fileLocal('renderer.js')) 272 | ]); 273 | 274 | const linesBefore = [].concat(stdout._getLines()); 275 | await touch(fileLocal('index.html')); 276 | const linesAfter = [].concat(stdout._getLines()); 277 | 278 | expect(linesAfter).to.deep.equal(linesBefore); 279 | 280 | await Promise.all([ 281 | waitFor(stdout, /main file change: main\.js/), 282 | waitFor(stdout, /restarting app due to file change/), 283 | waitFor(stdout, /main window open/), 284 | touch(fileLocal('main-error.js')), 285 | touch(fileLocal('main.js')) 286 | ]); 287 | 288 | const mainErrorChanged = stdout._getLines().find(line => !!line.match(/main file change: main-error\.js/)); 289 | 290 | expect(mainErrorChanged).to.equal(undefined); 291 | }); 292 | }); 293 | } 294 | 295 | afterEach(function () { 296 | if (this.currentTest.state === 'failed' && stdout) { 297 | // eslint-disable-next-line no-console 298 | console.log(stdout._getLines()); 299 | } 300 | 301 | stdout = null; 302 | }); 303 | 304 | describe('api', () => { 305 | const api = require('../'); 306 | let app; 307 | 308 | afterEach(async () => { 309 | if (!app) { 310 | return; 311 | } 312 | 313 | await app.destroy(); 314 | app = null; 315 | }); 316 | 317 | const start = async ({ args, cwd, env, patterns = [] }) => { 318 | const stdout = new PassThrough(); 319 | const stderr = new PassThrough(); 320 | 321 | app = await api({ 322 | // NOTE: the API should always use realPath 323 | cwd: await fs.realpath(cwd), 324 | args, 325 | env, 326 | stdio: [process.stdin, stdout, stderr], 327 | logLevel: env.ELECTRONMON_LOGLEVEL || 'verbose', 328 | patterns 329 | }); 330 | 331 | app.stdout = stdout; 332 | app.stderr = stderr; 333 | 334 | return app; 335 | }; 336 | 337 | runIntegrationSuite(start); 338 | 339 | describe('using api methods', () => { 340 | const cwd = path.resolve(__dirname, '../fixtures'); 341 | 342 | const startReady = async () => { 343 | app = await start({ 344 | args: ['main.js'], 345 | cwd, 346 | env: Object.assign({}, process.env, { 347 | ELECTRONMON_LOGLEVEL: 'verbose' 348 | }) 349 | }); 350 | 351 | const stdout = collect(wrap(app.stdout)); 352 | 353 | await ready(stdout); 354 | 355 | return { app, stdout }; 356 | }; 357 | 358 | it('can manually restart an app', async () => { 359 | const { app, stdout } = await startReady(); 360 | 361 | await Promise.all([ 362 | waitFor(stdout, /app exited/), 363 | waitFor(stdout, /main window open/), 364 | app.restart() 365 | ]); 366 | }); 367 | 368 | it('can restart an app after it is stopped', async () => { 369 | const { app, stdout } = await startReady(); 370 | 371 | await Promise.all([ 372 | waitFor(stdout, /app exited/), 373 | app.close() 374 | ]); 375 | 376 | await Promise.all([ 377 | waitFor(stdout, /main window open/), 378 | app.restart() 379 | ]); 380 | }); 381 | 382 | it('can restart an app after it is destroyed', async () => { 383 | const { app, stdout } = await startReady(); 384 | 385 | await Promise.all([ 386 | waitFor(stdout, /app exited/), 387 | app.destroy() 388 | ]); 389 | 390 | await Promise.all([ 391 | ready(stdout), 392 | app.restart() 393 | ]); 394 | }); 395 | }); 396 | }); 397 | 398 | describe('cli', () => { 399 | const cli = path.resolve(__dirname, '../bin/cli.js'); 400 | let app; 401 | 402 | afterEach(async () => { 403 | if (!app) { 404 | return; 405 | } 406 | 407 | const tmp = app; 408 | app = null; 409 | 410 | await new Promise(resolve => { 411 | tmp.once('exit', () => resolve()); 412 | 413 | // destroying the io is necessary on linux and osx 414 | tmp.stdout.destroy(); 415 | tmp.stderr.destroy(); 416 | 417 | tmp.kill(); 418 | }); 419 | }); 420 | 421 | const start = async ({ args, cwd, env, patterns }) => { 422 | if (patterns && patterns.length) { 423 | const pkgPath = path.resolve(cwd, 'package.json'); 424 | const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8')); 425 | pkg.electronmon = { patterns }; 426 | await fs.writeFile(pkgPath, JSON.stringify(pkg)); 427 | } 428 | 429 | app = spawn(process.execPath, [cli].concat(args), { 430 | env, 431 | cwd, 432 | stdio: ['ignore', 'pipe', 'pipe'] 433 | }); 434 | 435 | return app; 436 | }; 437 | 438 | runIntegrationSuite(start); 439 | }); 440 | }); 441 | --------------------------------------------------------------------------------