From f324a67bb9b643cd1afeddd40781e5b4af4125d0 Mon Sep 17 00:00:00 2001 From: Phung Tuan Hoang Date: Mon, 17 Dec 2018 06:46:37 +0800 Subject: [PATCH] Add tray icon and set single instance mode to improve startup time on Windows (#3324) Fixes #2935, #2980 --- app/index.js | 146 ++++++++++++++++- app/ui/window.js | 416 +++++++++++++++++++++++++---------------------- 2 files changed, 357 insertions(+), 205 deletions(-) diff --git a/app/index.js b/app/index.js index eb2fc7a6..f9620907 100644 --- a/app/index.js +++ b/app/index.js @@ -1,3 +1,18 @@ +const {app} = require('electron'); + +// Multiple instance check on windows +if (process.platform === 'win32') { + const isSecondInstance = app.makeSingleInstance((commandLine, workingDirectory) => { + // When tried to run a second instance, load a preloaded window instead on the main instance + app.loadPreloadedWindow(workingDirectory); + app.createPreloadedWindow(); + }); + + if (isSecondInstance) { + app.quit(); + } +} + // Print diagnostic information for a few arguments instead of running Hyper. if (['--help', '-v', '--version'].includes(process.argv[1])) { const {version} = require('./package'); @@ -52,7 +67,7 @@ if (process.platform === 'win32') { const {resolve} = require('path'); // Packages -const {app, BrowserWindow, Menu} = require('electron'); +const {BrowserWindow, Menu, Tray} = require('electron'); const {gitDescribe} = require('git-describe'); const isDev = require('electron-is-dev'); @@ -108,7 +123,10 @@ const url = 'file://' + resolve(isDev ? __dirname : app.getAppPath(), 'index.htm //eslint-disable-next-line no-console console.log('electron will open', url); -app.on('ready', () => +let tray = null; +const {icon} = require('./config/paths'); + +app.on('ready', () => { installDevExtensions(isDev) .then(() => { function createWindow(fn, options = {}) { @@ -165,11 +183,16 @@ app.on('ready', () => windowSet.delete(hwin); }); - hwin.on('closed', () => { - if (process.platform !== 'darwin' && windowSet.size === 0) { - app.quit(); - } - }); + // On Windows, do not quit app when all Hyper windows are closed + if (process.platform === 'win32') { + hwin.attachRPC(); + } else { + hwin.on('closed', () => { + if (process.platform !== 'darwin' && windowSet.size === 0) { + app.quit(); + } + }); + } return hwin; } @@ -189,6 +212,82 @@ app.on('ready', () => } }); + // Create a hidden window without rpc + // Reference to this preloaded window is stored in app.preloadedWindow + function createPreloadedWindow(fn, options = {}) { + const cfg = plugins.getDecoratedConfig(); + + const winSet = config.getWin(); + let [startX, startY] = winSet.position; + + const [width, height] = options.size ? options.size : cfg.windowSize || winSet.size; + const {screen} = require('electron'); + + const winPos = options.position; + + // Open the new window roughly the height of the header away from the + // previous window. This also ensures in multi monitor setups that the + // new terminal is on the correct screen. + const focusedWindow = BrowserWindow.getFocusedWindow() || app.getLastFocusedWindow(); + // In case of options defaults position and size, we should ignore the focusedWindow. + if (winPos !== undefined) { + [startX, startY] = winPos; + } else if (focusedWindow) { + const points = focusedWindow.getPosition(); + const currentScreen = screen.getDisplayNearestPoint({ + x: points[0], + y: points[1] + }); + + const biggestX = points[0] + 100 + width - currentScreen.bounds.x; + const biggestY = points[1] + 100 + height - currentScreen.bounds.y; + + if (biggestX > currentScreen.size.width) { + startX = 50; + } else { + startX = points[0] + 34; + } + if (biggestY > currentScreen.size.height) { + startY = 50; + } else { + startY = points[1] + 34; + } + } + + if (!windowUtils.positionIsValid([startX, startY])) { + [startX, startY] = config.windowDefaults.windowPosition; + } + + const hwin = new Window({width, height, x: startX, y: startY, show: false}, cfg, fn); + windowSet.add(hwin); + hwin.loadURL(url); + + // the window can be closed by the browser process itself + hwin.on('close', () => { + hwin.clean(); + windowSet.delete(hwin); + }); + + app.preloadedWindow = hwin; + } + + // Preloads a window on start up + app.createPreloadedWindow = createPreloadedWindow; + app.createPreloadedWindow(); + + // Loads a preloaded window (attach all rpc related functions) + function loadPreloadedWindow(workingDirectory) { + if (app.preloadedWindow) { + app.preloadedWindow.show(); + app.preloadedWindow.attachRPC(workingDirectory); + app.preloadedWindow.webContents.emit('did-finish-load'); + } + + app.preloadedWindow = null; + } + + app.loadPreloadedWindow = loadPreloadedWindow; + const makeMenu = () => { const menu = plugins.decorateMenu(AppMenu.createMenu(createWindow, plugins.getLoadedPluginVersions)); @@ -225,12 +324,41 @@ app.on('ready', () => } installCLI(false); } + + // Creates a system tray icon on Windows + if (process.platform === 'win32') { + tray = new Tray(icon); + + const contextMenu = Menu.buildFromTemplate([ + { + label: 'New Window', + type: 'normal', + click: () => { + app.loadPreloadedWindow(); + app.createPreloadedWindow(); + } + }, + {type: 'separator'}, + { + label: 'Exit', + type: 'normal', + click: () => { + if (app.preloadedWindow) { + app.preloadedWindow.emit('close'); + } + app.quit(); + } + } + ]); + tray.setToolTip('Hyper'); + tray.setContextMenu(contextMenu); + } }) .catch(err => { //eslint-disable-next-line no-console console.error('Error while loading devtools extensions', err); - }) -); + }); +}); app.on('open-file', (event, path) => { const lastWindow = app.getLastFocusedWindow(); diff --git a/app/ui/window.js b/app/ui/window.js index 751583c5..c1c0984f 100644 --- a/app/ui/window.js +++ b/app/ui/window.js @@ -33,7 +33,6 @@ module.exports = class Window { options_ ); const window = new BrowserWindow(app.plugins.getDecoratedBrowserOptions(winOpts)); - const rpc = createRPC(window); const sessions = new Map(); const updateBackgroundColor = () => { @@ -59,214 +58,239 @@ module.exports = class Window { cfg = cfg_; }); - rpc.on('init', () => { - window.show(); - updateBackgroundColor(); + const attachRPC = workingDirectory => { + const rpc = createRPC(window); - // 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'); + rpc.on('init', () => { + window.show(); + updateBackgroundColor(); + + // 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 definition. It's executed in place of + // the callback passed as parameter, and deleted right after. + (app.windowCallback || fn)(window); + delete app.windowCallback; + fetchNotifications(window); + // auto updates + if (!isDev) { + updater(window); + } else { + //eslint-disable-next-line no-console + console.log('ignoring auto updates during dev'); + } + }); + + rpc.on('new', options => { + let cwd = null; + if (workingDirectory) { + // this is the case when on Windows and second instance tried to run + cwd = workingDirectory; + } else { + cwd = process.argv[1] && isAbsolute(process.argv[1]) ? process.argv[1] : cfgDir; + } + + const sessionOpts = Object.assign( + { + rows: 40, + cols: 100, + cwd: cwd, + splitDirection: undefined, + shell: cfg.shell, + shellArgs: cfg.shellArgs && Array.from(cfg.shellArgs) + }, + options + ); + + const initSession = (opts, fn_) => { + fn_(uuid.v4(), new Session(opts)); + }; + + initSession(sessionOpts, (uid, session) => { + sessions.set(uid, session); + rpc.emit('session add', { + rows: sessionOpts.rows, + cols: sessionOpts.cols, + uid, + splitDirection: sessionOpts.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(); + } + }); + rpc.on('unmaximize', () => { + window.unmaximize(); + }); + rpc.on('maximize', () => { + window.maximize(); + }); + rpc.on('minimize', () => { + window.minimize(); + }); + rpc.on('resize', ({uid, cols, rows}) => { + const session = sessions.get(uid); + if (session) { + session.resize({cols, rows}); + } + }); + rpc.on('data', ({uid, data, escaped}) => { + const session = sessions.get(uid); + if (session) { + 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 context menu', selection => { + const {createWindow} = app; + const {buildFromTemplate} = Menu; + buildFromTemplate(contextMenuTemplate(createWindow, selection)).popup(window); + }); + 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']) { + window.on(ev, () => rpc.emit('windowGeometry change')); } + rpc.win.on('move', () => { + rpc.emit('move'); + }); + rpc.on('close', () => { + window.close(); + }); + rpc.on('command', command => { + const focusedWindow = BrowserWindow.getFocusedWindow(); + execCommand(command, focusedWindow); + }); + 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; + window.webContents.on('did-navigate', () => { + if (i++) { + deleteSessions(); + } + }); - // app.windowCallback is the createWindow callback - // that can be set before the 'ready' app event - // and createWindow definition. It's executed in place of - // the callback passed as parameter, and deleted right after. - (app.windowCallback || fn)(window); - delete app.windowCallback; - fetchNotifications(window); - // auto updates - if (!isDev) { - updater(window); - } else { - //eslint-disable-next-line no-console - console.log('ignoring auto updates during dev'); - } - }); + // If file is dropped onto the terminal window, navigate event is prevented + // and his path is added to active session. + window.webContents.on('will-navigate', (event, url) => { + const protocol = typeof url === 'string' && parseUrl(url).protocol; + if (protocol === 'file:') { + event.preventDefault(); - rpc.on('new', options => { - const sessionOpts = 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 path = fileUriToPath(url); - const initSession = (opts, fn_) => { - fn_(uuid.v4(), new Session(opts)); + rpc.emit('session data send', {data: path, escaped: true}); + } else if (protocol === 'http:' || protocol === 'https:') { + event.preventDefault(); + rpc.emit('session data send', {data: url}); + } + }); + + // xterm makes link clickable + window.webContents.on('new-window', (event, url) => { + const protocol = typeof url === 'string' && parseUrl(url).protocol; + if (protocol === 'http:' || protocol === 'https:') { + event.preventDefault(); + shell.openExternal(url); + } + }); + + // expose internals to extension authors + window.rpc = rpc; + window.sessions = sessions; + + const load = () => { + app.plugins.onWindow(window); }; - initSession(sessionOpts, (uid, session) => { - sessions.set(uid, session); - rpc.emit('session add', { - rows: sessionOpts.rows, - cols: sessionOpts.cols, - uid, - splitDirection: sessionOpts.splitDirection, - shell: session.shell, - pid: session.pty.pid - }); + // load plugins + load(); - 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(); - } - }); - rpc.on('unmaximize', () => { - window.unmaximize(); - }); - rpc.on('maximize', () => { - window.maximize(); - }); - rpc.on('minimize', () => { - window.minimize(); - }); - rpc.on('resize', ({uid, cols, rows}) => { - const session = sessions.get(uid); - if (session) { - session.resize({cols, rows}); - } - }); - rpc.on('data', ({uid, data, escaped}) => { - const session = sessions.get(uid); - if (session) { - 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); + const pluginsUnsubscribe = app.plugins.subscribe(err => { + if (!err) { + load(); + window.webContents.send('plugins change'); + updateBackgroundColor(); } - } - }); - rpc.on('open external', ({url}) => { - shell.openExternal(url); - }); - rpc.on('open context menu', selection => { - const {createWindow} = app; - const {buildFromTemplate} = Menu; - buildFromTemplate(contextMenuTemplate(createWindow, selection)).popup(window); - }); - 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']) { - window.on(ev, () => rpc.emit('windowGeometry change')); - } - rpc.win.on('move', () => { - rpc.emit('move'); - }); - rpc.on('close', () => { - window.close(); - }); - rpc.on('command', command => { - const focusedWindow = BrowserWindow.getFocusedWindow(); - execCommand(command, focusedWindow); - }); - 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; - window.webContents.on('did-navigate', () => { - if (i++) { + + // 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 = () => { + window.focusTime = process.uptime(); + }; + + window.on('focus', () => { + updateFocusTime(); + }); + + // the window can be closed by the browser process itself + window.clean = () => { + app.config.winRecord(window); + rpc.destroy(); deleteSessions(); - } - }); - - // If file is dropped onto the terminal window, navigate event is prevented - // and his path is added to active session. - window.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}); - } - }); - - // xterm makes link clickable - window.webContents.on('new-window', (event, url) => { - const protocol = typeof url === 'string' && parseUrl(url).protocol; - if (protocol === 'http:' || protocol === 'https:') { - event.preventDefault(); - shell.openExternal(url); - } - }); - - // expose internals to extension authors - window.rpc = rpc; - window.sessions = sessions; - - const load = () => { - app.plugins.onWindow(window); - }; - - // load plugins - load(); - - const pluginsUnsubscribe = app.plugins.subscribe(err => { - if (!err) { - load(); - window.webContents.send('plugins change'); - updateBackgroundColor(); - } - }); - - // 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 = () => { - window.focusTime = process.uptime(); - }; - - window.on('focus', () => { + cfgUnsubscribe(); + pluginsUnsubscribe(); + }; + // Ensure focusTime is set on window open. The focus event doesn't + // fire from the dock (see bug #583) updateFocusTime(); - }); - - // the window can be closed by the browser process itself - window.clean = () => { - app.config.winRecord(window); - 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(); + + // Allows delayed rpc related calls on Windows + if (process.platform === 'win32') { + window.attachRPC = attachRPC; + + // Allows cleaning up window without rpc + window.clean = () => { + app.config.winRecord(window); + cfgUnsubscribe(); + }; + } else { + attachRPC(); + } return window; }