experimental support for multiple windows (fixes #212, fixes #170)

This commit is contained in:
Eugene Pankov
2018-08-31 15:41:28 +02:00
parent 0749096d9f
commit 4b7b692ace
25 changed files with 577 additions and 395 deletions

171
app/lib/app.ts Normal file
View File

@@ -0,0 +1,171 @@
import { app, ipcMain, Menu, Tray, shell } from 'electron'
import { Window } from './window'
export class Application {
private tray: Tray
private windows: Window[] = []
constructor () {
ipcMain.on('app:config-change', () => {
this.broadcast('host:config-change')
})
}
async newWindow (): Promise<Window> {
let window = new Window()
this.windows.push(window)
window.visible$.subscribe(visible => {
if (visible) {
this.disableTray()
} else {
this.enableTray()
}
})
this.setupMenu()
await window.ready
return window
}
broadcast (event, ...args) {
for (let window of this.windows) {
window.send(event, ...args)
}
}
async send (event, ...args) {
if (!this.hasWindows()) {
await this.newWindow()
}
this.windows[0].send(event, ...args)
}
enableTray () {
if (this.tray) {
return
}
if (process.platform === 'darwin') {
this.tray = new Tray(`${app.getAppPath()}/assets/tray-darwinTemplate.png`)
this.tray.setPressedImage(`${app.getAppPath()}/assets/tray-darwinHighlightTemplate.png`)
} else {
this.tray = new Tray(`${app.getAppPath()}/assets/tray.png`)
}
this.tray.on('click', () => this.focus())
const contextMenu = Menu.buildFromTemplate([{
label: 'Show',
click: () => this.focus(),
}])
if (process.platform !== 'darwin') {
this.tray.setContextMenu(contextMenu)
}
this.tray.setToolTip(`Terminus ${app.getVersion()}`)
}
disableTray () {
if (this.tray) {
this.tray.destroy()
this.tray = null
}
}
hasWindows () {
return !!this.windows.length
}
focus () {
for (let window of this.windows) {
window.show()
window.focus()
}
}
private setupMenu () {
let template: Electron.MenuItemConstructorOptions[] = [
{
label: 'Application',
submenu: [
{ role: 'about', label: 'About Terminus' },
{ type: 'separator' },
{
label: 'Preferences',
accelerator: 'Cmd+,',
async click () {
if (!this.hasWindows()) {
await this.newWindow()
}
this.windows[0].send('host:preferences-menu')
},
},
{ type: 'separator' },
{ role: 'services', submenu: [] },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideothers' },
{ role: 'unhide' },
{ type: 'separator' },
{
label: 'Quit',
accelerator: 'Cmd+Q',
click () {
app.quit()
},
},
],
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'pasteandmatchstyle' },
{ role: 'delete' },
{ role: 'selectall' },
],
},
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forcereload' },
{ role: 'toggledevtools' },
{ type: 'separator' },
{ role: 'resetzoom' },
{ role: 'zoomin' },
{ role: 'zoomout' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{
role: 'window',
submenu: [
{ role: 'minimize' },
{ role: 'close' },
{ role: 'zoom' },
{ type: 'separator' },
{ role: 'front' },
],
},
{
role: 'help',
submenu: [
{
label: 'Website',
click () {
shell.openExternal('https://eugeny.github.io/terminus')
},
},
],
}
]
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
}
}

View File

@@ -1,23 +0,0 @@
import { app } from 'electron'
export function parseArgs (argv, cwd) {
if (argv[0].includes('node')) {
argv = argv.slice(1)
}
return require('yargs')
.usage('terminus [command] [arguments]')
.command('open [directory]', 'open a shell in a directory', {
directory: { type: 'string', 'default': cwd },
})
.command('run [command...]', 'run a command in the terminal', {
command: { type: 'string' },
})
.version('v', 'Show version and exit', app.getVersion())
.alias('d', 'debug')
.describe('d', 'Show DevTools on start')
.alias('h', 'help')
.help('h')
.strict()
.parse(argv.slice(1))
}

23
app/lib/cli.ts Normal file
View File

@@ -0,0 +1,23 @@
import { app } from 'electron'
export function parseArgs (argv, cwd) {
if (argv[0].includes('node')) {
argv = argv.slice(1)
}
return require('yargs')
.usage('terminus [command] [arguments]')
.command('open [directory]', 'open a shell in a directory', {
directory: { type: 'string', 'default': cwd },
})
.command('run [command...]', 'run a command in the terminal', {
command: { type: 'string' },
})
.version('v', 'Show version and exit', app.getVersion())
.alias('d', 'debug')
.describe('d', 'Show DevTools on start')
.alias('h', 'help')
.help('h')
.strict()
.parse(argv.slice(1))
}

