hyper/app/ui/window.ts

372 lines
11 KiB
TypeScript
Raw Normal View History

2023-07-25 09:30:19 -08:00
import {existsSync} from 'fs';
import {isAbsolute, normalize, sep} from 'path';
2021-07-20 23:43:01 -08:00
import {URL, fileURLToPath} from 'url';
2023-07-25 09:30:19 -08:00
import {app, BrowserWindow, shell, Menu} from 'electron';
import type {BrowserWindowConstructorOptions} from 'electron';
import {enable as remoteEnable} from '@electron/remote/main';
2019-11-28 05:17:01 -09:00
import isDev from 'electron-is-dev';
2023-07-25 09:30:19 -08:00
import {getWorkingDirectoryFromPID} from 'native-process-working-directory';
import {v4 as uuidv4} from 'uuid';
import type {sessionExtraOptions} from '../../typings/common';
import type {configOptions} from '../../typings/config';
import {execCommand} from '../commands';
import {getDefaultProfile} from '../config';
2020-01-25 09:34:41 -09:00
import {icon, homeDirectory} from '../config/paths';
2019-11-28 05:17:01 -09:00
import fetchNotifications from '../notifications';
2023-07-25 09:30:19 -08:00
import notify from '../notify';
import {decorateSessionOptions, decorateSessionClass} from '../plugins';
import createRPC from '../rpc';
2019-11-28 05:17:01 -09:00
import Session from '../session';
2023-07-25 09:30:19 -08:00
import updater from '../updater';
2019-11-28 05:17:01 -09:00
import {setRendererType, unsetRendererType} from '../utils/renderer-utils';
2023-07-25 09:30:19 -08:00
import toElectronBackgroundColor from '../utils/to-electron-background-color';
import contextMenuTemplate from './contextmenu';
2019-11-28 05:17:01 -09:00
export function newWindow(
options_: BrowserWindowConstructorOptions,
cfg: configOptions,
2023-06-30 11:07:04 -08:00
fn?: (win: BrowserWindow) => void,
profileName: string = getDefaultProfile()
): BrowserWindow {
const classOpts = Object.assign({uid: uuidv4()});
app.plugins.decorateWindowClass(classOpts);
2020-07-14 21:31:58 -08:00
const winOpts: BrowserWindowConstructorOptions = {
minWidth: 370,
minHeight: 190,
backgroundColor: toElectronBackgroundColor(cfg.backgroundColor || '#000'),
titleBarStyle: 'hiddenInset',
title: 'Hyper.app',
// we want to go frameless on Windows and Linux
frame: process.platform === 'darwin',
transparent: process.platform === 'darwin',
icon,
show: Boolean(process.env.HYPER_DEBUG || process.env.HYPERTERM_DEBUG || isDev),
acceptFirstMouse: true,
webPreferences: {
nodeIntegration: true,
navigateOnDragDrop: true,
2021-03-03 18:06:06 -09:00
contextIsolation: false
},
2020-07-14 21:31:58 -08:00
...options_
};
const window = new BrowserWindow(app.plugins.getDecoratedBrowserOptions(winOpts));
2023-06-30 11:07:04 -08:00
window.profileName = profileName;
// Enable remote module on this window
remoteEnable(window.webContents);
window.uid = classOpts.uid;
app.plugins.onWindowClass(window);
window.uid = classOpts.uid;
const rpc = createRPC(window);
const sessions = new Map<string, Session>();
const updateBackgroundColor = () => {
2023-06-30 11:07:04 -08:00
const cfg_ = app.plugins.getDecoratedConfig(profileName);
window.setBackgroundColor(toElectronBackgroundColor(cfg_.backgroundColor || '#000'));
};
// config changes
const cfgUnsubscribe = app.config.subscribe(() => {
2023-06-30 11:07:04 -08:00
const cfg_ = app.plugins.getDecoratedConfig(profileName);
// notify renderer
window.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
updateBackgroundColor();
cfg = cfg_;
});
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: BrowserWindow) => {
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);
2020-09-15 06:54:35 -08:00
app.windowCallback = undefined;
fetchNotifications(window);
// auto updates
if (!isDev) {
updater(window);
} else {
console.log('ignoring auto updates during dev');
}
});
2023-06-26 06:13:05 -08:00
function createSession(extraOptions: sessionExtraOptions = {}) {
const uid = uuidv4();
2023-06-26 06:13:05 -08:00
const extraOptionsFiltered: sessionExtraOptions = {};
2020-03-25 02:15:08 -08:00
Object.keys(extraOptions).forEach((key) => {
if (extraOptions[key] !== undefined) extraOptionsFiltered[key] = extraOptions[key];
});
2023-06-30 11:07:04 -08:00
const profile = extraOptionsFiltered.profile || profileName;
2023-06-28 21:04:33 -08:00
const activeSession = extraOptionsFiltered.activeUid ? sessions.get(extraOptionsFiltered.activeUid) : undefined;
2022-01-08 01:47:38 -09:00
let cwd = '';
2023-06-28 21:04:33 -08:00
if (cfg.preserveCWD !== false && activeSession && activeSession.profile === profile) {
const activePID = activeSession.pty?.pid;
2023-06-26 06:13:05 -08:00
if (activePID !== undefined) {
try {
cwd = getWorkingDirectoryFromPID(activePID) || '';
} catch (error) {
console.error(error);
}
2022-01-08 01:47:38 -09:00
}
2022-12-25 20:01:04 -09:00
cwd = cwd && isAbsolute(cwd) && existsSync(cwd) ? cwd : '';
2022-01-08 01:47:38 -09:00
}
2023-06-28 21:04:33 -08:00
const profileCfg = app.plugins.getDecoratedConfig(profile);
// set working directory
let argPath = process.argv[1];
if (argPath && process.platform === 'win32') {
if (/[a-zA-Z]:"/.test(argPath)) {
argPath = argPath.replace('"', sep);
}
argPath = normalize(argPath + sep);
}
let workingDirectory = homeDirectory;
if (argPath && isAbsolute(argPath)) {
workingDirectory = argPath;
} else if (profileCfg.workingDirectory && isAbsolute(profileCfg.workingDirectory)) {
workingDirectory = profileCfg.workingDirectory;
}
// remove the rows and cols, the wrong value of them will break layout when init create
const defaultOptions = Object.assign(
{
2022-01-08 01:47:38 -09:00
cwd: cwd || workingDirectory,
splitDirection: undefined,
2023-06-28 21:04:33 -08:00
shell: profileCfg.shell,
shellArgs: profileCfg.shellArgs && Array.from(profileCfg.shellArgs)
},
extraOptionsFiltered,
2023-06-28 21:04:33 -08:00
{
2023-06-30 11:07:04 -08:00
profile: extraOptionsFiltered.profile || profileName,
2023-06-28 21:04:33 -08:00
uid
}
);
const options = decorateSessionOptions(defaultOptions);
const DecoratedSession = decorateSessionClass(Session);
const session = new DecoratedSession(options);
sessions.set(uid, session);
return {session, options};
}
2020-03-25 02:15:08 -08:00
rpc.on('new', (extraOptions) => {
const {session, options} = createSession(extraOptions);
sessions.set(options.uid, session);
rpc.emit('session add', {
rows: options.rows,
cols: options.cols,
uid: options.uid,
splitDirection: options.splitDirection,
shell: session.shell,
pid: session.pty ? session.pty.pid : null,
2023-06-28 21:04:33 -08:00
activeUid: options.activeUid ?? undefined,
profile: options.profile
});
2020-06-19 04:51:34 -08:00
session.on('data', (data: string) => {
rpc.emit('session data', data);
});
session.on('exit', () => {
rpc.emit('session exit', {uid: options.uid});
unsetRendererType(options.uid);
sessions.delete(options.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});
}
});
2023-06-25 05:26:16 -08:00
rpc.on('data', ({uid, data, escaped}) => {
const session = uid && 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('info renderer', ({uid, type}) => {
// Used in the "About" dialog
setRendererType(uid, type);
});
rpc.on('open external', ({url}) => {
void shell.openExternal(url);
});
2020-03-25 02:15:08 -08:00
rpc.on('open context menu', (selection) => {
const {createWindow} = app;
Menu.buildFromTemplate(contextMenuTemplate(createWindow, selection)).popup({window});
});
rpc.on('open hamburger menu', ({x, y}) => {
Menu.getApplicationMenu()!.popup({x: Math.ceil(x), y: 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
2023-06-26 06:13:05 -08:00
const onGeometryChange = () => rpc.emit('windowGeometry change', {isMaximized: window.isMaximized()});
window.on('maximize', onGeometryChange);
window.on('unmaximize', onGeometryChange);
window.on('minimize', onGeometryChange);
window.on('restore', onGeometryChange);
window.on('move', () => {
const position = window.getPosition();
rpc.emit('move', {bounds: {x: position[0], y: position[1]}});
});
rpc.on('close', () => {
window.close();
});
2020-03-25 02:15:08 -08:00
rpc.on('command', (command) => {
const focusedWindow = BrowserWindow.getFocusedWindow();
execCommand(command, focusedWindow!);
});
// pass on the full screen events from the window to react
rpc.win.on('enter-full-screen', () => {
2023-06-25 05:26:16 -08:00
rpc.emit('enter full screen');
});
rpc.win.on('leave-full-screen', () => {
2023-06-25 05:26:16 -08:00
rpc.emit('leave full screen');
});
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();
}
});
2023-02-20 08:43:43 -09:00
const handleDroppedURL = (url: string) => {
2021-07-20 23:43:01 -08:00
const protocol = typeof url === 'string' && new URL(url).protocol;
if (protocol === 'file:') {
2021-07-20 23:43:01 -08:00
const path = fileURLToPath(url);
2023-06-25 05:26:16 -08:00
return {uid: null, data: path, escaped: true};
} else if (protocol === 'http:' || protocol === 'https:') {
2023-06-25 05:26:16 -08:00
return {uid: null, data: url};
}
2021-07-20 23:43:01 -08:00
};
2021-07-20 23:43:01 -08:00
// If file is dropped onto the terminal window, navigate and new-window events are prevented
2023-02-20 08:43:43 -09:00
// and it's path is added to active session.
window.webContents.on('will-navigate', (event, url) => {
const data = handleDroppedURL(url);
if (data) {
event.preventDefault();
rpc.emit('session data send', data);
}
});
window.webContents.setWindowOpenHandler(({url}) => {
const data = handleDroppedURL(url);
if (data) {
rpc.emit('session data send', data);
return {action: 'deny'};
}
return {action: 'allow'};
});
// 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: any) => {
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();
});
// 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();
return window;
2019-11-28 05:17:01 -09:00
}