UNPKG

@kui-shell/core

Version:

An Electron-based shell for cloud-native development

683 lines (681 loc) • 29.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.createWindow = createWindow; exports.getCommand = void 0; exports.initElectron = initElectron; var _debug = _interopRequireDefault(require("debug")); var _path = require("path"); var _defaults = _interopRequireWildcard(require("../webapp/defaults")); var _path2 = require("../plugins/path"); function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } /* * Copyright 2017 The Kubernetes Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var __awaiter = void 0 && (void 0).__awaiter || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; const debug = (0, _debug.default)('main/spawn-electron'); debug('loading'); /** * Keep a global reference of the window object, if you don't, the window will * be closed automatically when the JavaScript object is garbage collected. * */ let nWindows = 0; /** cleaners to be invoked when the last window is closed */ let cleaners = []; /** Sigh. Spectron uses @electron/remote */ const electronRemoteIsNeeded = !!process.env.RUNNING_SHELL_TEST; /** @electron/remote fails if you try to initialize it more than once */ let electronRemoteNeedsInit = electronRemoteIsNeeded; /** * Keep refs to the electron app around * */ let app; /** * Screen coordinates for regular window * */ function getPositionForRegularWindow({ screen, BrowserWindow }) { if (screen) { const nWindows = BrowserWindow.getAllWindows().length; const { bounds } = screen.getPrimaryDisplay(); const delta = nWindows * 40; const x = Math.round((bounds.width - _defaults.default.width) / 2) + delta; const y = Math.round((bounds.height - _defaults.default.height) / 2) + delta; return { x, y }; } } /** * Screen coordinates for popup window * */ function getPositionForPopup({ screen, BrowserWindow }) { if (screen && BrowserWindow) { const nWindows = BrowserWindow.getAllWindows().length; const { bounds } = screen.getPrimaryDisplay(); return { x: bounds.width - _defaults.popupWindowDefaults.width - 50, y: Math.round((bounds.height - _defaults.popupWindowDefaults.height) / 4 + (nWindows - 1) * 40) }; } } /** Special purpose createWindow that accepts only an argv */ function createWindowWithArgv(executeThisArgvPlease) { // eslint-disable-next-line @typescript-eslint/no-use-before-define createWindow(true, process.env, executeThisArgvPlease); } function getClientProductName() { return Promise.resolve().then(() => require('@kui-shell/client/config.d/name.json')).then(_ => _.productName); } function getClientIcons() { return Promise.resolve().then(() => require('@kui-shell/client/config.d/icons.json')).then(_ => _.filesystem); } function getClientStyles() { return Promise.resolve().then(() => require('@kui-shell/client/config.d/style.json')).then(_ => _.default); } /** Open a new Electron window */ function createWindow(noHeadless = false, env, executeThisArgvPlease, subwindowPlease, subwindowPrefs, secondary = false, isForPopup = false) { return __awaiter(this, void 0, void 0, function* () { debug('createWindow', executeThisArgvPlease); if (subwindowPrefs && subwindowPrefs.bringYourOwnWindow) { subwindowPrefs.bringYourOwnWindow(); return; } // Create the browser window. let width = subwindowPrefs && subwindowPrefs.width; let height = subwindowPrefs && subwindowPrefs.height; if (process.env.WINDOW_WIDTH) { width = parseInt(process.env.WINDOW_WIDTH, 10); if (isNaN(width)) { console.error('Cannot parse WINDOW_WIDTH ' + process.env.WINDOW_WIDTH); width = undefined; } } if (process.env.WINDOW_HEIGHT) { height = parseInt(process.env.WINDOW_HEIGHT, 10); if (isNaN(height)) { console.error('Cannot parse WINDOW_HEIGHT ' + process.env.WINDOW_HEIGHT); height = undefined; } } let promise = Promise.resolve(); if (!app) { debug('we need to spawn electron', subwindowPlease, subwindowPrefs); delete subwindowPrefs.synonymFor; // circular JSON // eslint-disable-next-line @typescript-eslint/no-use-before-define promise = initElectron(['--'].concat(executeThisArgvPlease), {}, subwindowPlease, subwindowPrefs, isForPopup).then(() => __awaiter(this, void 0, void 0, function* () { app = (yield Promise.resolve().then(() => require('electron'))).app; })).catch(err => { // headless debug('not ready for graphics', err); }); } // note: titleBarStyle on macOS needs to be customButtonsOnHover if we want to support cursor:pointer // but this doesn't render the inset window buttons // see https://github.com/electron/electron/issues/10243 return new Promise((resolve, reject) => { promise.then(() => __awaiter(this, void 0, void 0, function* () { const [productName, icons, styles] = yield Promise.all([getClientProductName(), getClientIcons(), getClientStyles()]); const { screen, BrowserWindow, app } = yield Promise.resolve().then(() => require('electron')); const position = subwindowPrefs && subwindowPrefs.position ? yield subwindowPrefs.position() : getPositionForRegularWindow({ screen, BrowserWindow }); const opts = Object.assign({}, styles, { title: subwindowPrefs && subwindowPrefs.title || productName, width: width || styles.width || 1280, height: height || styles.height || 960, webPreferences: { backgroundThrottling: false, contextIsolation: false, nodeIntegration: true // prior to electron 5, this was the default } // titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default' }, position); // if we are opening a random URL, then we cannot use a // frameless window; otherwise, it is unlikely the window will // be moveable if (typeof executeThisArgvPlease === 'string') { delete opts.frame; delete opts.titleBarStyle; if (!subwindowPrefs) { subwindowPrefs = {}; } subwindowPrefs._notAKuiWindow = true; } // if user ups zoom level, reloads, we're stuck at a higher zoom // see https://github.com/electron/electron/issues/10572 // note that this requires show: false above opts.webPreferences.zoomFactor = 1; // when jumping directly to the UI from bash, getAppPath() may // include dist/headless; if so, we need to back out of that yield promise; // wait for `app` to be defined, since we're about to use it if (!app) return; // then we will go a different route to opening the electron window const appPath = app.getAppPath(); const root = (0, _path.join)(appPath, /headless$/.test(appPath) ? '../../' : '', 'node_modules/@kui-shell'); if (process.platform === 'linux') { const icon = (0, _path.join)(root, 'build', icons.linux); opts.icon = icon; } else if (process.platform === 'win32') { const icon = (0, _path.join)(root, 'build', icons.win32); opts.icon = icon; } if (subwindowPlease) { // this tells electron to size content to the given width and height, // (i.e. NOT vice versa, to size the window to the content!) opts.useContentSize = true; } if (electronRemoteNeedsInit) { // use the webpackIgnore magic comment to avoid specifying a // direct dependence on @electron/remote; this dep pulls in // all of electron, which some clients will not need. if you // need it, make sure to install @electron/remote yourself. yield Promise.resolve().then(() => require('@electron/remote/main/index.js')).then(_ => _.initialize()); electronRemoteNeedsInit = false; } if (process.env.KUI_POSITION_X) { opts.x = parseInt(process.env.KUI_POSITION_X, 10); } if (process.env.KUI_POSITION_Y) { opts.y = parseInt(process.env.KUI_POSITION_Y, 10); } debug('createWindow::new BrowserWindow'); const mainWindow = new BrowserWindow(opts); if (electronRemoteIsNeeded) { // see the webpackIgnore: true comment just above yield Promise.resolve().then(() => require('@electron/remote/main/index.js')).then(_ => _.enable(mainWindow.webContents)); } nWindows++; debug('createWindow::new BrowserWindow success'); mainWindow.once('ready-to-show', () => { mainWindow.setVisibleOnAllWorkspaces(true); mainWindow.show(); mainWindow.setVisibleOnAllWorkspaces(false); }); const fixedWindows = {}; const openFixedWindow = opts => { const { type, url, size = mainWindow.getBounds(), position = mainWindow.getBounds() } = opts; const existing = fixedWindows[type] || {}; const { window: existingWindow, url: currentURL } = existing; if (!existingWindow || existingWindow.isDestroyed()) { const window = new BrowserWindow({ width: size.width, height: size.height, frame: true }); fixedWindows[type] = { window, url }; window.setPosition(position.x + 62, position.y + 62); // window.on('closed', () => { docsWindow = null }) window.loadURL(url); } else { if (currentURL !== url) { existingWindow.loadURL(url); existing.url = url; } existingWindow.focus(); } }; /** this event handler will be called when the window's content finishes loading */ mainWindow.webContents.on('did-finish-load', () => __awaiter(this, void 0, void 0, function* () { if (mainWindow) { try { // const { switchToPersistedThemeChoice } = await import('../webapp/themes/persistence') // await switchToPersistedThemeChoice(mainWindow.webContents /*, Electron.nativeTheme.shouldUseDarkColors */) } catch (err) { console.error('error initializing themes', err); } } })); /** * Intercept window.location=... and send to platform browser. * see https://github.com/IBM/kui/issues/2881 */ mainWindow.webContents.on('will-navigate', (event, url) => __awaiter(this, void 0, void 0, function* () { const openInKui = /kui-shell\/build\/index.html/.test(url); // webpack-dev-server causes this navigation; ignore if (!openInKui) { event.preventDefault(); (yield Promise.resolve().then(() => require('electron'))).shell.openExternal(url); } })); /** jump in and manage the way popups create new windows */ mainWindow.webContents.setWindowOpenHandler(({ url }) => { if (url.startsWith('https://youtu.be')) { // special handling of youtube links openFixedWindow({ type: 'videos', url, size: { width: 800, height: 600 } }); return { action: 'deny' }; } else { Promise.resolve().then(() => require('electron')).then(_ => _.shell).then(_ => _.openExternal(url)); return { action: 'deny' }; } }); let commandContext = Array.isArray(executeThisArgvPlease) && executeThisArgvPlease.find(_ => /--command-context/.test(_)); if (Array.isArray(executeThisArgvPlease) && commandContext) { executeThisArgvPlease = executeThisArgvPlease.filter(_ => !_.match(/--command-context/)); // strip off the leading --, to help with URL window.location.search commandContext = commandContext.replace(/^--/, ''); } debug('subwindowPrefs', subwindowPrefs); // and load the index.html of the app. const searchFields = [commandContext ? `command-context=${encodeURIComponent(commandContext)}` : '', noHeadless === true && Array.isArray(executeThisArgvPlease) ? `executeThisArgvPlease=${encodeURIComponent(JSON.stringify(executeThisArgvPlease))}` : '', subwindowPrefs && Object.keys(subwindowPrefs).length > 0 ? `subwindow=${encodeURIComponent(JSON.stringify(subwindowPrefs))}` : ''].filter(Boolean); const url = typeof executeThisArgvPlease === 'string' ? executeThisArgvPlease : require('url').format({ pathname: (0, _path.join)(root, 'build', 'index.html'), protocol: 'file:', search: searchFields.length === 0 ? '' : `?${searchFields.join('&')}`, slashes: true }); debug('mainWindow::loadURL', url); try { mainWindow.loadURL(url); } catch (err) { const errorIsNavigatedError = err.message.includes('Inspected target navigated or closed') || err.message.includes('cannot determine loading status') || err.message.includes('Inspected target navigated or closed'); if (!process.env.TRAVIS_JOB_ID || !errorIsNavigatedError) { reject(err); } } debug('install menus'); require('./menu').install(createWindowWithArgv); // Open the DevTools. // mainWindow.webContents.openDevTools() // Emitted when the window is closed. mainWindow.once('closed', function () { // Dereference the window object, usually you would store windows // in an array if your app supports multi windows, this is the time // when you should delete the corresponding element. if (--nWindows === 0) { cleaners.forEach(cleaner => cleaner()); cleaners = []; } // now that the window is closed, resolve the promise of `createWindow` resolve(); }); if (!secondary && cleaners.length === 0) { // // set up ipc from renderer // const { ipcMain } = yield Promise.resolve().then(() => require('electron')); // // take a screenshot; note that this has to be done in the main // process, due to the way clipboard.writeImage is implemented on // Linux. on macOS, this could be done entirely in the renderer // process. on Linux, however, the nativeImages aren't // translatable between the renderer and main processes as fluidly // as they are on macOS. oh well! this is why the screenshot // plugin has to pollute main.js // debug('ipc registration'); const onCaptureToClipboard = (event, contentsId, rect) => __awaiter(this, void 0, void 0, function* () { try { const { clipboard, nativeImage, webContents } = yield Promise.resolve().then(() => require('electron')); const image = yield webContents.fromId(parseInt(contentsId, 10)).capturePage(rect); try { const buf = image.toPNG(); clipboard.writeImage(nativeImage.createFromBuffer(buf)); event.sender.send('capture-page-to-clipboard-done', buf); } catch (err) { console.log(err); event.sender.send('capture-page-to-clipboard-done'); } } catch (err) { console.log(err); event.sender.send('capture-page-to-clipboard-done'); } }); // end of screenshot logic const onSynchronousMessage = (event, arg) => __awaiter(this, void 0, void 0, function* () { const message = JSON.parse(arg); switch (message.operation) { case 'quit': app.quit(); break; case 'new-window': createWindow(true, env, message.argv, undefined, { width: message.width, height: message.height, title: message.title, initialTabTitle: message.initialTabTitle || message.title, quietExecCommand: message.quietExecCommand !== undefined ? message.quietExecCommand : false }, true); break; case 'open-graphical-shell': createWindow(true, env); break; case 'enlarge-window': mainWindow.setContentSize(1400, 1050, true); break; case 'reduce-window': mainWindow.setContentSize(1024, 768, true); break; case 'maximize-window': mainWindow.maximize(); break; case 'unmaximize-window': mainWindow.unmaximize(); break; case 'find-in-page': event.sender.on('found-in-page', (ev, result) => { // if (result.finalUpdate) event.sender.stopFindInPage('activateSelection') event.sender.send('found-in-page-result', result); }); event.sender.findInPage(message.value, message.options); break; case 'stop-find-in-page': event.sender.stopFindInPage('activateSelection'); break; } event.returnValue = 'ok'; }); const onExecInvoke = (event, arg) => __awaiter(this, void 0, void 0, function* () { const message = JSON.parse(arg); const channel = `/exec/response/${message.hash}`; debug('invoke', message); try { const mod = yield Promise.resolve().then(() => require(`${'@kui-shell/plugin-' + (0, _path2.webpackPath)(message.module) + '/mdist/electron-main.js'}`)); debug('invoke got module'); const returnValue = yield mod[message.main || 'main'](message.args, event.sender, (argv, prefs) => createWindow(true, env, argv, undefined, prefs)); debug('invoke got returnValue', returnValue); event.sender.send(channel, JSON.stringify({ success: true, returnValue })); } catch (error) { debug('error in exec', error); event.sender.send(channel, JSON.stringify({ success: false, error })); } }); ipcMain.on('/exec/invoke', onExecInvoke); ipcMain.on('synchronous-message', onSynchronousMessage); ipcMain.on('capture-page-to-clipboard', onCaptureToClipboard); cleaners.push(() => ipcMain.off('/exec/invoke', onExecInvoke)); cleaners.push(() => ipcMain.off('synchronous-message', onSynchronousMessage)); cleaners.push(() => ipcMain.off('capture-page-to-clipboard', onCaptureToClipboard)); } debug('createWindow done'); })).catch(err => { // headless debug('not ready for graphics (2)', err); }); }); }); } const getCommand = (argv, cwd, env, screen) => { debug('getCommand', argv); const dashDash = argv.lastIndexOf('--'); argv = dashDash === -1 ? argv.slice(1) : argv.slice(dashDash + 1); // re: the -psn bit, opening Kui from macOS Finder adds additional argv -psn; see: https://github.com/IBM/kui/issues/382 // re: the --allow-file... and --enable-av...; see https://github.com/kubernetes-sigs/kui/issues/8746 argv = argv.filter(_ => _ !== '--ui' && _ !== '--no-color' && !_.match(/^-psn/) && _ !== '--allow-file-access-from-files' && _ !== '--enable-avfoundation' && _ !== '--enable-crashpad' // this one seems to be linux-specific? ); // re: argv.length === 0, this should happen for double-click launches const isShell = !env.KUI_POPUP && (argv.length === 0 || argv.find(_ => _ === 'shell') || env.RUNNING_SHELL_TEST && !env.KUI_TEE_TO_FILE); debug('isShell', argv, isShell); let subwindowPlease = true; let subwindowPrefs = { cwd, env, fullscreen: true, width: _defaults.default.width, height: _defaults.default.height }; if (isShell) { // use a full window for 'shell' argv = ['shell']; subwindowPlease = false; subwindowPrefs = { cwd, env }; } else if (env.KUI_POPUP) { argv = JSON.parse(env.KUI_POPUP); subwindowPrefs = { cwd, env }; } if (env.KUI_POPUP_WINDOW_RESIZE) { subwindowPrefs = { cwd, env, fullscreen: true, position: () => __awaiter(void 0, void 0, void 0, function* () { return getPositionForPopup(yield screen()); }), width: _defaults.popupWindowDefaults.width, height: _defaults.popupWindowDefaults.height }; } debug('using args', argv, subwindowPrefs); return { argv, subwindowPlease, subwindowPrefs }; }; /** * Spawn electron * */ exports.getCommand = getCommand; function initElectron(command = [], { isRunningHeadless = false } = {}, subwindowPlease, subwindowPrefs, isForPopup = false) { return __awaiter(this, void 0, void 0, function* () { debug('initElectron', command, subwindowPlease, subwindowPrefs); if (process.env.KUI_HEADLESS) { if (!process.env.KUI_ELECTRON_HOME) { throw new Error('Unable to locate graphics components'); } else { // then we're still in pure headless mode; we'll need to fork ourselves to spawn electron const childProcess = Promise.resolve().then(() => require('child_process')); const appHome = '.'; const args = [appHome, '--', ...command]; debug('spawning electron', process.env.KUI_ELECTRON_HOME, args); // pass through any window options, originating from the command's usage model, on to the subprocess const windowOptions = {}; if (subwindowPlease) { debug('passing through subwindowPlease', subwindowPlease); windowOptions['subwindowPlease'] = subwindowPlease.toString(); } if (subwindowPrefs && Object.keys(subwindowPrefs).length > 0) { debug('passing through subwindowPrefs', subwindowPrefs); windowOptions['subwindowPrefs'] = JSON.stringify(subwindowPrefs); } // note how we ignore the subprocess's stdio if debug mode // is not enabled this allows you (as a developer) to // debug issues with spawning the subprocess by passing // DEBUG=* or DEBUG=main const env = Object.assign({}, process.env, windowOptions); delete env.KUI_GRAPHICS; delete env.KUI_HEADLESS; delete env.ELECTRON_RUN_AS_NODE; const child = (yield childProcess).spawn(process.env.KUI_ELECTRON_HOME, args, { stdio: debug.enabled ? 'inherit' : 'ignore', env, detached: true // needed on windows to separate this process into its own process group }); if (!debug.enabled) { // as with the "ignore stdio" comment immediately // above: unless we're in DEBUG mode, let's disown // ("unref" in nodejs terms) the subprocess child.unref(); } debug('spawning electron done, this process will soon exit'); if (!isForPopup) { process.exit(0); } } } else { debug('loading electron'); const Electron = yield Promise.resolve().then(() => require('electron')); app = Electron.app; } // deal with multiple processes if (!process.env.RUNNING_SHELL_TEST) { const widthFromCaller = subwindowPrefs && subwindowPrefs.width; const heightFromCaller = subwindowPrefs && subwindowPrefs.height; app.on('second-instance', (event, commandLine, cwd, additionalData) => { // Someone tried to run a second instance, open a new window // to handle it const env = !additionalData || !additionalData.env ? process.env : JSON.parse(additionalData.env); const { argv, subwindowPlease, subwindowPrefs: defaultSubwindowPrefs } = getCommand(commandLine, cwd, env, () => __awaiter(this, void 0, void 0, function* () { return Promise.resolve().then(() => require('electron')); })); const mySubwindowPrefs = additionalData && additionalData.subwindowPrefs || defaultSubwindowPrefs; if (!mySubwindowPrefs.width && widthFromCaller) { mySubwindowPrefs.width = widthFromCaller; } if (!mySubwindowPrefs.height && heightFromCaller) { mySubwindowPrefs.height = heightFromCaller; } debug('opening window for second instance', commandLine, subwindowPlease, mySubwindowPrefs, additionalData); createWindow(true, env, argv, subwindowPlease, mySubwindowPrefs); }); // data to pass along with our request, to preserve some context // info, such as desired window size and window title, and // environment variables from this second process that we need to // pass back to the first, so that the desired env appears in the // new window const additionalData = { env: JSON.stringify(process.env), subwindowPrefs: !subwindowPrefs ? undefined : { width: subwindowPrefs.width, height: subwindowPrefs.height, title: subwindowPrefs.title, fullscreen: subwindowPrefs.fullscreen } }; if (!app.requestSingleInstanceLock(additionalData)) { // The primary instance of app failed to optain the lock, which means another instance of app is already running with the lock debug('exiting, since we are not the first instance'); return app.exit(0); } } // See menu.ts; Open Recent leads here app.on('open-file', (evt, filepath) => __awaiter(this, void 0, void 0, function* () { const { replay } = yield Promise.resolve().then(() => require('./notebooks')); replay(filepath, createWindowWithArgv); })); // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. app.once('ready', () => { debug('opening primary window', command); createWindow(true, process.env, command.length > 0 && command, subwindowPlease, subwindowPrefs); }); // Quit when all windows are closed. app.on('window-all-closed', function () { // On OS X it is common for applications and their menu bar // to stay active until the user quits explicitly with Cmd + Q if (process.platform !== 'darwin' || isRunningHeadless) { // if we're running headless, then quit on window closed, no matter which platform we're on app.quit(); } else { app.hide(); } }); app.on('activate', function () { // On OS X it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. if (nWindows === 0) { createWindow(false, process.env); } }); }); }