View File

@@ -1,304 +0,0 @@
import { app, ipcMain, BrowserWindow, Menu, Tray, shell } from 'electron'
import * as path from 'path'
import electronDebug from 'electron-debug'
import * as fs from 'fs'
import * as yaml from 'js-yaml'
import './lru'
import { parseArgs } from './cli'
import ElectronConfig from 'electron-config'
if (process.platform === 'win32' && require('electron-squirrel-startup')) process.exit(0)
let electronVibrancy
if (process.platform !== 'linux') {
electronVibrancy = require('electron-vibrancy')
}
let windowConfig = new ElectronConfig({ name: 'window' })
if (!process.env.TERMINUS_PLUGINS) {
process.env.TERMINUS_PLUGINS = ''
}
const setWindowVibrancy = (enabled) => {
if (enabled && !app.window.vibrancyViewID) {
app.window.vibrancyViewID = electronVibrancy.SetVibrancy(app.window, 0)
} else if (!enabled && app.window.vibrancyViewID) {
electronVibrancy.RemoveView(app.window, app.window.vibrancyViewID)
app.window.vibrancyViewID = null
}
}
const setupTray = () => {
if (process.platform === 'darwin') {
app.tray = new Tray(`${app.getAppPath()}/assets/tray-darwinTemplate.png`)
app.tray.setPressedImage(`${app.getAppPath()}/assets/tray-darwinHighlightTemplate.png`)
} else {
app.tray = new Tray(`${app.getAppPath()}/assets/tray.png`)
}
app.tray.on('click', () => {
app.window.show()
app.window.focus()
})
const contextMenu = Menu.buildFromTemplate([{
label: 'Show',
click () {
app.window.show()
app.window.focus()
},
}])
if (process.platform !== 'darwin') {
app.tray.setContextMenu(contextMenu)
}
app.tray.setToolTip(`Terminus ${app.getVersion()}`)
}
const setupWindowManagement = () => {
app.window.on('show', () => {
app.window.webContents.send('host:window-shown')
if (app.tray) {
app.tray.destroy()
app.tray = null
}
})
app.window.on('hide', () => {
if (!app.tray) {
setupTray()
}
})
app.window.on('enter-full-screen', () => app.window.webContents.send('host:window-enter-full-screen'))
app.window.on('leave-full-screen', () => app.window.webContents.send('host:window-leave-full-screen'))
app.window.on('close', () => {
windowConfig.set('windowBoundaries', app.window.getBounds())
})
app.window.on('closed', () => {
app.window = null
})
ipcMain.on('window-focus', () => {
app.window.focus()
})
ipcMain.on('window-maximize', () => {
app.window.maximize()
})
ipcMain.on('window-unmaximize', () => {
app.window.unmaximize()
})
ipcMain.on('window-toggle-maximize', () => {
if (app.window.isMaximized()) {
app.window.unmaximize()
} else {
app.window.maximize()
}
})
ipcMain.on('window-minimize', () => {
app.window.minimize()
})
ipcMain.on('window-set-bounds', (event, bounds) => {
app.window.setBounds(bounds)
})
ipcMain.on('window-set-always-on-top', (event, flag) => {
app.window.setAlwaysOnTop(flag)
})
ipcMain.on('window-set-vibrancy', (event, enabled) => {
setWindowVibrancy(enabled)
})
}
const setupMenu = () => {
let template = [{
label: 'Application',
submenu: [
{ role: 'about', label: 'About Terminus' },
{ type: 'separator' },
{
label: 'Preferences',
accelerator: 'Cmd+,',
click () {
app.window.webContents.send('host:preferences-menu')
},
},
{ type: 'separator' },
{ role: 'services', submenu: [] },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideothers' },
{ role: 'unhide' },
{ type: 'separator' },
{
label: 'Quit',
accelerator: 'Cmd+Q',
click () {
app.quit()
},
},
],
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'pasteandmatchstyle' },
{ role: 'delete' },
{ role: 'selectall' },
],
},
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forcereload' },
{ role: 'toggledevtools' },
{ type: 'separator' },
{ role: 'resetzoom' },
{ role: 'zoomin' },
{ role: 'zoomout' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{
role: 'window',
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
{ type: 'separator' },
{ role: 'front' },
],
},
{
role: 'help',
submenu: [
{
label: 'Website',
click () {
shell.openExternal('https://eugeny.github.io/terminus')
},
},
],
}]
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
}
const start = () => {
let t0 = Date.now()
let configPath = path.join(app.getPath('userData'), 'config.yaml')
let configData
if (fs.existsSync(configPath)) {
configData = yaml.safeLoad(fs.readFileSync(configPath, 'utf8'))
} else {
configData = {}
}
let options = {
width: 800,
height: 600,
title: 'Terminus',
minWidth: 400,
minHeight: 300,
webPreferences: { webSecurity: false },
frame: false,
show: false,
}
Object.assign(options, windowConfig.get('windowBoundaries'))
if ((configData.appearance || {}).frame === 'native') {
options.frame = true
} else {
if (process.platform === 'darwin') {
options.titleBarStyle = 'hiddenInset'
}
}
if (process.platform === 'win32' && (configData.appearance || {}).vibrancy) {
options.transparent = true
}
if (process.platform === 'linux') {
options.backgroundColor = '#131d27'
}
app.commandLine.appendSwitch('disable-http-cache')
app.window = new BrowserWindow(options)
app.window.once('ready-to-show', () => {
if (process.platform === 'darwin') {
app.window.setVibrancy('dark')
} else if (process.platform === 'win32' && (configData.appearance || {}).vibrancy) {
setWindowVibrancy(true)
}
app.window.show()
app.window.focus()
})
app.window.loadURL(`file://${app.getAppPath()}/dist/index.html`, { extraHeaders: 'pragma: no-cache\n' })
if (process.platform !== 'darwin') {
app.window.setMenu(null)
}
setupWindowManagement()
if (process.platform === 'darwin') {
setupMenu()
} else {
app.window.setMenu(null)
}
console.info(`Host startup: ${Date.now() - t0}ms`)
t0 = Date.now()
ipcMain.on('app:ready', () => {
console.info(`App startup: ${Date.now() - t0}ms`)
})
}
app.on('activate', () => {
if (!app.window) {
start()
} else {
app.window.show()
app.window.focus()
}
})
process.on('uncaughtException', function (err) {
console.log(err)
app.window.webContents.send('uncaughtException', err)
})
app.on('second-instance', (event, argv, cwd) => {
app.window.webContents.send('host:second-instance', parseArgs(argv, cwd))
})
const argv = parseArgs(process.argv, process.cwd())
if (!app.requestSingleInstanceLock()) {
app.quit()
process.exit(0)
}
if (argv.d) {
electronDebug({ enabled: true, showDevTools: 'undocked' })
}
app.on('ready', start)

