Add tray icon and set single instance mode to improve startup time on Windows (#3324)

Fixes #2935, #2980
This commit is contained in:
Phung Tuan Hoang 2018-12-17 06:46:37 +08:00 committed by CHaBou
parent 76ab6abd68
commit f324a67bb9
2 changed files with 357 additions and 205 deletions

View file

@ -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. // Print diagnostic information for a few arguments instead of running Hyper.
if (['--help', '-v', '--version'].includes(process.argv[1])) { if (['--help', '-v', '--version'].includes(process.argv[1])) {
const {version} = require('./package'); const {version} = require('./package');
@ -52,7 +67,7 @@ if (process.platform === 'win32') {
const {resolve} = require('path'); const {resolve} = require('path');
// Packages // Packages
const {app, BrowserWindow, Menu} = require('electron'); const {BrowserWindow, Menu, Tray} = require('electron');
const {gitDescribe} = require('git-describe'); const {gitDescribe} = require('git-describe');
const isDev = require('electron-is-dev'); 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 //eslint-disable-next-line no-console
console.log('electron will open', url); console.log('electron will open', url);
app.on('ready', () => let tray = null;
const {icon} = require('./config/paths');
app.on('ready', () => {
installDevExtensions(isDev) installDevExtensions(isDev)
.then(() => { .then(() => {
function createWindow(fn, options = {}) { function createWindow(fn, options = {}) {
@ -165,11 +183,16 @@ app.on('ready', () =>
windowSet.delete(hwin); windowSet.delete(hwin);
}); });
hwin.on('closed', () => { // On Windows, do not quit app when all Hyper windows are closed
if (process.platform !== 'darwin' && windowSet.size === 0) { if (process.platform === 'win32') {
app.quit(); hwin.attachRPC();
} } else {
}); hwin.on('closed', () => {
if (process.platform !== 'darwin' && windowSet.size === 0) {
app.quit();
}
});
}
return hwin; 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 makeMenu = () => {
const menu = plugins.decorateMenu(AppMenu.createMenu(createWindow, plugins.getLoadedPluginVersions)); const menu = plugins.decorateMenu(AppMenu.createMenu(createWindow, plugins.getLoadedPluginVersions));
@ -225,12 +324,41 @@ app.on('ready', () =>
} }
installCLI(false); 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 => { .catch(err => {
//eslint-disable-next-line no-console //eslint-disable-next-line no-console
console.error('Error while loading devtools extensions', err); console.error('Error while loading devtools extensions', err);
}) });
); });
app.on('open-file', (event, path) => { app.on('open-file', (event, path) => {
const lastWindow = app.getLastFocusedWindow(); const lastWindow = app.getLastFocusedWindow();

View file

@ -33,7 +33,6 @@ module.exports = class Window {
options_ options_
); );
const window = new BrowserWindow(app.plugins.getDecoratedBrowserOptions(winOpts)); const window = new BrowserWindow(app.plugins.getDecoratedBrowserOptions(winOpts));
const rpc = createRPC(window);
const sessions = new Map(); const sessions = new Map();
const updateBackgroundColor = () => { const updateBackgroundColor = () => {
@ -59,214 +58,239 @@ module.exports = class Window {
cfg = cfg_; cfg = cfg_;
}); });
rpc.on('init', () => { const attachRPC = workingDirectory => {
window.show(); const rpc = createRPC(window);
updateBackgroundColor();
// If no callback is passed to createWindow, rpc.on('init', () => {
// a new session will be created by default. window.show();
if (!fn) { updateBackgroundColor();
fn = win => win.rpc.emit('termgroup add req');
// 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 // If file is dropped onto the terminal window, navigate event is prevented
// that can be set before the 'ready' app event // and his path is added to active session.
// and createWindow definition. It's executed in place of window.webContents.on('will-navigate', (event, url) => {
// the callback passed as parameter, and deleted right after. const protocol = typeof url === 'string' && parseUrl(url).protocol;
(app.windowCallback || fn)(window); if (protocol === 'file:') {
delete app.windowCallback; event.preventDefault();
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 => { const path = fileUriToPath(url);
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 initSession = (opts, fn_) => { rpc.emit('session data send', {data: path, escaped: true});
fn_(uuid.v4(), new Session(opts)); } 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) => { // load plugins
sessions.set(uid, session); load();
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 => { const pluginsUnsubscribe = app.plugins.subscribe(err => {
rpc.emit('session data', uid + data); if (!err) {
}); load();
window.webContents.send('plugins change');
session.on('exit', () => { updateBackgroundColor();
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 // Keep track of focus time of every window, to figure out
// subsequent refreshes (ie: F5) // which one of the existing window is the last focused.
let i = 0; // Works nicely even if a window is closed and removed.
window.webContents.on('did-navigate', () => { const updateFocusTime = () => {
if (i++) { 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(); deleteSessions();
} cfgUnsubscribe();
}); pluginsUnsubscribe();
};
// If file is dropped onto the terminal window, navigate event is prevented // Ensure focusTime is set on window open. The focus event doesn't
// and his path is added to active session. // fire from the dock (see bug #583)
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', () => {
updateFocusTime(); 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) // Allows delayed rpc related calls on Windows
updateFocusTime(); if (process.platform === 'win32') {
window.attachRPC = attachRPC;
// Allows cleaning up window without rpc
window.clean = () => {
app.config.winRecord(window);
cfgUnsubscribe();
};
} else {
attachRPC();
}
return window; return window;
} }