import type { BrowserWindow, TouchBar, MenuItemConstructorOptions } from 'electron' import * as path from 'path' import * as fs from 'mz/fs' import shellEscape from 'shell-escape' import { Observable, Subject } from 'rxjs' import { Injectable, NgZone, EventEmitter } from '@angular/core' import { ElectronService } from './electron.service' import { Logger, LogService } from './log.service' import { isWindowsBuild, WIN_BUILD_FLUENT_BG_SUPPORTED } from '../utils' export enum Platform { Linux = 'Linux', macOS = 'macOS', Windows = 'Windows', } export interface Bounds { x: number y: number width: number height: number } /** * Provides interaction with the main process */ @Injectable({ providedIn: 'root' }) export class HostAppService { platform: Platform /** * Fired once the window is visible */ shown = new EventEmitter() isFullScreen = false isPortable = !!process.env.PORTABLE_EXECUTABLE_FILE private preferencesMenu = new Subject() private secondInstance = new Subject() private cliOpenDirectory = new Subject() private cliRunCommand = new Subject() private cliPaste = new Subject() private cliOpenProfile = new Subject() private configChangeBroadcast = new Subject() private windowCloseRequest = new Subject() private windowMoved = new Subject() private windowFocused = new Subject() private displayMetricsChanged = new Subject() private displaysChanged = new Subject() private logger: Logger private windowId: number /** * Fired when Preferences is selected in the macOS menu */ get preferencesMenu$ (): Observable { return this.preferencesMenu } /** * Fired when a second instance of Terminus is launched */ get secondInstance$ (): Observable { return this.secondInstance } /** * Fired for the `terminus open` CLI command */ get cliOpenDirectory$ (): Observable { return this.cliOpenDirectory } /** * Fired for the `terminus run` CLI command */ get cliRunCommand$ (): Observable { return this.cliRunCommand } /** * Fired for the `terminus paste` CLI command */ get cliPaste$ (): Observable { return this.cliPaste } /** * Fired for the `terminus profile` CLI command */ get cliOpenProfile$ (): Observable { return this.cliOpenProfile } /** * Fired when another window modified the config file */ get configChangeBroadcast$ (): Observable { return this.configChangeBroadcast } /** * Fired when the window close button is pressed */ get windowCloseRequest$ (): Observable { return this.windowCloseRequest } get windowMoved$ (): Observable { return this.windowMoved } get windowFocused$ (): Observable { return this.windowFocused } get displayMetricsChanged$ (): Observable { return this.displayMetricsChanged } get displaysChanged$ (): Observable { return this.displaysChanged } private constructor ( private zone: NgZone, private electron: ElectronService, log: LogService, ) { this.logger = log.create('hostApp') this.platform = { win32: Platform.Windows, darwin: Platform.macOS, linux: Platform.Linux, }[process.platform] 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) => { this.logger.error('Unhandled exception:', err) }) electron.ipcRenderer.on('host:window-enter-full-screen', () => this.zone.run(() => { this.isFullScreen = true })) electron.ipcRenderer.on('host:window-leave-full-screen', () => this.zone.run(() => { this.isFullScreen = false })) electron.ipcRenderer.on('host:window-shown', () => { this.zone.run(() => this.shown.emit()) }) electron.ipcRenderer.on('host:window-close-request', () => { this.zone.run(() => this.windowCloseRequest.next()) }) electron.ipcRenderer.on('host:window-moved', () => { this.zone.run(() => this.windowMoved.next()) }) electron.ipcRenderer.on('host:window-focused', () => { this.zone.run(() => this.windowFocused.next()) }) electron.ipcRenderer.on('host:display-metrics-changed', () => { this.zone.run(() => this.displayMetricsChanged.next()) }) electron.ipcRenderer.on('host:displays-changed', () => { this.zone.run(() => this.displaysChanged.next()) }) electron.ipcRenderer.on('cli', (_$event, argv: any, cwd: string, secondInstance: boolean) => this.zone.run(async () => { this.logger.info('Second instance', argv) const op = argv._[0] const opAsPath = path.resolve(cwd, op) if (op === 'open') { this.cliOpenDirectory.next(path.resolve(cwd, argv.directory)) } else if (op === 'run') { this.cliRunCommand.next(argv.command) } else if (op === 'paste') { let text = argv.text if (argv.escape) { text = shellEscape([text]) } this.cliPaste.next(text) } else if (op === 'profile') { this.cliOpenProfile.next(argv.profileName) } else if (op === undefined) { this.newWindow() } else if ((await fs.lstat(opAsPath)).isDirectory()) { this.cliOpenDirectory.next(opAsPath) } if (secondInstance) { this.secondInstance.next() } })) electron.ipcRenderer.on('host:config-change', () => this.zone.run(() => { this.configChangeBroadcast.next() })) if (isWindowsBuild(WIN_BUILD_FLUENT_BG_SUPPORTED)) { electron.ipcRenderer.send('window-set-disable-vibrancy-while-dragging', true) } } /** * Returns the current remote [[BrowserWindow]] */ getWindow (): BrowserWindow { return this.electron.BrowserWindow.fromId(this.windowId)! } newWindow (): void { this.electron.ipcRenderer.send('app:new-window') } toggleFullscreen (): void { const window = this.getWindow() window.setFullScreen(!this.isFullScreen) } openDevTools (): void { this.getWindow().webContents.openDevTools({ mode: 'undocked' }) } focusWindow (): void { this.electron.ipcRenderer.send('window-focus') } minimize (): void { this.electron.ipcRenderer.send('window-minimize') } maximize (): void { this.electron.ipcRenderer.send('window-maximize') } unmaximize (): void { this.electron.ipcRenderer.send('window-unmaximize') } toggleMaximize (): void { this.electron.ipcRenderer.send('window-toggle-maximize') } setBounds (bounds: Bounds): void { this.electron.ipcRenderer.send('window-set-bounds', bounds) } setAlwaysOnTop (flag: boolean): void { this.electron.ipcRenderer.send('window-set-always-on-top', flag) } /** * Sets window vibrancy mode (Windows, macOS) * * @param type `null`, or `fluent` when supported (Windowd only) */ setVibrancy (enable: boolean, type: string|null): void { if (this.platform === Platform.Windows && !isWindowsBuild(WIN_BUILD_FLUENT_BG_SUPPORTED)) { type = null } document.body.classList.toggle('vibrant', enable) this.electron.ipcRenderer.send('window-set-vibrancy', enable, type) } setTitle (title?: string): void { this.electron.ipcRenderer.send('window-set-title', title ?? 'Terminus') } setTouchBar (touchBar: TouchBar): void { this.getWindow().setTouchBar(touchBar) } popupContextMenu (menuDefinition: MenuItemConstructorOptions[]): void { this.electron.Menu.buildFromTemplate(menuDefinition).popup({}) } /** * Notifies other windows of config file changes */ broadcastConfigChange (configStore: Record): void { this.electron.ipcRenderer.send('app:config-change', configStore) } emitReady (): void { this.electron.ipcRenderer.send('app:ready') } bringToFront (): void { this.electron.ipcRenderer.send('window-bring-to-front') } closeWindow (): void { this.electron.ipcRenderer.send('window-close') } registerGlobalHotkey (specs: string[]): void { this.electron.ipcRenderer.send('app:register-global-hotkey', specs) } relaunch (): void { if (this.isPortable) { this.electron.app.relaunch({ execPath: process.env.PORTABLE_EXECUTABLE_FILE }) } else { this.electron.app.relaunch() } this.electron.app.exit() } quit (): void { this.logger.info('Quitting') this.electron.app.quit() } }