59
app/lib/index.ts Normal file
View File

@@ -0,0 +1,59 @@
import './lru'
import { app, ipcMain, Menu } from 'electron'
import electronDebug = require('electron-debug')
import { parseArgs } from './cli'
import { Application } from './app'
if (process.platform === 'win32' && require('electron-squirrel-startup')) process.exit(0)
if (!process.env.TERMINUS_PLUGINS) {
process.env.TERMINUS_PLUGINS = ''
}
const application = new Application()
app.commandLine.appendSwitch('disable-http-cache')
ipcMain.on('app:new-window', () => {
console.log('new-window')
application.newWindow()
})
app.on('activate', () => {
if (!application.hasWindows()) {
application.newWindow()
} else {
application.focus()
}
})
process.on('uncaughtException' as any, err => {
console.log(err)
application.broadcast('uncaughtException', err)
})
app.on('second-instance', (_event, argv, cwd) => {
application.send('host:second-instance', parseArgs(argv, cwd))
})
const argv = parseArgs(process.argv, process.cwd())
if (!app.requestSingleInstanceLock()) {
app.quit()
process.exit(0)
}
if (argv.d) {
electronDebug({ enabled: true, showDevTools: 'undocked' })
}
app.on('ready', () => {
app.dock.setMenu(Menu.buildFromTemplate([
{
label: 'New window',
click () {
this.app.newWindow()
}
}
]))
application.newWindow()
})

View File

@@ -6,10 +6,10 @@ let origLstat = fs.realpathSync.bind(fs)
// NB: The biggest offender of thrashing realpathSync is the node module system
// itself, which we can't get into via any sane means.
require('fs').realpathSync = function (p) {
let r = lru.get(p)
if (r) return r
let r = lru.get(p)
if (r) return r
r = origLstat(p)
lru.set(p, r)
return r
r = origLstat(p)
lru.set(p, r)
return r
}

