diff --git a/app/index.js b/app/index.js index b97f7f30..f78e0767 100644 --- a/app/index.js +++ b/app/index.js @@ -35,24 +35,14 @@ if (process.platform === 'win32') { } // Native -const {resolve, isAbsolute} = require('path'); -const {homedir} = require('os'); +const {resolve} = require('path'); // Packages -const {parse: parseUrl} = require('url'); -const {app, BrowserWindow, shell, Menu} = require('electron'); +const {app, BrowserWindow, Menu} = require('electron'); const {gitDescribe} = require('git-describe'); -const uuid = require('uuid'); -const fileUriToPath = require('file-uri-to-path'); const isDev = require('electron-is-dev'); -// Ours -const AutoUpdater = require('./auto-updater'); -const toElectronBackgroundColor = require('./utils/to-electron-background-color'); const AppMenu = require('./menus/menu'); -const createRPC = require('./rpc'); -const notify = require('./notify'); -const fetchNotifications = require('./notifications'); const config = require('./config'); app.commandLine.appendSwitch('js-flags', '--harmony-async-await'); @@ -61,7 +51,8 @@ app.commandLine.appendSwitch('js-flags', '--harmony-async-await'); config.setup(); const plugins = require('./plugins'); -const Session = require('./session'); + +const Window = require('./ui/window'); const windowSet = new Set([]); @@ -103,7 +94,7 @@ console.log('electron will open', url); app.on('ready', () => installDevExtensions(isDev).then(() => { function createWindow(fn, options = {}) { - let cfg = plugins.getDecoratedConfig(); + const cfg = plugins.getDecoratedConfig(); const winSet = config.getWin(); let [startX, startY] = winSet.position; @@ -139,247 +130,17 @@ app.on('ready', () => installDevExtensions(isDev).then(() => { } } - const browserDefaults = { - width, - height, - minHeight: 190, - minWidth: 370, - titleBarStyle: 'hidden-inset', // macOS only - title: 'Hyper.app', - backgroundColor: toElectronBackgroundColor(cfg.backgroundColor || '#000'), - // we want to go frameless on windows and linux - frame: process.platform === 'darwin', - transparent: process.platform === 'darwin', - icon: resolve(__dirname, 'static/icon.png'), - // we only want to show when the prompt is ready for user input - // HYPERTERM_DEBUG for backwards compatibility with hyperterm - show: process.env.HYPER_DEBUG || process.env.HYPERTERM_DEBUG || isDev, - x: startX, - y: startY, - acceptFirstMouse: true - }; - const browserOptions = plugins.getDecoratedBrowserOptions(browserDefaults); - - const win = new BrowserWindow(browserOptions); - windowSet.add(win); - - win.loadURL(url); - - const rpc = createRPC(win); - const sessions = new Map(); - - // config changes - const cfgUnsubscribe = config.subscribe(() => { - const cfg_ = plugins.getDecoratedConfig(); - - // notify renderer - win.webContents.send('config change'); - - // notify user that shell changes require new sessions - if (cfg_.shell !== cfg.shell || - JSON.stringify(cfg_.shellArgs) !== JSON.stringify(cfg.shellArgs)) { - notify( - 'Shell configuration changed!', - 'Open a new tab or window to start using the new shell' - ); - } - - // update background color if necessary - cfg = cfg_; - }); - - rpc.on('init', () => { - // we update the backgroundColor once the init is called. - // when we do a win.reload() we need need to reset the backgroundColor - win.setBackgroundColor(toElectronBackgroundColor(cfg.backgroundColor || '#000')); - win.show(); - - // If no callback is passed to createWindow, - // a new session will be created by default. - if (!fn) { - fn = win => win.rpc.emit('termgroup add req'); - } - - // app.windowCallback is the createWindow callback - // that can be set before the 'ready' app event - // and createWindow deifinition. It's executed in place of - // the callback passed as parameter, and deleted right after. - (app.windowCallback || fn)(win); - delete (app.windowCallback); - - fetchNotifications(win); - // auto updates - if (!isDev && process.platform !== 'linux') { - AutoUpdater(win); - } else { - console.log('ignoring auto updates during dev'); - } - }); - - rpc.on('new', ({rows = 40, cols = 100, cwd = process.argv[1] && isAbsolute(process.argv[1]) ? process.argv[1] : homedir(), splitDirection}) => { - const shell = cfg.shell; - const shellArgs = cfg.shellArgs && Array.from(cfg.shellArgs); - - initSession({rows, cols, cwd, shell, shellArgs}, (uid, session) => { - sessions.set(uid, session); - rpc.emit('session add', { - rows, - cols, - uid, - splitDirection, - shell: session.shell, - pid: session.pty.pid - }); - - session.on('data', data => { - rpc.emit('session data', uid + data); - }); - - session.on('exit', () => { - rpc.emit('session exit', {uid}); - sessions.delete(uid); - }); - }); - }); - - rpc.on('exit', ({uid}) => { - const session = sessions.get(uid); - if (session) { - session.exit(); - } else { - console.log('session not found by', uid); - } - }); - - rpc.on('unmaximize', () => { - win.unmaximize(); - }); - - rpc.on('maximize', () => { - win.maximize(); - }); - - rpc.on('resize', ({uid, cols, rows}) => { - const session = sessions.get(uid); - session.resize({cols, rows}); - }); - - rpc.on('data', ({uid, data, escaped}) => { - const session = sessions.get(uid); - - if (escaped) { - const escapedData = session.shell.endsWith('cmd.exe') ? - `"${data}"` : // This is how cmd.exe does it - `'${data.replace(/'/g, `'\\''`)}'`; // Inside a single-quoted string nothing is interpreted - - session.write(escapedData); - } else { - session.write(data); - } - }); - - rpc.on('open external', ({url}) => { - shell.openExternal(url); - }); - - rpc.win.on('move', () => { - rpc.emit('move'); - }); - - rpc.on('open hamburger menu', ({x, y}) => { - Menu.getApplicationMenu().popup(Math.ceil(x), Math.ceil(y)); - }); - - rpc.on('minimize', () => { - win.minimize(); - }); - - rpc.on('close', () => { - win.close(); - }); - - const deleteSessions = () => { - sessions.forEach((session, key) => { - session.removeAllListeners(); - session.destroy(); - sessions.delete(key); - }); - }; - - // we reset the rpc channel only upon - // subsequent refreshes (ie: F5) - let i = 0; - win.webContents.on('did-navigate', () => { - if (i++) { - deleteSessions(); - } - }); - - // If file is dropped onto the terminal window, navigate event is prevented - // and his path is added to active session. - win.webContents.on('will-navigate', (event, url) => { - const protocol = typeof url === 'string' && parseUrl(url).protocol; - if (protocol === 'file:') { - event.preventDefault(); - - const path = fileUriToPath(url); - - rpc.emit('session data send', {data: path, escaped: true}); - } else if (protocol === 'http:' || protocol === 'https:') { - event.preventDefault(); - rpc.emit('session data send', {data: url}); - } - }); - - // expose internals to extension authors - win.rpc = rpc; - win.sessions = sessions; - - const load = () => { - plugins.onWindow(win); - }; - - // load plugins - load(); - - const pluginsUnsubscribe = plugins.subscribe(err => { - if (!err) { - load(); - win.webContents.send('plugins change'); - } - }); - - // Keep track of focus time of every window, to figure out - // which one of the existing window is the last focused. - // Works nicely even if a window is closed and removed. - const updateFocusTime = () => { - win.focusTime = process.uptime(); - }; - win.on('focus', () => { - updateFocusTime(); - }); - // Ensure focusTime is set on window open. The focus event doesn't - // fire from the dock (see bug #583) - updateFocusTime(); + const hwin = new Window({width, height, x: startX, y: startY}, cfg, fn); + windowSet.add(hwin); + hwin.loadURL(url); // the window can be closed by the browser process itself - win.on('close', () => { - config.winRecord(win); - windowSet.delete(win); - rpc.destroy(); - deleteSessions(); - cfgUnsubscribe(); - pluginsUnsubscribe(); + hwin.on('close', () => { + hwin.clean(); + windowSet.delete(this); }); - // Same deal as above, grabbing the window titlebar when the window - // is maximized on Windows results in unmaximize, without hitting any - // app buttons - for (const ev of ['maximize', 'unmaximize', 'minimize', 'restore']) { - win.on(ev, () => rpc.emit('windowGeometry change')); - } - - win.on('closed', () => { + hwin.on('closed', () => { if (process.platform !== 'darwin' && windowSet.size === 0) { app.quit(); } @@ -437,10 +198,6 @@ app.on('ready', () => installDevExtensions(isDev).then(() => { console.error('Error while loading devtools extensions', err); })); -function initSession(opts, fn) { - fn(uuid.v4(), new Session(opts)); -} - app.on('open-file', (event, path) => { const lastWindow = app.getLastFocusedWindow(); const callback = win => win.rpc.emit('open file', {path}); diff --git a/app/ui/window.js b/app/ui/window.js new file mode 100644 index 00000000..6fa8a851 --- /dev/null +++ b/app/ui/window.js @@ -0,0 +1,239 @@ +const {app, BrowserWindow, shell, Menu} = require('electron'); +const {isAbsolute} = require('path'); +const {parse: parseUrl} = require('url'); +const uuid = require('uuid'); +const fileUriToPath = require('file-uri-to-path'); +const isDev = require('electron-is-dev'); +const AutoUpdater = require('../auto-updater'); +const toElectronBackgroundColor = require('../utils/to-electron-background-color'); +const {icon, cfgDir} = require('../config/paths'); +const createRPC = require('../rpc'); +const notify = require('../notify'); +const fetchNotifications = require('../notifications'); +const Session = require('../session'); + +module.exports = class Window extends BrowserWindow { + constructor(options, cfg, fn) { + const opts = Object.assign({ + minWidth: 370, + minHeight: 190, + backgroundColor: toElectronBackgroundColor(cfg.backgroundColor || '#000'), + titleBarStyle: 'hidden-inset', + title: 'Hyper.app', + // we want to go frameless on windows and linux + frame: process.platform === 'darwin', + transparent: process.platform === 'darwin', + icon, + show: process.env.HYPER_DEBUG || process.env.HYPERTERM_DEBUG || isDev, + acceptFirstMouse: true + }, options); + const modOpts = app.plugins.getDecoratedBrowserOptions(opts); + super(modOpts); + const rpc = createRPC(this); + const sessions = new Map(); + + // config changes + const cfgUnsubscribe = app.config.subscribe(() => { + const cfg_ = app.plugins.getDecoratedConfig(); + + // notify renderer + this.webContents.send('config change'); + + // notify user that shell changes require new sessions + if (cfg_.shell !== cfg.shell || + JSON.stringify(cfg_.shellArgs) !== JSON.stringify(cfg.shellArgs)) { + notify( + 'Shell configuration changed!', + 'Open a new tab or window to start using the new shell' + ); + } + + // update background color if necessary + cfg = cfg_; + }); + + rpc.on('init', () => { + this.setBackgroundColor(toElectronBackgroundColor(cfg.backgroundColor || '#000')); + this.show(); + + // If no callback is passed to createWindow, + // a new session will be created by default. + if (!fn) { + fn = win => win.rpc.emit('termgroup add req'); + } + + // app.windowCallback is the createWindow callback + // that can be set before the 'ready' app event + // and createWindow deifinition. It's executed in place of + // the callback passed as parameter, and deleted right after. + (app.windowCallback || fn)(this); + delete (app.windowCallback); + fetchNotifications(this); + // auto updates + if (!isDev && process.platform !== 'linux') { + AutoUpdater(this); + } else { + console.log('ignoring auto updates during dev'); + } + }); + + rpc.on('new', options => { + const opts = Object.assign({ + rows: 40, + cols: 100, + cwd: process.argv[1] && isAbsolute(process.argv[1]) ? process.argv[1] : cfgDir, + splitDirection: undefined, + shell: cfg.shell, + shellArgs: cfg.shellArgs && Array.from(cfg.shellArgs) + }, options); + + const initSession = (opts, fn) => { + fn(uuid.v4(), new Session(opts)); + }; + + initSession(opts, (uid, session) => { + sessions.set(uid, session); + rpc.emit('session add', { + rows: opts.rows, + cols: opts.cols, + uid, + splitDirection: opts.splitDirection, + shell: session.shell, + pid: session.pty.pid + }); + + session.on('data', data => { + rpc.emit('session data', uid + data); + }); + + session.on('exit', () => { + rpc.emit('session exit', {uid}); + }); + }); + }); + rpc.on('exit', ({uid}) => { + const session = sessions.get(uid); + if (session) { + session.exit(); + } else { + console.log('session not found by', uid); + } + }); + rpc.on('unmaximize', () => { + this.unmaximize(); + }); + rpc.on('maximize', () => { + this.maximize(); + }); + rpc.on('minimize', () => { + this.minimize(); + }); + rpc.on('resize', ({uid, cols, rows}) => { + const session = sessions.get(uid); + session.resize({cols, rows}); + }); + rpc.on('data', ({uid, data, escaped}) => { + const session = sessions.get(uid); + + if (escaped) { + const escapedData = session.shell.endsWith('cmd.exe') ? + `"${data}"` : // This is how cmd.exe does it + `'${data.replace(/'/g, `'\\''`)}'`; // Inside a single-quoted string nothing is interpreted + + session.write(escapedData); + } else { + session.write(data); + } + }); + rpc.on('open external', ({url}) => { + shell.openExternal(url); + }); + rpc.on('open hamburger menu', ({x, y}) => { + Menu.getApplicationMenu().popup(Math.ceil(x), Math.ceil(y)); + }); + // Same deal as above, grabbing the window titlebar when the window + // is maximized on Windows results in unmaximize, without hitting any + // app buttons + for (const ev of ['maximize', 'unmaximize', 'minimize', 'restore']) { + this.on(ev, () => rpc.emit('windowGeometry change')); + } + rpc.win.on('move', () => { + rpc.emit('move'); + }); + rpc.on('close', () => { + this.close(); + }); + const deleteSessions = () => { + sessions.forEach((session, key) => { + session.removeAllListeners(); + session.destroy(); + sessions.delete(key); + }); + }; + // we reset the rpc channel only upon + // subsequent refreshes (ie: F5) + let i = 0; + this.webContents.on('did-navigate', () => { + if (i++) { + deleteSessions(); + } + }); + + // If file is dropped onto the terminal window, navigate event is prevented + // and his path is added to active session. + this.webContents.on('will-navigate', (event, url) => { + const protocol = typeof url === 'string' && parseUrl(url).protocol; + if (protocol === 'file:') { + event.preventDefault(); + + const path = fileUriToPath(url); + + rpc.emit('session data send', {data: path, escaped: true}); + } else if (protocol === 'http:' || protocol === 'https:') { + event.preventDefault(); + rpc.emit('session data send', {data: url}); + } + }); + + // expose internals to extension authors + this.rpc = rpc; + this.sessions = sessions; + + const load = () => { + app.plugins.onWindow(this); + }; + + // load plugins + load(); + + const pluginsUnsubscribe = app.plugins.subscribe(err => { + if (!err) { + load(); + this.webContents.send('plugins change'); + } + }); + + // Keep track of focus time of every window, to figure out + // which one of the existing window is the last focused. + // Works nicely even if a window is closed and removed. + const updateFocusTime = () => { + this.focusTime = process.uptime(); + }; + + this.on('focus', () => { + updateFocusTime(); + }); + + // the window can be closed by the browser process itself + this.clean = () => { + app.config.winRecord(this); + rpc.destroy(); + deleteSessions(); + cfgUnsubscribe(); + pluginsUnsubscribe(); + }; + // Ensure focusTime is set on window open. The focus event doesn't + // fire from the dock (see bug #583) + updateFocusTime(); + } +};