UNPKG

nightmare

Version:

A high-level browser automation library.

735 lines (625 loc) 18.1 kB
/** * Module Dependencies */ var parent = require('./ipc')(process) var electron = require('electron') var BrowserWindow = electron.BrowserWindow var defaults = require('deep-defaults') var join = require('path').join var sliced = require('sliced') var renderer = require('electron').ipcMain var app = require('electron').app var urlFormat = require('url') var FrameManager = require('./frame-manager') // URL protocols that don't need to be checked for validity const KNOWN_PROTOCOLS = ['http', 'https', 'file', 'about', 'javascript'] // Property for tracking whether a window is ready for interaction const IS_READY = Symbol('isReady') /** * Handle uncaught exceptions in the main electron process */ process.on('uncaughtException', function(err) { parent.emit('uncaughtException', err.stack || err.message || String(err)) }) /** * Update the app paths */ if (process.argv.length < 3) { throw new Error(`Too few runner arguments: ${JSON.stringify(process.argv)}`) } var processArgs = JSON.parse(process.argv[2]) var paths = processArgs.paths if (paths) { for (let i in paths) { app.setPath(i, paths[i]) } } var switches = processArgs.switches if (switches) { for (let i in switches) { app.commandLine.appendSwitch(i, switches[i]) } } /** * Hide the dock */ // app.dock is not defined when running // electron in a platform other than OS X if (!processArgs.dock && app.dock) { app.dock.hide() } /** * Set the client certificate by subjectName if processArgs.certificateSubjectName is defined */ if (processArgs.certificateSubjectName) { app.on( 'select-client-certificate', (event, webContents, url, list, callback) => { for (var i = 0; i < list.length; i++) { if (list[i].subjectName === processArgs.certificateSubjectName) { callback(list[i]) return } } // defaults to first if the subject name is not available callback(list[0]) } ) } /** * Listen for the app being "ready" */ app.on('ready', function() { var win, frameManager, options, closed /** * create a browser window */ parent.respondTo('browser-initialize', function(opts, done) { options = defaults(opts || {}, { show: false, alwaysOnTop: true, webPreferences: { preload: join(__dirname, 'preload.js'), nodeIntegration: false } }) /** * Create a new Browser Window */ win = new BrowserWindow(options) if (options.show && options.openDevTools) { if (typeof options.openDevTools === 'object') { win.openDevTools(options.openDevTools) } else { win.openDevTools() } } /** * Window Docs: * https://github.com/atom/electron/blob/master/docs/api/browser-window.md */ frameManager = FrameManager(win) /** * Window options */ win.webContents.setAudioMuted(true) /** * Sets user agent. */ if (options.userAgent) { win.webContents.setUserAgent(options.userAgent) } /** * Pass along web content events */ renderer.on('page', function(_sender /*, arguments, ... */) { parent.emit.apply(parent, ['page'].concat(sliced(arguments, 1))) }) renderer.on('console', function(sender, type, args) { parent.emit.apply(parent, ['console', type].concat(args)) }) win.webContents.on('did-finish-load', forward('did-finish-load')) win.webContents.on('did-fail-load', forward('did-fail-load')) win.webContents.on( 'did-fail-provisional-load', forward('did-fail-provisional-load') ) win.webContents.on( 'did-frame-finish-load', forward('did-frame-finish-load') ) win.webContents.on('did-start-loading', forward('did-start-loading')) win.webContents.on('did-stop-loading', forward('did-stop-loading')) win.webContents.on( 'did-get-response-details', forward('did-get-response-details') ) win.webContents.on( 'did-get-redirect-request', forward('did-get-redirect-request') ) win.webContents.on('dom-ready', forward('dom-ready')) win.webContents.on('page-favicon-updated', forward('page-favicon-updated')) win.webContents.on('new-window', forward('new-window')) win.webContents.on('will-navigate', forward('will-navigate')) win.webContents.on('crashed', forward('crashed')) win.webContents.on('plugin-crashed', forward('plugin-crashed')) win.webContents.on('destroyed', forward('destroyed')) win.webContents.on( 'media-started-playing', forward('media-started-playing') ) win.webContents.on('media-paused', forward('media-paused')) win.webContents.on('close', _e => { closed = true }) var loadwatch win.webContents.on('did-start-loading', function() { if (win.webContents.isLoadingMainFrame()) { if (options.loadTimeout) { loadwatch = setTimeout(function() { win.webContents.stop() }, options.loadTimeout) } setIsReady(false) } }) win.webContents.on('did-stop-loading', function() { clearTimeout(loadwatch) setIsReady(true) }) setIsReady(true) done() }) /** * Parent actions */ /** * goto */ parent.respondTo('goto', function(url, headers, timeout, done) { if (!url || typeof url !== 'string') { return done(new Error('goto: `url` must be a non-empty string')) } var httpReferrer = '' var extraHeaders = '' for (var key in headers) { if (key.toLowerCase() == 'referer') { httpReferrer = headers[key] continue } extraHeaders += key + ': ' + headers[key] + '\n' } var loadUrlOptions = { extraHeaders: extraHeaders } httpReferrer && (loadUrlOptions.httpReferrer = httpReferrer) if (win.webContents.getURL() == url) { done() } else { var responseData = {} var domLoaded = false var timer = setTimeout(function() { // If the DOM loaded before timing out, consider the load successful. var error = domLoaded ? undefined : { message: 'navigation error', code: -7, // chromium's generic networking timeout code details: `Navigation timed out after ${timeout} ms`, url: url } // Even if "successful," note that some things didn't finish. responseData.details = `Not all resources loaded after ${timeout} ms` cleanup(error, responseData) }, timeout) function handleFailure(event, code, detail, failedUrl, isMainFrame) { if (isMainFrame) { cleanup({ message: 'navigation error', code: code, details: detail, url: failedUrl || url }) } } function handleDetails( event, status, newUrl, oldUrl, statusCode, method, referrer, headers, resourceType ) { if (resourceType === 'mainFrame') { responseData = { url: newUrl, code: statusCode, method: method, referrer: referrer, headers: headers } } } function handleDomReady() { domLoaded = true } // We will have already unsubscribed if load failed, so assume success. function handleFinish(_event) { cleanup(null, responseData) } function cleanup(err, data) { clearTimeout(timer) win.webContents.removeListener('did-fail-load', handleFailure) win.webContents.removeListener( 'did-fail-provisional-load', handleFailure ) win.webContents.removeListener( 'did-get-response-details', handleDetails ) win.webContents.removeListener('dom-ready', handleDomReady) win.webContents.removeListener('did-finish-load', handleFinish) setIsReady(true) // wait a tick before notifying to resolve race conditions for events setImmediate(() => done(err, data)) } // In most environments, loadURL handles this logic for us, but in some // it just hangs for unhandled protocols. Mitigate by checking ourselves. function canLoadProtocol(protocol, callback) { protocol = (protocol || '').replace(/:$/, '') if (!protocol || KNOWN_PROTOCOLS.includes(protocol)) { return callback(true) } electron.protocol.isProtocolHandled(protocol, callback) } function startLoading() { // abort any pending loads first if (win.webContents.isLoading()) { parent.emit('log', 'aborting pending page load') win.webContents.once('did-stop-loading', function() { startLoading(true) }) return win.webContents.stop() } win.webContents.on('did-fail-load', handleFailure) win.webContents.on('did-fail-provisional-load', handleFailure) win.webContents.on('did-get-response-details', handleDetails) win.webContents.on('dom-ready', handleDomReady) win.webContents.on('did-finish-load', handleFinish) win.webContents.loadURL(url, loadUrlOptions) // javascript: URLs *may* trigger page loads; wait a bit to see if (protocol === 'javascript:') { setTimeout(function() { if (!win.webContents.isLoadingMainFrame()) { done(null, { url: url, code: 200, method: 'GET', referrer: win.webContents.getURL(), headers: {} }) } }, 10) } } var protocol = urlFormat.parse(url).protocol canLoadProtocol(protocol, function startLoad(canLoad) { if (canLoad) { parent.emit( 'log', `Navigating: "${url}", headers: ${extraHeaders || '[none]'}, timeout: ${timeout}` ) return startLoading() } cleanup({ message: 'navigation error', code: -1000, details: 'unhandled protocol', url: url }) }) } }) /** * javascript */ parent.respondTo('javascript', function(src, done) { var onresponse = (event, response) => { renderer.removeListener('error', onerror) renderer.removeListener('log', onlog) done(null, response) } var onerror = (event, err) => { renderer.removeListener('log', onlog) renderer.removeListener('response', onresponse) done(err) } var onlog = (event, args) => parent.emit.apply(parent, ['log'].concat(args)) renderer.once('response', onresponse) renderer.once('error', onerror) renderer.on('log', onlog) //parent.emit('log', 'about to execute javascript: ' + src); win.webContents.executeJavaScript(src) }) /** * css */ parent.respondTo('css', function(css, done) { win.webContents.insertCSS(css) done() }) /** * size */ parent.respondTo('size', function(width, height, done) { win.setSize(width, height) done() }) parent.respondTo('useragent', function(useragent, done) { win.webContents.setUserAgent(useragent) done() }) /** * type */ parent.respondTo('type', function(value, done) { var chars = String(value).split('') function type() { var ch = chars.shift() if (ch === undefined) { return done() } // keydown win.webContents.sendInputEvent({ type: 'keyDown', keyCode: ch }) // keypress win.webContents.sendInputEvent({ type: 'char', keyCode: ch }) // keyup win.webContents.sendInputEvent({ type: 'keyUp', keyCode: ch }) // defer function into next event loop setTimeout(type, options.typeInterval) } // start type() }) /** * Insert */ parent.respondTo('insert', function(value, done) { win.webContents.insertText(String(value)) done() }) /** * screenshot */ parent.respondTo('screenshot', function(path, clip, done) { // https://gist.github.com/twolfson/0d374d9d7f26eefe7d38 var args = [ function handleCapture(img) { done(null, img.toPNG()) } ] if (clip) args.unshift(clip) frameManager.requestFrame(function() { win.capturePage.apply(win, args) }) }) /** * html */ parent.respondTo('html', function(path, saveType, done) { // https://github.com/atom/electron/blob/master/docs/api/web-contents.md#webcontentssavepagefullpath-savetype-callback saveType = saveType || 'HTMLComplete' win.webContents.savePage(path, saveType, function(err) { done(err) }) }) /** * pdf */ parent.respondTo('pdf', function(path, options, done) { // https://github.com/fraserxu/electron-pdf/blob/master/index.js#L98 options = defaults(options || {}, { marginType: 0, printBackground: true, printSelectionOnly: false, landscape: false }) win.webContents.printToPDF(options, function(err, data) { if (err) return done(err) done(null, data) }) }) /** * Get cookies */ parent.respondTo('cookie.get', function(query, done) { var details = Object.assign( { url: win.webContents.getURL() }, query ) parent.emit('log', 'getting cookie: ' + JSON.stringify(details)) win.webContents.session.cookies.get(details, function(err, cookies) { if (err) return done(err) done(null, details.name ? cookies[0] : cookies) }) }) /** * Set cookies */ parent.respondTo('cookie.set', function(cookies, done) { var pending = cookies.length for (var i = 0, cookie; (cookie = cookies[i]); i++) { var details = Object.assign( { url: win.webContents.getURL() }, cookie ) parent.emit('log', 'setting cookie: ' + JSON.stringify(details)) win.webContents.session.cookies.set(details, function(err) { if (err) done(err) else if (!--pending) done() }) } }) /** * Clear cookie */ parent.respondTo('cookie.clear', function(cookies, done) { var url = win.webContents.getURL() var getCookies = cb => cb(null, cookies) if (cookies.length == 0) { getCookies = cb => win.webContents.session.cookies.get({ url: url }, (error, cookies) => { cb(error, cookies.map(cookie => cookie.name)) }) } getCookies((error, cookies) => { var pending = cookies.length //if no cookies, return if (pending == 0) { return done() } parent.emit('log', 'listing params', cookies) //for each cookie name in cookies, for (var i = 0, cookie; (cookie = cookies[i]); i++) { //remove the cookie from the url win.webContents.session.cookies.remove(url, cookie, function(err) { if (err) done(err) else if (!--pending) done() }) } }) }) /** * Clear all cookies */ parent.respondTo('cookie.clearAll', function(done) { win.webContents.session.clearStorageData( { storages: ['cookies'] }, function(err) { if (err) return done(err) return done() } ) }) /** * Add custom functionality */ parent.respondTo('action', function(name, fntext, done) { var fn = new Function( 'with(this){ parent.emit("log", "adding action for ' + name + '"); return ' + fntext + '}' ).call({ require: require, parent: parent }) fn(name, options, parent, win, renderer, function(err) { if (err) return done(err) return done() }) }) /** * Continue */ parent.respondTo('continue', function(done) { if (isReady()) { done() } else { parent.emit('log', 'waiting for window to load...') win.once('did-change-is-ready', function() { parent.emit('log', 'window became ready: ' + win.webContents.getURL()) done() }) } }) /** * Authentication */ var loginListener parent.respondTo('authentication', function(login, password, done) { var currentUrl var tries = 0 if (loginListener) { win.webContents.removeListener('login', loginListener) } loginListener = function(webContents, request, authInfo, callback) { tries++ parent.emit('log', `authenticating against ${request.url}, try #${tries}`) if (currentUrl != request.url) { currentUrl = request.url tries = 1 } if (tries >= options.maxAuthRetries) { parent.emit('die', 'problem authenticating, check your credentials') } else { callback(login, password) } } win.webContents.on('login', loginListener) done() }) /** * Kill the electron app */ parent.respondTo('quit', function(done) { app.quit() done() }) /** * Send "ready" event to the parent process */ parent.emit('ready', { electron: process.versions['electron'], chrome: process.versions['chrome'] }) /** * Check whether the window is ready for interaction */ function isReady() { return win[IS_READY] } /** * Set whether the window is ready for interaction */ function setIsReady(ready) { ready = !!ready if (ready !== win[IS_READY]) { win[IS_READY] = ready win.emit('did-change-is-ready', ready) } } /** * Forward events */ function forward(name) { return function(_event) { // NOTE: the raw Electron event used to be forwarded here, but we now send // an empty event in its place -- the raw event is not JSON serializable. if (!closed) { parent.emit.apply(parent, [name, {}].concat(sliced(arguments, 1))) } } } })