173
app/lib/window.ts Normal file
View File

@@ -0,0 +1,173 @@
import { Subject, Observable } from 'rxjs'
import { BrowserWindow, app, ipcMain } from 'electron'
import ElectronConfig = require('electron-config')
import * as yaml from 'js-yaml'
import * as fs from 'fs'
import * as path from 'path'
let electronVibrancy: any
if (process.platform !== 'linux') {
electronVibrancy = require('electron-vibrancy')
}
export class Window {
ready: Promise<void>
private visible = new Subject<boolean>()
private window: BrowserWindow
private vibrancyViewID: number
private windowConfig: ElectronConfig
get visible$ (): Observable<boolean> { return this.visible }
constructor () {
let configPath = path.join(app.getPath('userData'), 'config.yaml')
let configData
if (fs.existsSync(configPath)) {
configData = yaml.safeLoad(fs.readFileSync(configPath, 'utf8'))
} else {
configData = {}
}
this.windowConfig = new ElectronConfig({ name: 'window' })
let options: Electron.BrowserWindowConstructorOptions = {
width: 800,
height: 600,
title: 'Terminus',
minWidth: 400,
minHeight: 300,
webPreferences: { webSecurity: false },
frame: false,
show: false,
}
Object.assign(options, this.windowConfig.get('windowBoundaries'))
if ((configData.appearance || {}).frame === 'native') {
options.frame = true
} else {
if (process.platform === 'darwin') {
options.titleBarStyle = 'hiddenInset'
}
}
if (process.platform === 'win32' && (configData.appearance || {}).vibrancy) {
options.transparent = true
}
if (process.platform === 'linux') {
options.backgroundColor = '#131d27'
}
this.window = new BrowserWindow(options)
this.window.once('ready-to-show', () => {
if (process.platform === 'darwin') {
this.window.setVibrancy('dark')
} else if (process.platform === 'win32' && (configData.appearance || {}).vibrancy) {
this.setVibrancy(true)
}
this.window.show()
this.window.focus()
})
this.window.loadURL(`file://${app.getAppPath()}/dist/index.html?${this.window.id}`, { extraHeaders: 'pragma: no-cache\n' })
if (process.platform !== 'darwin') {
this.window.setMenu(null)
}
this.setupWindowManagement()
this.ready = new Promise(resolve => {
const listener = event => {
if (event.sender === this.window.webContents) {
ipcMain.removeListener('app:ready', listener)
resolve()
}
}
ipcMain.on('app:ready', listener)
})
}
setVibrancy (enabled: boolean) {
if (enabled && !this.vibrancyViewID) {
this.vibrancyViewID = electronVibrancy.SetVibrancy(this.window, 0)
} else if (!enabled && this.vibrancyViewID) {
electronVibrancy.RemoveView(this.window, this.vibrancyViewID)
this.vibrancyViewID = null
}
}
show () {
this.window.show()
}
focus () {
this.window.focus()
}
send (event, ...args) {
this.window.webContents.send(event, ...args)
}
private setupWindowManagement () {
this.window.on('show', () => {
this.visible.next(true)
this.window.webContents.send('host:window-shown')
})
this.window.on('hide', () => {
this.visible.next(false)
})
this.window.on('enter-full-screen', () => this.window.webContents.send('host:window-enter-full-screen'))
this.window.on('leave-full-screen', () => this.window.webContents.send('host:window-leave-full-screen'))
this.window.on('close', () => {
this.windowConfig.set('windowBoundaries', this.window.getBounds())
})
this.window.on('closed', () => {
this.destroy()
})
ipcMain.on('window-focus', () => {
this.window.focus()
})
ipcMain.on('window-maximize', () => {
this.window.maximize()
})
ipcMain.on('window-unmaximize', () => {
this.window.unmaximize()
})
ipcMain.on('window-toggle-maximize', () => {
if (this.window.isMaximized()) {
this.window.unmaximize()
} else {
this.window.maximize()
}
})
ipcMain.on('window-minimize', () => {
this.window.minimize()
})
ipcMain.on('window-set-bounds', (_event, bounds) => {
this.window.setBounds(bounds)
})
ipcMain.on('window-set-always-on-top', (_event, flag) => {
this.window.setAlwaysOnTop(flag)
})
ipcMain.on('window-set-vibrancy', (_event, enabled) => {
this.setVibrancy(enabled)
})
}
private destroy () {
this.window = null
this.visible.complete()
}
}