diff --git a/app/lib/app.ts b/app/lib/app.ts new file mode 100644 index 0000000000000000000000000000000000000000..9c38c460a67b66ec2f3aed433957206dfdfe0042 --- /dev/null +++ b/app/lib/app.ts @@ -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 { + 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)) + } +} diff --git a/app/lib/cli.js b/app/lib/cli.js deleted file mode 100644 index 53d5d541b63b727bdf06a03164a43f13b300dda0..0000000000000000000000000000000000000000 --- a/app/lib/cli.js +++ /dev/null @@ -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)) -} diff --git a/app/lib/cli.ts b/app/lib/cli.ts new file mode 100644 index 0000000000000000000000000000000000000000..c5f36643b853993ee7e14014e67e3ecfa0e4a312 --- /dev/null +++ b/app/lib/cli.ts @@ -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)) +} diff --git a/app/lib/index.js b/app/lib/index.js deleted file mode 100644 index cd072c1a7a67dbaa0a2d94cfbabaf4f76684acb5..0000000000000000000000000000000000000000 --- a/app/lib/index.js +++ /dev/null @@ -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) diff --git a/app/lib/index.ts b/app/lib/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..91de8a9b36c49755c071b2c7a4235e29ca8c7f44 --- /dev/null +++ b/app/lib/index.ts @@ -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() +}) diff --git a/app/lib/lru.js b/app/lib/lru.ts similarity index 76% rename from app/lib/lru.js rename to app/lib/lru.ts index e3ffefbe2bd622ae56e8b986b1f212c23657e80a..695db68690bcff4cec60e2bab09219246c894b01 100644 --- a/app/lib/lru.js +++ b/app/lib/lru.ts @@ -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 } diff --git a/app/lib/window.ts b/app/lib/window.ts new file mode 100644 index 0000000000000000000000000000000000000000..a33ed98fa31427ee70f3acfcf9541e1ea258eca3 --- /dev/null +++ b/app/lib/window.ts @@ -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 + private visible = new Subject() + private window: BrowserWindow + private vibrancyViewID: number + private windowConfig: ElectronConfig + + get visible$ (): Observable { 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() + } +} diff --git a/app/src/entry.preload.ts b/app/src/entry.preload.ts index b415e078bcfff7a4bd6424329194977f144ca3af..2bd8b06d65c1a3cf274d4ef14cf35641570fc5d5 100644 --- a/app/src/entry.preload.ts +++ b/app/src/entry.preload.ts @@ -1,4 +1,4 @@ -import '../lib/lru.js' +import '../lib/lru' import 'source-sans-pro' import 'font-awesome/css/font-awesome.css' import 'ngx-toastr/toastr.css' @@ -29,7 +29,7 @@ Raven.config( } ) -process.on('uncaughtException', (err) => { +process.on('uncaughtException' as any, (err) => { Raven.captureException(err) console.error(err) }) diff --git a/app/tsconfig.main.json b/app/tsconfig.main.json new file mode 100644 index 0000000000000000000000000000000000000000..4c0b09dbe78abb147862b084571a8db6a868eb02 --- /dev/null +++ b/app/tsconfig.main.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "baseUrl": "./lib", + "module": "commonjs", + "target": "es2017", + "declaration": false, + "noImplicitAny": false, + "removeComments": false, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "sourceMap": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedParameters": true, + "noUnusedLocals": true, + "lib": [ + "dom", + "es2015", + "es2015.iterable", + "es2017", + "es7" + ] + }, + "compileOnSave": false, + "exclude": [ + "dist", + "node_modules", + "*/node_modules" + ] +} diff --git a/app/webpack.config.js b/app/webpack.config.js index 1125fb57a8f0cd29bf0fd4eb3ee155dc10e71b1b..b2532b236e2ef28e0af9863d6387966a53e2064a 100644 --- a/app/webpack.config.js +++ b/app/webpack.config.js @@ -6,8 +6,8 @@ module.exports = { target: 'node', entry: { 'index.ignore': 'file-loader?name=index.html!val-loader!pug-html-loader!' + path.resolve(__dirname, './index.pug'), - 'preload': path.resolve(__dirname, 'src/entry.preload.ts'), - 'bundle': path.resolve(__dirname, 'src/entry.ts'), + preload: path.resolve(__dirname, 'src/entry.preload.ts'), + bundle: path.resolve(__dirname, 'src/entry.ts'), }, mode: process.env.DEV ? 'development' : 'production', context: __dirname, @@ -15,7 +15,7 @@ module.exports = { output: { path: path.join(__dirname, 'dist'), pathinfo: true, - filename: '[name].js' + filename: '[name].js', }, resolve: { modules: ['src/', 'node_modules', '../node_modules', 'assets/'].map(x => path.join(__dirname, x)), @@ -29,8 +29,8 @@ module.exports = { loader: 'awesome-typescript-loader', options: { configFileName: path.resolve(__dirname, 'tsconfig.json'), - } - } + }, + }, }, { test: /\.scss$/, use: ['style-loader', 'css-loader', 'sass-loader'] }, { test: /\.css$/, use: ['style-loader', 'css-loader', 'sass-loader'] }, @@ -39,20 +39,20 @@ module.exports = { use: { loader: 'file-loader', options: { - name: 'images/[name].[ext]' - } - } + name: 'images/[name].[ext]', + }, + }, }, { test: /\.(ttf|eot|otf|woff|woff2)(\?v=[0-9]\.[0-9]\.[0-9])?$/, use: { loader: 'file-loader', options: { - name: 'fonts/[name].[ext]' - } - } - } - ] + name: 'fonts/[name].[ext]', + }, + }, + }, + ], }, externals: { '@angular/core': 'commonjs @angular/core', @@ -62,15 +62,15 @@ module.exports = { '@angular/forms': 'commonjs @angular/forms', '@angular/common': 'commonjs @angular/common', '@ng-bootstrap/ng-bootstrap': 'commonjs @ng-bootstrap/ng-bootstrap', - 'child_process': 'commonjs child_process', - 'electron': 'commonjs electron', + child_process: 'commonjs child_process', + electron: 'commonjs electron', 'electron-is-dev': 'commonjs electron-is-dev', - 'fs': 'commonjs fs', + fs: 'commonjs fs', 'ngx-toastr': 'commonjs ngx-toastr', - 'module': 'commonjs module', - 'mz': 'commonjs mz', - 'path': 'commonjs path', - 'rxjs': 'commonjs rxjs', + module: 'commonjs module', + mz: 'commonjs mz', + path: 'commonjs path', + rxjs: 'commonjs rxjs', 'zone.js': 'commonjs zone.js/dist/zone.js', }, plugins: [ diff --git a/app/webpack.main.config.js b/app/webpack.main.config.js index dcc33770b60bff4f4e8973e9d01c0b0624a326b5..d392cd9714a7cc0e87ad188bd8def439b6d79f5f 100644 --- a/app/webpack.main.config.js +++ b/app/webpack.main.config.js @@ -5,7 +5,7 @@ module.exports = { name: 'terminus-main', target: 'node', entry: { - main: path.resolve(__dirname, 'lib/index.js'), + main: path.resolve(__dirname, 'lib/index.ts'), }, mode: process.env.DEV ? 'development' : 'production', context: __dirname, @@ -22,12 +22,11 @@ module.exports = { module: { rules: [ { - test: /lib[\\/].*\.js$/, - exclude: /node_modules/, + test: /\.ts$/, use: { - loader: 'babel-loader', + loader: 'awesome-typescript-loader', options: { - presets: ['babel-preset-es2015'], + configFileName: path.resolve(__dirname, 'tsconfig.main.json'), }, }, }, diff --git a/package.json b/package.json index 35d5e0dd25c198da05fac34d22b71c52c4204a82..16e7b0e90ea9b7fc3f528d320d5b2ac05549f5f2 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "@types/electron-config": "^0.2.1", "@types/electron-debug": "^1.1.0", "@types/fs-promise": "1.0.1", + "@types/js-yaml": "^3.11.2", "@types/node": "7.0.5", "@types/webpack-env": "1.13.0", "apply-loader": "0.1.0", diff --git a/terminus-core/src/components/appRoot.component.ts b/terminus-core/src/components/appRoot.component.ts index e1c41fbbf901e27e11b4e8a6a9ffe47fa6695659..a026c2916e0e912efd5612bf354bdd2e9c99b591 100644 --- a/terminus-core/src/components/appRoot.component.ts +++ b/terminus-core/src/components/appRoot.component.ts @@ -142,9 +142,9 @@ export class AppRootComponent { this.unsortedTabs.push(tab) tab.progress$.subscribe(progress => { if (progress !== null) { - this.hostApp.getWindow().setProgressBar(progress / 100.0, 'normal') + this.hostApp.getWindow().setProgressBar(progress / 100.0, { mode: 'normal' }) } else { - this.hostApp.getWindow().setProgressBar(-1, 'none') + this.hostApp.getWindow().setProgressBar(-1, { mode: 'none' }) } }) }) @@ -154,26 +154,26 @@ export class AppRootComponent { } onGlobalHotkey () { - if (this.electron.app.window.isFocused()) { + if (this.hostApp.getWindow().isFocused()) { // focused this.electron.loseFocus() if (this.hostApp.platform !== Platform.macOS) { - this.electron.app.window.hide() + this.hostApp.getWindow().hide() } } else { - if (!this.electron.app.window.isVisible()) { + if (!this.hostApp.getWindow().isVisible()) { // unfocused, invisible - this.electron.app.window.show() - this.electron.app.window.focus() + this.hostApp.getWindow().show() + this.hostApp.getWindow().focus() } else { if (this.config.store.appearance.dock === 'off') { // not docked, visible setTimeout(() => { - this.electron.app.window.focus() + this.hostApp.getWindow().focus() }) } else { // docked, visible - this.electron.app.window.hide() + this.hostApp.getWindow().hide() } } } @@ -223,7 +223,7 @@ export class AppRootComponent { } private updateVibrancy () { - this.hostApp.setVibrancy(this.config.store.appearance.vibrancy) - this.hostApp.getWindow().setOpacity(this.config.store.appearance.opacity) + this.hostApp.setVibrancy(this.config.store.appearance.vibrancy) + this.hostApp.getWindow().setOpacity(this.config.store.appearance.opacity) } } diff --git a/terminus-core/src/configDefaults.linux.yaml b/terminus-core/src/configDefaults.linux.yaml index 01042767df97e4f4d16eb7e147548f9ae6aec36d..75671c35f24b6b6278e63caed07e7134ce290dab 100644 --- a/terminus-core/src/configDefaults.linux.yaml +++ b/terminus-core/src/configDefaults.linux.yaml @@ -1,4 +1,6 @@ hotkeys: + new-window: + - 'Ctrl-Shift-N' toggle-window: - 'Ctrl+Space' toggle-fullscreen: diff --git a/terminus-core/src/configDefaults.macos.yaml b/terminus-core/src/configDefaults.macos.yaml index a37ef6a96e0fb68f7b1eff4a2558cba079deab5d..239f30fc5b8b23a9f0684d0c73704e722a4341fc 100644 --- a/terminus-core/src/configDefaults.macos.yaml +++ b/terminus-core/src/configDefaults.macos.yaml @@ -1,4 +1,6 @@ hotkeys: + new-window: + - '⌘-N' toggle-window: - 'Ctrl+Space' toggle-fullscreen: diff --git a/terminus-core/src/configDefaults.windows.yaml b/terminus-core/src/configDefaults.windows.yaml index 7b0fbf9cb450fe23576451ea4e78094162467d97..b5222d65c91d87cd5cb608728a63f78ec4a5bdc6 100644 --- a/terminus-core/src/configDefaults.windows.yaml +++ b/terminus-core/src/configDefaults.windows.yaml @@ -1,4 +1,6 @@ hotkeys: + new-window: + - 'Ctrl-Shift-N' toggle-window: - 'Ctrl+Space' toggle-fullscreen: diff --git a/terminus-core/src/services/app.service.ts b/terminus-core/src/services/app.service.ts index e08a509f55ce58eb4c1e311ad93f6ff7ef29abbb..ef2b69e00afaeaf64726c8384161975fa7e35a12 100644 --- a/terminus-core/src/services/app.service.ts +++ b/terminus-core/src/services/app.service.ts @@ -3,6 +3,7 @@ import { Injectable, ComponentFactoryResolver, Injector } from '@angular/core' import { BaseTabComponent } from '../components/baseTab.component' import { Logger, LogService } from './log.service' import { ConfigService } from './config.service' +import { HostAppService } from './hostApp.service' export declare type TabComponentType = new (...args: any[]) => BaseTabComponent @@ -28,6 +29,7 @@ export class AppService { constructor ( private componentFactoryResolver: ComponentFactoryResolver, private config: ConfigService, + private hostApp: HostAppService, private injector: Injector, log: LogService, ) { @@ -37,15 +39,21 @@ export class AppService { openNewTab (type: TabComponentType, inputs?: any): BaseTabComponent { let componentFactory = this.componentFactoryResolver.resolveComponentFactory(type) let componentRef = componentFactory.create(this.injector) - componentRef.instance.hostView = componentRef.hostView - Object.assign(componentRef.instance, inputs || {}) + let tab = componentRef.instance + tab.hostView = componentRef.hostView + Object.assign(tab, inputs || {}) - this.tabs.push(componentRef.instance) - this.selectTab(componentRef.instance) + this.tabs.push(tab) + this.selectTab(tab) this.tabsChanged.next() - this.tabOpened.next(componentRef.instance) + this.tabOpened.next(tab) - return componentRef.instance + tab.titleChange$.subscribe(title => { + if (tab === this.activeTab) { + this.hostApp.getWindow().setTitle(title) + } + }) + return tab } selectTab (tab: BaseTabComponent) { @@ -67,6 +75,7 @@ export class AppService { if (this.activeTab) { this.activeTab.emitFocused() } + this.hostApp.getWindow().setTitle(this.activeTab.title) } toggleLastTab () { @@ -122,5 +131,6 @@ export class AppService { emitReady () { this.ready.next(null) this.ready.complete() + this.hostApp.emitReady() } } diff --git a/terminus-core/src/services/config.service.ts b/terminus-core/src/services/config.service.ts index 066c8f4e72e7ac9974e9653677d1b860bd970898..e6433bd07c00d649cba6683f84fc854ce886d0f0 100644 --- a/terminus-core/src/services/config.service.ts +++ b/terminus-core/src/services/config.service.ts @@ -63,7 +63,7 @@ export class ConfigService { constructor ( electron: ElectronService, - hostApp: HostAppService, + private hostApp: HostAppService, @Inject(ConfigProvider) configProviders: ConfigProvider[], ) { this.path = path.join(electron.app.getPath('userData'), 'config.yaml') @@ -78,6 +78,11 @@ export class ConfigService { return defaults }).reduce(configMerge) this.load() + + hostApp.configChangeBroadcast$.subscribe(() => { + this.load() + this.emitChange() + }) } getDefaults () { @@ -96,6 +101,7 @@ export class ConfigService { save (): void { fs.writeFileSync(this.path, yaml.safeDump(this._store), 'utf8') this.emitChange() + this.hostApp.broadcastConfigChange() } readRaw (): string { diff --git a/terminus-core/src/services/docking.service.ts b/terminus-core/src/services/docking.service.ts index 63c0ccce1fc7ed47b8447a32414b88624ec7b5fb..018522f7369269b759b70d6be9c39a74f76e850c 100644 --- a/terminus-core/src/services/docking.service.ts +++ b/terminus-core/src/services/docking.service.ts @@ -76,12 +76,8 @@ export class DockingService { }) } - getWindow () { - return this.electron.app.window - } - repositionWindow () { - let [x, y] = this.getWindow().getPosition() + let [x, y] = this.hostApp.getWindow().getPosition() for (let screen of this.electron.screen.getAllDisplays()) { let bounds = screen.bounds if (x >= bounds.x && x <= bounds.x + bounds.width && y >= bounds.y && y <= bounds.y + bounds.height) { @@ -89,6 +85,6 @@ export class DockingService { } } let screen = this.electron.screen.getPrimaryDisplay() - this.getWindow().setPosition(screen.bounds.x, screen.bounds.y) + this.hostApp.getWindow().setPosition(screen.bounds.x, screen.bounds.y) } } diff --git a/terminus-core/src/services/electron.service.ts b/terminus-core/src/services/electron.service.ts index ccc238b32acec624e31df6a4b8e8024db3a8528a..d20e3bcca41459053b420d76d306a4d6b738b23c 100644 --- a/terminus-core/src/services/electron.service.ts +++ b/terminus-core/src/services/electron.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core' -import { TouchBar } from 'electron' +import { TouchBar, BrowserWindow } from 'electron' @Injectable() export class ElectronService { @@ -13,6 +13,7 @@ export class ElectronService { screen: any remote: any TouchBar: typeof TouchBar + BrowserWindow: typeof BrowserWindow private electron: any constructor () { @@ -27,6 +28,7 @@ export class ElectronService { this.globalShortcut = this.remote.globalShortcut this.nativeImage = this.remote.nativeImage this.TouchBar = this.remote.TouchBar + this.BrowserWindow = this.remote.BrowserWindow } remoteRequire (name: string): any { diff --git a/terminus-core/src/services/hostApp.service.ts b/terminus-core/src/services/hostApp.service.ts index 40d5ca0e4fa717006363c424fc3208a60526dea5..e18e858ede9b1e9a5b7f365ffd10e165d8329934 100644 --- a/terminus-core/src/services/hostApp.service.ts +++ b/terminus-core/src/services/hostApp.service.ts @@ -1,8 +1,8 @@ import * as path from 'path' import { Observable, Subject } from 'rxjs' import { Injectable, NgZone, EventEmitter } from '@angular/core' -import { ElectronService } from '../services/electron.service' -import { Logger, LogService } from '../services/log.service' +import { ElectronService } from './electron.service' +import { Logger, LogService } from './log.service' export enum Platform { Linux, macOS, Windows, @@ -19,19 +19,21 @@ export interface Bounds { export class HostAppService { platform: Platform nodePlatform: string - ready = new EventEmitter() shown = new EventEmitter() isFullScreen = false private preferencesMenu = new Subject() private secondInstance = new Subject() private cliOpenDirectory = new Subject() private cliRunCommand = new Subject() + private configChangeBroadcast = new Subject() private logger: Logger + private windowId: number get preferencesMenu$ (): Observable { return this.preferencesMenu } get secondInstance$ (): Observable { return this.secondInstance } get cliOpenDirectory$ (): Observable { return this.cliOpenDirectory } get cliRunCommand$ (): Observable { return this.cliRunCommand } + get configChangeBroadcast$ (): Observable { return this.configChangeBroadcast } constructor ( private zone: NgZone, @@ -46,9 +48,12 @@ export class HostAppService { linux: Platform.Linux }[this.nodePlatform] + this.windowId = parseInt(location.search.substring(1)) + this.logger.info('Window ID:', this.windowId) + electron.ipcRenderer.on('host:preferences-menu', () => this.zone.run(() => this.preferencesMenu.next())) - electron.ipcRenderer.on('uncaughtException', ($event, err) => { + electron.ipcRenderer.on('uncaughtException', (_$event, err) => { this.logger.error('Unhandled exception:', err) }) @@ -64,7 +69,7 @@ export class HostAppService { this.zone.run(() => this.shown.emit()) }) - electron.ipcRenderer.on('host:second-instance', ($event, argv: any, cwd: string) => this.zone.run(() => { + electron.ipcRenderer.on('host:second-instance', (_$event, argv: any, cwd: string) => this.zone.run(() => { this.logger.info('Second instance', argv) const op = argv._[0] if (op === 'open') { @@ -74,13 +79,17 @@ export class HostAppService { } })) - this.ready.subscribe(() => { - electron.ipcRenderer.send('app:ready') - }) + electron.ipcRenderer.on('host:config-change', () => this.zone.run(() => { + this.configChangeBroadcast.next() + })) } getWindow () { - return this.electron.app.window + return this.electron.BrowserWindow.fromId(this.windowId) + } + + newWindow () { + this.electron.ipcRenderer.send('app:new-window') } getShell () { @@ -142,6 +151,14 @@ export class HostAppService { } } + broadcastConfigChange () { + this.electron.ipcRenderer.send('app:config-change') + } + + emitReady () { + this.electron.ipcRenderer.send('app:ready') + } + quit () { this.logger.info('Quitting') this.electron.app.quit() diff --git a/terminus-core/src/services/hotkeys.service.ts b/terminus-core/src/services/hotkeys.service.ts index 43830229aedf395dd6017069b7b0f90e5b99ca6b..760c0c73a45648e290573d303a56854ea431b4ab 100644 --- a/terminus-core/src/services/hotkeys.service.ts +++ b/terminus-core/src/services/hotkeys.service.ts @@ -174,6 +174,10 @@ export class HotkeysService { @Injectable() export class AppHotkeyProvider extends HotkeyProvider { hotkeys: IHotkeyDescription[] = [ + { + id: 'new-window', + name: 'New window', + }, { id: 'toggle-window', name: 'Toggle terminal window', diff --git a/terminus-core/src/services/touchbar.service.ts b/terminus-core/src/services/touchbar.service.ts index ad9db0e38234d42ec425fa7e299a7bcfd26e4320..ea1b0b096c7dff08cf22995b368c65fd952a26d1 100644 --- a/terminus-core/src/services/touchbar.service.ts +++ b/terminus-core/src/services/touchbar.service.ts @@ -3,6 +3,7 @@ import { TouchBarSegmentedControl, SegmentedControlSegment } from 'electron' import { AppService } from './app.service' import { ConfigService } from './config.service' import { ElectronService } from './electron.service' +import { HostAppService } from './hostApp.service' import { IToolbarButton, ToolbarButtonProvider } from '../api' @Injectable() @@ -12,6 +13,7 @@ export class TouchbarService { constructor ( private app: AppService, + private hostApp: HostAppService, @Inject(ToolbarButtonProvider) private toolbarButtonProviders: ToolbarButtonProvider[], private config: ConfigService, private electron: ElectronService, @@ -51,7 +53,7 @@ export class TouchbarService { ...buttons.map(button => this.getButton(button)) ] }) - this.electron.app.window.setTouchBar(touchBar) + this.hostApp.getWindow().setTouchBar(touchBar) } private getButton (button: IToolbarButton): Electron.TouchBarButton { diff --git a/terminus-terminal/src/buttonProvider.ts b/terminus-terminal/src/buttonProvider.ts index 6cbe8db8708c9612ccbbdf0811c8d70d5ee63934..8de05816db3f4f144801957cc9ef8f4e49f7eef9 100644 --- a/terminus-terminal/src/buttonProvider.ts +++ b/terminus-terminal/src/buttonProvider.ts @@ -17,7 +17,12 @@ export class ButtonProvider extends ToolbarButtonProvider { super() hotkeys.matchedHotkey.subscribe(async (hotkey) => { if (hotkey === 'new-tab') { - this.terminal.openTab() + terminal.openTab() + } + }) + hotkeys.matchedHotkey.subscribe(async (hotkey) => { + if (hotkey === 'new-window') { + hostApp.newWindow() } }) hostApp.cliOpenDirectory$.subscribe(async directory => { diff --git a/yarn.lock b/yarn.lock index d2aad41db5e4392ace73129b23ab8f2591d0d0d3..1a5cdaf5596f3ce28d5c1244fb38732d2d9f452d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -148,6 +148,10 @@ "@types/mz" "*" "@types/node" "*" +"@types/js-yaml@^3.11.2": + version "3.11.2" + resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-3.11.2.tgz#699ad86054cc20043c30d66a6fcde30bbf5d3d5e" + "@types/mz@*": version "0.0.32" resolved "https://registry.yarnpkg.com/@types/mz/-/mz-0.0.32.tgz#e8248b4e41424c052edc1725dd33650c313a3659"