Browse Source

more electron/web separation

Eugene Pankov 4 years ago
parent
commit
fad7858f3f
50 changed files with 567 additions and 447 deletions
  1. 17 5
      app/lib/app.ts
  2. 3 8
      app/lib/window.ts
  3. 10 6
      app/src/entry.ts
  4. 36 55
      app/src/plugins.ts
  5. 1 2
      scripts/vars.js
  6. 53 0
      terminus-core/src/api/hostApp.ts
  7. 25 2
      terminus-core/src/api/hostWindow.ts
  8. 2 2
      terminus-core/src/api/index.ts
  9. 14 0
      terminus-core/src/api/mainProcess.ts
  10. 6 0
      terminus-core/src/api/platform.ts
  11. 1 1
      terminus-core/src/cli.ts
  12. 2 2
      terminus-core/src/components/appRoot.component.ts
  13. 1 1
      terminus-core/src/components/tabHeader.component.ts
  14. 2 2
      terminus-core/src/config.ts
  15. 0 4
      terminus-core/src/configDefaults.linux.yaml
  16. 0 4
      terminus-core/src/configDefaults.macos.yaml
  17. 6 0
      terminus-core/src/configDefaults.web.yaml
  18. 0 4
      terminus-core/src/configDefaults.windows.yaml
  19. 0 8
      terminus-core/src/hotkeys.ts
  20. 6 2
      terminus-core/src/index.ts
  21. 2 2
      terminus-core/src/services/app.service.ts
  22. 1 1
      terminus-core/src/services/config.service.ts
  23. 5 0
      terminus-core/src/services/docking.service.ts
  24. 4 3
      terminus-core/src/services/homeBase.service.ts
  25. 0 209
      terminus-core/src/services/hostApp.service.ts
  26. 0 30
      terminus-core/src/services/hotkeys.service.ts
  27. 26 0
      terminus-electron/src/config.ts
  28. 21 0
      terminus-electron/src/hotkeys.ts
  29. 46 8
      terminus-electron/src/index.ts
  30. 18 11
      terminus-electron/src/services/docking.service.ts
  31. 85 0
      terminus-electron/src/services/hostApp.service.ts
  32. 54 9
      terminus-electron/src/services/hostWindow.service.ts
  33. 17 6
      terminus-electron/src/services/platform.service.ts
  34. 3 1
      terminus-electron/src/services/touchbar.service.ts
  35. 7 7
      terminus-local/src/cli.ts
  36. 3 1
      terminus-local/src/components/shellSettingsTab.component.ts
  37. 2 2
      terminus-plugin-manager/src/components/pluginsSettingsTab.component.ts
  38. 7 17
      terminus-plugin-manager/src/services/pluginManager.service.ts
  39. 1 1
      terminus-settings/src/buttonProvider.ts
  40. 5 5
      terminus-settings/src/components/settingsTab.component.pug
  41. 2 0
      terminus-settings/src/components/settingsTab.component.ts
  42. 1 1
      terminus-settings/src/components/windowSettingsTab.component.ts
  43. 17 10
      terminus-ssh/src/buttonProvider.ts
  44. 1 3
      terminus-ssh/src/components/editConnectionModal.component.ts
  45. 5 3
      terminus-terminal/src/api/baseTerminalTab.component.ts
  46. 3 4
      terminus-terminal/src/cli.ts
  47. 3 1
      terminus-web/src/index.ts
  48. 5 1
      terminus-web/src/platform.ts
  49. 33 0
      terminus-web/src/services/hostApp.service.ts
  50. 5 3
      terminus-web/src/services/hostWindow.service.ts

+ 17 - 5
app/lib/app.ts

@@ -1,6 +1,8 @@
 import { app, ipcMain, Menu, Tray, shell, screen, globalShortcut, MenuItemConstructorOptions } from 'electron'
 import * as promiseIpc from 'electron-promise-ipc'
 import * as remote from '@electron/remote/main'
+import * as path from 'path'
+import * as fs from 'fs'
 
 import { loadConfig } from './config'
 import { Window, WindowOptions } from './window'
@@ -17,6 +19,7 @@ export class Application {
     private tray?: Tray
     private ptyManager = new PTYManager()
     private windows: Window[] = []
+    userPluginsPath: string
 
     constructor () {
         remote.initialize()
@@ -36,12 +39,12 @@ export class Application {
             }
         })
 
-        ;(promiseIpc as any).on('plugin-manager:install', (path, name, version) => {
-            return pluginManager.install(path, name, version)
+        ;(promiseIpc as any).on('plugin-manager:install', (name, version) => {
+            return pluginManager.install(this.userPluginsPath, name, version)
         })
 
-        ;(promiseIpc as any).on('plugin-manager:uninstall', (path, name) => {
-            return pluginManager.uninstall(path, name)
+        ;(promiseIpc as any).on('plugin-manager:uninstall', (name) => {
+            return pluginManager.uninstall(this.userPluginsPath, name)
         })
 
         const configData = loadConfig()
@@ -53,6 +56,15 @@ export class Application {
             }
         }
 
+        this.userPluginsPath = path.join(
+            app.getPath('userData'),
+            'plugins',
+        )
+
+        if (!fs.existsSync(this.userPluginsPath)) {
+            fs.mkdirSync(this.userPluginsPath)
+        }
+
         app.commandLine.appendSwitch('disable-http-cache')
         app.commandLine.appendSwitch('max-active-webgl-contexts', '9000')
         app.commandLine.appendSwitch('lang', 'EN')
@@ -70,7 +82,7 @@ export class Application {
     }
 
     async newWindow (options?: WindowOptions): Promise<Window> {
-        const window = new Window(options)
+        const window = new Window(this, options)
         this.windows.push(window)
         window.visible$.subscribe(visible => {
             if (visible) {

+ 3 - 8
app/lib/window.ts

@@ -9,6 +9,7 @@ import * as path from 'path'
 import macOSRelease from 'macos-release'
 import * as compareVersions from 'compare-versions'
 
+import type { Application } from './app'
 import { parseArgs } from './cli'
 import { loadConfig } from './config'
 
@@ -43,7 +44,7 @@ export class Window {
     get visible$ (): Observable<boolean> { return this.visible }
     get closed$ (): Observable<void> { return this.closed }
 
-    constructor (options?: WindowOptions) {
+    constructor (private application: Application, options?: WindowOptions) {
         this.configStore = loadConfig()
 
         options = options ?? {}
@@ -299,16 +300,10 @@ export class Window {
                 executable: app.getPath('exe'),
                 windowID: this.window.id,
                 isFirstWindow: this.window.id === 1,
+                userPluginsPath: this.application.userPluginsPath,
             })
         })
 
-        ipcMain.on('window-focus', event => {
-            if (!this.window || event.sender !== this.window.webContents) {
-                return
-            }
-            this.window.focus()
-        })
-
         ipcMain.on('window-toggle-maximize', event => {
             if (!this.window || event.sender !== this.window.webContents) {
                 return

+ 10 - 6
app/src/entry.ts

@@ -11,7 +11,7 @@ import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'
 import { ipcRenderer } from 'electron'
 
 import { getRootModule } from './app.module'
-import { findPlugins, loadPlugins, PluginInfo } from './plugins'
+import { findPlugins, initModuleLookup, loadPlugins } from './plugins'
 import { BootstrapData, BOOTSTRAP_DATA } from '../../terminus-core/src/api/mainProcess'
 
 // Always land on the start view
@@ -29,12 +29,12 @@ if (process.env.TERMINUS_DEV && !process.env.TERMINUS_FORCE_ANGULAR_PROD) {
     enableProdMode()
 }
 
-async function bootstrap (plugins: PluginInfo[], bootstrapData: BootstrapData, safeMode = false): Promise<NgModuleRef<any>> {
+async function bootstrap (bootstrapData: BootstrapData, safeMode = false): Promise<NgModuleRef<any>> {
     if (safeMode) {
-        plugins = plugins.filter(x => x.isBuiltin)
+        bootstrapData.installedPlugins = bootstrapData.installedPlugins.filter(x => x.isBuiltin)
     }
 
-    const pluginModules = await loadPlugins(plugins, (current, total) => {
+    const pluginModules = await loadPlugins(bootstrapData.installedPlugins, (current, total) => {
         (document.querySelector('.progress .bar') as HTMLElement).style.width = `${100 * current / total}%` // eslint-disable-line
     })
     const module = getRootModule(pluginModules)
@@ -53,20 +53,24 @@ async function bootstrap (plugins: PluginInfo[], bootstrapData: BootstrapData, s
 ipcRenderer.once('start', async (_$event, bootstrapData: BootstrapData) => {
     console.log('Window bootstrap data:', bootstrapData)
 
+    initModuleLookup(bootstrapData.userPluginsPath)
+
     let plugins = await findPlugins()
     if (bootstrapData.config.pluginBlacklist) {
         plugins = plugins.filter(x => !bootstrapData.config.pluginBlacklist.includes(x.name))
     }
     plugins = plugins.filter(x => x.name !== 'web')
+    bootstrapData.installedPlugins = plugins
+
     console.log('Starting with plugins:', plugins)
     try {
-        await bootstrap(plugins, bootstrapData)
+        await bootstrap(bootstrapData)
     } catch (error) {
         console.error('Angular bootstrapping error:', error)
         console.warn('Trying safe mode')
         window['safeModeReason'] = error
         try {
-            await bootstrap(plugins, bootstrapData, true)
+            await bootstrap(bootstrapData, true)
         } catch (error2) {
             console.error('Bootstrap failed:', error2)
         }

+ 36 - 55
app/src/plugins.ts

@@ -1,8 +1,11 @@
 import * as fs from 'mz/fs'
 import * as path from 'path'
 import * as remote from '@electron/remote'
+import { PluginInfo } from '../../terminus-core/src/api/mainProcess'
+
 const nodeModule = require('module') // eslint-disable-line @typescript-eslint/no-var-requires
-const nodeRequire = (global as any).require
+
+const nodeRequire = global['require']
 
 function normalizePath (p: string): string {
     const cygwinPrefix = '/cygdrive/'
@@ -13,45 +16,8 @@ function normalizePath (p: string): string {
     return p
 }
 
-global['module'].paths.map((x: string) => nodeModule.globalPaths.push(normalizePath(x)))
-
-if (process.env.TERMINUS_DEV) {
-    nodeModule.globalPaths.unshift(path.dirname(remote.app.getAppPath()))
-}
-
 const builtinPluginsPath = process.env.TERMINUS_DEV ? path.dirname(remote.app.getAppPath()) : path.join((process as any).resourcesPath, 'builtin-plugins')
 
-const userPluginsPath = path.join(
-    remote.app.getPath('userData'),
-    'plugins',
-)
-
-if (!fs.existsSync(userPluginsPath)) {
-    fs.mkdir(userPluginsPath)
-}
-
-Object.assign(window, { builtinPluginsPath, userPluginsPath })
-nodeModule.globalPaths.unshift(builtinPluginsPath)
-nodeModule.globalPaths.unshift(path.join(userPluginsPath, 'node_modules'))
-// nodeModule.globalPaths.unshift(path.join((process as any).resourcesPath, 'app.asar', 'node_modules'))
-if (process.env.TERMINUS_PLUGINS) {
-    process.env.TERMINUS_PLUGINS.split(':').map(x => nodeModule.globalPaths.push(normalizePath(x)))
-}
-
-export type ProgressCallback = (current: number, total: number) => void // eslint-disable-line @typescript-eslint/no-type-alias
-
-export interface PluginInfo {
-    name: string
-    description: string
-    packageName: string
-    isBuiltin: boolean
-    version: string
-    author: string
-    homepage?: string
-    path?: string
-    info?: any
-}
-
 const builtinModules = [
     '@angular/animations',
     '@angular/common',
@@ -71,25 +37,42 @@ const builtinModules = [
     'zone.js/dist/zone.js',
 ]
 
-const cachedBuiltinModules = {}
-builtinModules.forEach(m => {
-    cachedBuiltinModules[m] = nodeRequire(m)
-})
+export type ProgressCallback = (current: number, total: number) => void // eslint-disable-line @typescript-eslint/no-type-alias
+
+export function initModuleLookup (userPluginsPath: string): void {
+    global['module'].paths.map((x: string) => nodeModule.globalPaths.push(normalizePath(x)))
 
-const originalRequire = (global as any).require
-;(global as any).require = function (query: string) {
-    if (cachedBuiltinModules[query]) {
-        return cachedBuiltinModules[query]
+    if (process.env.TERMINUS_DEV) {
+        nodeModule.globalPaths.unshift(path.dirname(remote.app.getAppPath()))
+    }
+
+    nodeModule.globalPaths.unshift(builtinPluginsPath)
+    nodeModule.globalPaths.unshift(path.join(userPluginsPath, 'node_modules'))
+    // nodeModule.globalPaths.unshift(path.join((process as any).resourcesPath, 'app.asar', 'node_modules'))
+    if (process.env.TERMINUS_PLUGINS) {
+        process.env.TERMINUS_PLUGINS.split(':').map(x => nodeModule.globalPaths.push(normalizePath(x)))
     }
-    return originalRequire.apply(this, [query])
-}
 
-const originalModuleRequire = nodeModule.prototype.require
-nodeModule.prototype.require = function (query: string) {
-    if (cachedBuiltinModules[query]) {
-        return cachedBuiltinModules[query]
+    const cachedBuiltinModules = {}
+    builtinModules.forEach(m => {
+        cachedBuiltinModules[m] = nodeRequire(m)
+    })
+
+    const originalRequire = (global as any).require
+    ;(global as any).require = function (query: string) {
+        if (cachedBuiltinModules[query]) {
+            return cachedBuiltinModules[query]
+        }
+        return originalRequire.apply(this, [query])
+    }
+
+    const originalModuleRequire = nodeModule.prototype.require
+    nodeModule.prototype.require = function (query: string) {
+        if (cachedBuiltinModules[query]) {
+            return cachedBuiltinModules[query]
+        }
+        return originalModuleRequire.call(this, query)
     }
-    return originalModuleRequire.call(this, query)
 }
 
 export async function findPlugins (): Promise<PluginInfo[]> {
@@ -167,8 +150,6 @@ export async function findPlugins (): Promise<PluginInfo[]> {
     }
 
     foundPlugins.sort((a, b) => a.name > b.name ? 1 : -1)
-
-    ;(window as any).installedPlugins = foundPlugins
     return foundPlugins
 }
 

+ 1 - 2
scripts/vars.js

@@ -3,7 +3,6 @@ const fs = require('fs')
 const semver = require('semver')
 const childProcess = require('child_process')
 
-const appInfo = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../app/package.json')))
 const electronInfo = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../node_modules/electron/package.json')))
 
 exports.version = childProcess.execSync('git describe --tags', {encoding:'utf-8'})
@@ -18,10 +17,10 @@ exports.builtinPlugins = [
   'terminus-core',
   'terminus-settings',
   'terminus-terminal',
+  'terminus-electron',
   'terminus-local',
   'terminus-web',
   'terminus-community-color-schemes',
-  'terminus-electron',
   'terminus-plugin-manager',
   'terminus-ssh',
   'terminus-serial',

+ 53 - 0
terminus-core/src/api/hostApp.ts

@@ -0,0 +1,53 @@
+import { Observable, Subject } from 'rxjs'
+import { Injector } from '@angular/core'
+import { Logger, LogService } from '../services/log.service'
+
+export enum Platform {
+    Linux = 'Linux',
+    macOS = 'macOS',
+    Windows = 'Windows',
+    Web = 'Web',
+}
+
+/**
+ * Provides interaction with the main process
+ */
+export abstract class HostAppService {
+    abstract get platform (): Platform
+    abstract get configPlatform (): Platform
+
+    protected settingsUIRequest = new Subject<void>()
+    protected configChangeBroadcast = new Subject<void>()
+    protected logger: Logger
+
+    /**
+     * Fired when Preferences is selected in the macOS menu
+     */
+    get settingsUIRequest$ (): Observable<void> { return this.settingsUIRequest }
+
+    /**
+     * Fired when another window modified the config file
+     */
+    get configChangeBroadcast$ (): Observable<void> { return this.configChangeBroadcast }
+
+    constructor (
+        injector: Injector,
+    ) {
+        this.logger = injector.get(LogService).create('hostApp')
+    }
+
+    abstract newWindow (): void
+
+    /**
+     * Notifies other windows of config file changes
+     */
+    // eslint-disable-next-line @typescript-eslint/no-empty-function
+    broadcastConfigChange (_configStore: Record<string, any>): void { }
+
+    // eslint-disable-next-line @typescript-eslint/no-empty-function
+    emitReady (): void { }
+
+    abstract relaunch (): void
+
+    abstract quit (): void
+}

+ 25 - 2
terminus-core/src/api/hostWindow.ts

@@ -1,7 +1,24 @@
-import { Observable } from 'rxjs'
+import { Observable, Subject } from 'rxjs'
 
 export abstract class HostWindowService {
-    abstract readonly closeRequest$: Observable<void>
+
+    /**
+     * Fired once the window is visible
+     */
+    get windowShown$ (): Observable<void> { return this.windowShown }
+
+    /**
+     * Fired when the window close button is pressed
+     */
+    get windowCloseRequest$ (): Observable<void> { return this.windowCloseRequest }
+    get windowMoved$ (): Observable<void> { return this.windowMoved }
+    get windowFocused$ (): Observable<void> { return this.windowFocused }
+
+    protected windowShown = new Subject<void>()
+    protected windowCloseRequest = new Subject<void>()
+    protected windowMoved = new Subject<void>()
+    protected windowFocused = new Subject<void>()
+
     abstract readonly isFullscreen: boolean
     abstract reload (): void
     abstract setTitle (title?: string): void
@@ -9,4 +26,10 @@ export abstract class HostWindowService {
     abstract minimize (): void
     abstract toggleMaximize (): void
     abstract close (): void
+
+    // eslint-disable-next-line @typescript-eslint/no-empty-function
+    openDevTools (): void { }
+
+    // eslint-disable-next-line @typescript-eslint/no-empty-function
+    bringToFront (): void { }
 }

+ 2 - 2
terminus-core/src/api/index.ts

@@ -12,8 +12,9 @@ export { SelectorOption } from './selector'
 export { CLIHandler, CLIEvent } from './cli'
 export { PlatformService, ClipboardContent, MessageBoxResult, MessageBoxOptions, FileDownload, FileUpload, FileTransfer, HTMLFileUpload, FileUploadOptions } from './platform'
 export { MenuItemOptions } from './menu'
-export { BootstrapData, BOOTSTRAP_DATA } from './mainProcess'
+export { BootstrapData, PluginInfo, BOOTSTRAP_DATA } from './mainProcess'
 export { HostWindowService } from './hostWindow'
+export { HostAppService, Platform } from './hostApp'
 
 export { AppService } from '../services/app.service'
 export { ConfigService } from '../services/config.service'
@@ -22,7 +23,6 @@ export { ElectronService } from '../services/electron.service'
 export { Logger, ConsoleLogger, LogService } from '../services/log.service'
 export { HomeBaseService } from '../services/homeBase.service'
 export { HotkeysService } from '../services/hotkeys.service'
-export { HostAppService, Platform, Bounds } from '../services/hostApp.service'
 export { NotificationsService } from '../services/notifications.service'
 export { ThemesService } from '../services/themes.service'
 export { TabsService } from '../services/tabs.service'

+ 14 - 0
terminus-core/src/api/mainProcess.ts

@@ -1,8 +1,22 @@
 export const BOOTSTRAP_DATA = 'BOOTSTRAP_DATA'
 
+export interface PluginInfo {
+    name: string
+    description: string
+    packageName: string
+    isBuiltin: boolean
+    version: string
+    author: string
+    homepage?: string
+    path?: string
+    info?: any
+}
+
 export interface BootstrapData {
     config: Record<string, any>
     executable: string
     isFirstWindow: boolean
     windowID: number
+    installedPlugins: PluginInfo[]
+    userPluginsPath: string
 }

+ 6 - 0
terminus-core/src/api/platform.ts

@@ -77,8 +77,10 @@ export abstract class PlatformService {
     supportsWindowControls = false
 
     get fileTransferStarted$ (): Observable<FileTransfer> { return this.fileTransferStarted }
+    get displayMetricsChanged$ (): Observable<void> { return this.displayMetricsChanged }
 
     protected fileTransferStarted = new Subject<FileTransfer>()
+    protected displayMetricsChanged = new Subject<void>()
 
     abstract readClipboard (): string
     abstract setClipboard (content: ClipboardContent): void
@@ -158,6 +160,7 @@ export abstract class PlatformService {
     abstract getAppVersion (): string
     abstract openExternal (url: string): void
     abstract listFonts (): Promise<string[]>
+    abstract setErrorHandler (handler: (_: any) => void): void
     abstract popupContextMenu (menu: MenuItemOptions[], event?: MouseEvent): void
     abstract showMessageBox (options: MessageBoxOptions): Promise<MessageBoxResult>
     abstract quit (): void
@@ -191,6 +194,9 @@ export class HTMLFileUpload extends FileUpload {
         return chunk
     }
 
+    // eslint-disable-next-line @typescript-eslint/no-empty-function
+    bringToFront (): void { }
+
     // eslint-disable-next-line @typescript-eslint/no-empty-function
     close (): void { }
 }

+ 1 - 1
terminus-core/src/cli.ts

@@ -1,5 +1,5 @@
 import { Injectable } from '@angular/core'
-import { HostAppService } from './services/hostApp.service'
+import { HostAppService } from './api/hostApp'
 import { CLIHandler, CLIEvent } from './api/cli'
 
 @Injectable()

+ 2 - 2
terminus-core/src/components/appRoot.component.ts

@@ -3,7 +3,7 @@ import { Component, Inject, Input, HostListener, HostBinding } from '@angular/co
 import { trigger, style, animate, transition, state } from '@angular/animations'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 
-import { HostAppService, Platform } from '../services/hostApp.service'
+import { HostAppService, Platform } from '../api/hostApp'
 import { HotkeysService } from '../services/hotkeys.service'
 import { Logger, LogService } from '../services/log.service'
 import { ConfigService } from '../services/config.service'
@@ -115,7 +115,7 @@ export class AppRootComponent {
             }
         })
 
-        this.hostApp.windowCloseRequest$.subscribe(async () => {
+        this.hostWindow.windowCloseRequest$.subscribe(async () => {
             this.app.closeWindow()
         })
 

+ 1 - 1
terminus-core/src/components/tabHeader.component.ts

@@ -7,7 +7,7 @@ import { BaseTabComponent } from './baseTab.component'
 import { RenameTabModalComponent } from './renameTabModal.component'
 import { HotkeysService } from '../services/hotkeys.service'
 import { AppService } from '../services/app.service'
-import { HostAppService, Platform } from '../services/hostApp.service'
+import { HostAppService, Platform } from '../api/hostApp'
 import { ConfigService } from '../services/config.service'
 import { BaseComponent } from './base.component'
 import { MenuItemOptions } from '../api/menu'

+ 2 - 2
terminus-core/src/config.ts

@@ -1,5 +1,5 @@
 import { ConfigProvider } from './api/configProvider'
-import { Platform } from './services/hostApp.service'
+import { Platform } from './api/hostApp'
 
 /** @hidden */
 export class CoreConfigProvider extends ConfigProvider {
@@ -7,7 +7,7 @@ export class CoreConfigProvider extends ConfigProvider {
         [Platform.macOS]: require('./configDefaults.macos.yaml'),
         [Platform.Windows]: require('./configDefaults.windows.yaml'),
         [Platform.Linux]: require('./configDefaults.linux.yaml'),
-        [Platform.Web]: require('./configDefaults.windows.yaml'),
+        [Platform.Web]: require('./configDefaults.web.yaml'),
     }
     defaults = require('./configDefaults.yaml')
 }

+ 0 - 4
terminus-core/src/configDefaults.linux.yaml

@@ -1,8 +1,4 @@
 hotkeys:
-  new-window:
-    - 'Ctrl-Shift-N'
-  toggle-window:
-    - 'Ctrl+Space'
   toggle-fullscreen:
     - 'F11'
   close-tab:

+ 0 - 4
terminus-core/src/configDefaults.macos.yaml

@@ -1,8 +1,4 @@
 hotkeys:
-  new-window:
-    - '⌘-N'
-  toggle-window:
-    - 'Ctrl+Space'
   toggle-fullscreen:
     - 'Ctrl+⌘+F'
   close-tab:

+ 6 - 0
terminus-core/src/configDefaults.web.yaml

@@ -0,0 +1,6 @@
+pluginBlacklist: ['local']
+terminal:
+  recoverTabs: false
+enableAnalytics: false
+enableWelcomeTab: false
+enableAutomaticUpdates: false

+ 0 - 4
terminus-core/src/configDefaults.windows.yaml

@@ -1,8 +1,4 @@
 hotkeys:
-  new-window:
-    - 'Ctrl-Shift-N'
-  toggle-window:
-    - 'Ctrl+Space'
   toggle-fullscreen:
     - 'F11'
     - 'Alt-Enter'

+ 0 - 8
terminus-core/src/hotkeys.ts

@@ -5,14 +5,6 @@ import { HotkeyDescription, HotkeyProvider } from './api/hotkeyProvider'
 @Injectable()
 export class AppHotkeyProvider extends HotkeyProvider {
     hotkeys: HotkeyDescription[] = [
-        {
-            id: 'new-window',
-            name: 'New window',
-        },
-        {
-            id: 'toggle-window',
-            name: 'Toggle terminal window',
-        },
         {
             id: 'toggle-fullscreen',
             name: 'Toggle fullscreen mode',

+ 6 - 2
terminus-core/src/index.ts

@@ -27,7 +27,7 @@ import { AutofocusDirective } from './directives/autofocus.directive'
 import { FastHtmlBindDirective } from './directives/fastHtmlBind.directive'
 import { DropZoneDirective } from './directives/dropZone.directive'
 
-import { Theme, CLIHandler, TabContextMenuItemProvider, TabRecoveryProvider, HotkeyProvider, ConfigProvider } from './api'
+import { Theme, CLIHandler, TabContextMenuItemProvider, TabRecoveryProvider, HotkeyProvider, ConfigProvider, PlatformService } from './api'
 
 import { AppService } from './services/app.service'
 import { ConfigService } from './services/config.service'
@@ -102,12 +102,16 @@ const PROVIDERS = [
     ],
 })
 export default class AppModule { // eslint-disable-line @typescript-eslint/no-extraneous-class
-    constructor (app: AppService, config: ConfigService) {
+    constructor (app: AppService, config: ConfigService, platform: PlatformService) {
         app.ready$.subscribe(() => {
             if (config.store.enableWelcomeTab) {
                 app.openNewTabRaw(WelcomeTabComponent)
             }
         })
+
+        platform.setErrorHandler(err => {
+            console.error('Unhandled exception:', err)
+        })
     }
 
     static forRoot (): ModuleWithProviders<AppModule> {

+ 2 - 2
terminus-core/src/services/app.service.ts

@@ -11,9 +11,9 @@ import { SelectorOption } from '../api/selector'
 import { RecoveryToken } from '../api/tabRecovery'
 import { BootstrapData, BOOTSTRAP_DATA } from '../api/mainProcess'
 import { HostWindowService } from '../api/hostWindow'
+import { HostAppService } from '../api/hostApp'
 
 import { ConfigService } from './config.service'
-import { HostAppService } from './hostApp.service'
 import { TabRecoveryService } from './tabRecovery.service'
 import { TabsService, TabComponentType } from './tabs.service'
 
@@ -100,7 +100,7 @@ export class AppService {
             }
         })
 
-        hostApp.windowFocused$.subscribe(() => this._activeTab?.emitFocused())
+        hostWindow.windowFocused$.subscribe(() => this._activeTab?.emitFocused())
 
         this.tabClosed$.subscribe(async tab => {
             const token = await tabRecovery.getFullRecoveryToken(tab)

+ 1 - 1
terminus-core/src/services/config.service.ts

@@ -3,7 +3,7 @@ import * as yaml from 'js-yaml'
 import { Injectable, Inject } from '@angular/core'
 import { ConfigProvider } from '../api/configProvider'
 import { PlatformService } from '../api/platform'
-import { HostAppService } from './hostApp.service'
+import { HostAppService } from '../api/hostApp'
 import { Vault, VaultService } from './vault.service'
 const deepmerge = require('deepmerge')
 

+ 5 - 0
terminus-core/src/services/docking.service.ts

@@ -1,9 +1,14 @@
+import { Observable, Subject } from 'rxjs'
+
 export abstract class Screen {
     id: number
     name?: string
 }
 
 export abstract class DockingService {
+    get screensChanged$ (): Observable<void> { return this.screensChanged }
+    protected screensChanged = new Subject<void>()
+
     abstract dock (): void
     abstract getScreens (): Screen[]
 }

+ 4 - 3
terminus-core/src/services/homeBase.service.ts

@@ -1,8 +1,8 @@
-import { Injectable } from '@angular/core'
+import { Injectable, Inject } from '@angular/core'
 import * as mixpanel from 'mixpanel'
 import { v4 as uuidv4 } from 'uuid'
 import { ConfigService } from './config.service'
-import { PlatformService } from '../api'
+import { PlatformService, BOOTSTRAP_DATA, BootstrapData } from '../api'
 
 @Injectable({ providedIn: 'root' })
 export class HomeBaseService {
@@ -13,6 +13,7 @@ export class HomeBaseService {
     private constructor (
         private config: ConfigService,
         private platform: PlatformService,
+        @Inject(BOOTSTRAP_DATA) private bootstrapData: BootstrapData,
     ) {
         this.appVersion = platform.getAppVersion()
 
@@ -38,7 +39,7 @@ export class HomeBaseService {
             sunos: 'OS: Solaris',
             win32: 'OS: Windows',
         }[process.platform]
-        const plugins = (window as any).installedPlugins.filter(x => !x.isBuiltin).map(x => x.name)
+        const plugins = this.bootstrapData.installedPlugins.filter(x => !x.isBuiltin).map(x => x.name)
         body += `Plugins: ${plugins.join(', ') || 'none'}\n\n`
         this.platform.openExternal(`https://github.com/eugeny/terminus/issues/new?body=${encodeURIComponent(body)}&labels=${label}`)
     }

+ 0 - 209
terminus-core/src/services/hostApp.service.ts

@@ -1,209 +0,0 @@
-import type { BrowserWindow, TouchBar } from 'electron'
-import { Observable, Subject } from 'rxjs'
-import { Injectable, NgZone, EventEmitter, Injector, Inject } from '@angular/core'
-import { ElectronService } from './electron.service'
-import { Logger, LogService } from './log.service'
-import { CLIHandler } from '../api/cli'
-import { BootstrapData, BOOTSTRAP_DATA } from '../api/mainProcess'
-import { isWindowsBuild, WIN_BUILD_FLUENT_BG_SUPPORTED } from '../utils'
-
-export enum Platform {
-    Linux = 'Linux',
-    macOS = 'macOS',
-    Windows = 'Windows',
-    Web = 'Web',
-}
-
-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
-    configPlatform: Platform
-
-    /**
-     * Fired once the window is visible
-     */
-    shown = new EventEmitter<any>()
-    isPortable = !!process.env.PORTABLE_EXECUTABLE_FILE
-
-    private preferencesMenu = new Subject<void>()
-    private configChangeBroadcast = new Subject<void>()
-    private windowCloseRequest = new Subject<void>()
-    private windowMoved = new Subject<void>()
-    private windowFocused = new Subject<void>()
-    private displayMetricsChanged = new Subject<void>()
-    private displaysChanged = new Subject<void>()
-    private logger: Logger
-
-    /**
-     * Fired when Preferences is selected in the macOS menu
-     */
-    get preferencesMenu$ (): Observable<void> { return this.preferencesMenu }
-
-    /**
-     * Fired when another window modified the config file
-     */
-    get configChangeBroadcast$ (): Observable<void> { return this.configChangeBroadcast }
-
-    /**
-     * Fired when the window close button is pressed
-     */
-    get windowCloseRequest$ (): Observable<void> { return this.windowCloseRequest }
-
-    get windowMoved$ (): Observable<void> { return this.windowMoved }
-
-    get windowFocused$ (): Observable<void> { return this.windowFocused }
-
-    get displayMetricsChanged$ (): Observable<void> { return this.displayMetricsChanged }
-
-    get displaysChanged$ (): Observable<void> { return this.displaysChanged }
-
-    private constructor (
-        private zone: NgZone,
-        private electron: ElectronService,
-        @Inject(BOOTSTRAP_DATA) private bootstrapData: BootstrapData,
-        injector: Injector,
-        log: LogService,
-    ) {
-        this.logger = log.create('hostApp')
-        this.configPlatform = this.platform = {
-            win32: Platform.Windows,
-            darwin: Platform.macOS,
-            linux: Platform.Linux,
-        }[process.platform]
-
-        if (process.env.XWEB) {
-            this.platform = Platform.Web
-        }
-
-        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-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 () => {
-            const event = { argv, cwd, secondInstance }
-            this.logger.info('CLI arguments received:', event)
-
-            const cliHandlers = injector.get(CLIHandler) as unknown as CLIHandler[]
-            cliHandlers.sort((a, b) => b.priority - a.priority)
-
-            let handled = false
-            for (const handler of cliHandlers) {
-                if (handled && handler.firstMatchOnly) {
-                    continue
-                }
-                if (await handler.handle(event)) {
-                    this.logger.info('CLI handler matched:', handler.constructor.name)
-                    handled = true
-                }
-            }
-        }))
-
-        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.bootstrapData.windowID)!
-    }
-
-    newWindow (): void {
-        this.electron.ipcRenderer.send('app:new-window')
-    }
-
-    openDevTools (): void {
-        this.getWindow().webContents.openDevTools({ mode: 'undocked' })
-    }
-
-    focusWindow (): void {
-        this.electron.ipcRenderer.send('window-focus')
-    }
-
-    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)
-    }
-
-    setTouchBar (touchBar: TouchBar): void {
-        this.getWindow().setTouchBar(touchBar)
-    }
-
-    /**
-     * Notifies other windows of config file changes
-     */
-    broadcastConfigChange (configStore: Record<string, any>): 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')
-    }
-
-    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()
-    }
-}

+ 0 - 30
terminus-core/src/services/hotkeys.service.ts

@@ -3,7 +3,6 @@ import { Observable, Subject } from 'rxjs'
 import { HotkeyDescription, HotkeyProvider } from '../api/hotkeyProvider'
 import { stringifyKeySequence, EventData } from './hotkeys.util'
 import { ConfigService } from './config.service'
-import { HostAppService } from './hostApp.service'
 
 export interface PartialHotkeyMatch {
     id: string
@@ -33,7 +32,6 @@ export class HotkeysService {
 
     private constructor (
         private zone: NgZone,
-        private hostApp: HostAppService,
         private config: ConfigService,
         @Inject(HotkeyProvider) private hotkeyProviders: HotkeyProvider[],
     ) {
@@ -47,11 +45,7 @@ export class HotkeysService {
                 }
             })
         })
-        this.config.changed$.subscribe(() => {
-            this.registerGlobalHotkey()
-        })
         this.config.ready$.toPromise().then(() => {
-            this.registerGlobalHotkey()
             this.getHotkeyDescriptions().then(hotkeys => {
                 this.hotkeyDescriptions = hotkeys
             })
@@ -182,30 +176,6 @@ export class HotkeysService {
         ).reduce((a, b) => a.concat(b))
     }
 
-    private registerGlobalHotkey () {
-        let value = this.config.store.hotkeys['toggle-window'] || []
-        if (typeof value === 'string') {
-            value = [value]
-        }
-        const specs: string[] = []
-        value.forEach((item: string | string[]) => {
-            item = typeof item === 'string' ? [item] : item
-
-            try {
-                let electronKeySpec = item[0]
-                electronKeySpec = electronKeySpec.replace('Meta', 'Super')
-                electronKeySpec = electronKeySpec.replace('⌘', 'Command')
-                electronKeySpec = electronKeySpec.replace('⌥', 'Alt')
-                electronKeySpec = electronKeySpec.replace(/-/g, '+')
-                specs.push(electronKeySpec)
-            } catch (err) {
-                console.error('Could not register the global hotkey:', err)
-            }
-        })
-
-        this.hostApp.registerGlobalHotkey(specs)
-    }
-
     private getHotkeysConfig () {
         return this.getHotkeysConfigRecursive(this.config.store.hotkeys)
     }

+ 26 - 0
terminus-electron/src/config.ts

@@ -0,0 +1,26 @@
+import { ConfigProvider, Platform } from 'terminus-core'
+
+/** @hidden */
+export class ElectronConfigProvider extends ConfigProvider {
+    platformDefaults = {
+        [Platform.macOS]: {
+            hotkeys: {
+                'toggle-window': ['Ctrl-Space'],
+                'new-window': ['⌘-N'],
+            },
+        },
+        [Platform.Windows]: {
+            hotkeys: {
+                'toggle-window': ['Ctrl-Space'],
+                'new-window': ['Ctrl-Shift-N'],
+            },
+        },
+        [Platform.Linux]: {
+            hotkeys: {
+                'toggle-window': ['Ctrl-Space'],
+                'new-window': ['Ctrl-Shift-N'],
+            },
+        },
+    }
+    defaults = {}
+}

+ 21 - 0
terminus-electron/src/hotkeys.ts

@@ -0,0 +1,21 @@
+import { Injectable } from '@angular/core'
+import { HotkeyDescription, HotkeyProvider } from 'terminus-core'
+
+/** @hidden */
+@Injectable()
+export class ElectronHotkeyProvider extends HotkeyProvider {
+    hotkeys: HotkeyDescription[] = [
+        {
+            id: 'new-window',
+            name: 'New window',
+        },
+        {
+            id: 'toggle-window',
+            name: 'Toggle terminal window',
+        },
+    ]
+
+    async provide (): Promise<HotkeyDescription[]> {
+        return this.hotkeys
+    }
+}

+ 46 - 8
terminus-electron/src/index.ts

@@ -1,5 +1,5 @@
 import { NgModule } from '@angular/core'
-import { PlatformService, LogService, UpdaterService, DockingService, HostAppService, ThemesService, Platform, AppService, ConfigService, ElectronService, WIN_BUILD_FLUENT_BG_SUPPORTED, isWindowsBuild, HostWindowService } from 'terminus-core'
+import { PlatformService, LogService, UpdaterService, DockingService, HostAppService, ThemesService, Platform, AppService, ConfigService, ElectronService, WIN_BUILD_FLUENT_BG_SUPPORTED, isWindowsBuild, HostWindowService, HotkeyProvider, ConfigProvider } from 'terminus-core'
 import { TerminalColorSchemeProvider } from 'terminus-terminal'
 
 import { HyperColorSchemes } from './colorSchemes'
@@ -9,39 +9,51 @@ import { ElectronUpdaterService } from './services/updater.service'
 import { TouchbarService } from './services/touchbar.service'
 import { ElectronDockingService } from './services/docking.service'
 import { ElectronHostWindow } from './services/hostWindow.service'
+import { ElectronHostAppService } from './services/hostApp.service'
+import { ElectronHotkeyProvider } from './hotkeys'
+import { ElectronConfigProvider } from './config'
 
 @NgModule({
     providers: [
         { provide: TerminalColorSchemeProvider, useClass: HyperColorSchemes, multi: true },
         { provide: PlatformService, useClass: ElectronPlatformService },
         { provide: HostWindowService, useClass: ElectronHostWindow },
+        { provide: HostAppService, useClass: ElectronHostAppService },
         { provide: LogService, useClass: ElectronLogService },
         { provide: UpdaterService, useClass: ElectronUpdaterService },
         { provide: DockingService, useClass: ElectronDockingService },
+        { provide: HotkeyProvider, useClass: ElectronHotkeyProvider, multi: true },
+        { provide: ConfigProvider, useClass: ElectronConfigProvider, multi: true },
     ],
 })
 export default class ElectronModule {
     constructor (
         private config: ConfigService,
-        private hostApp: HostAppService,
+        private hostApp: ElectronHostAppService,
         private electron: ElectronService,
+        private hostWindow: ElectronHostWindow,
         touchbar: TouchbarService,
         docking: DockingService,
         themeService: ThemesService,
-        app: AppService
+        app: AppService,
     ) {
         config.ready$.toPromise().then(() => {
             touchbar.update()
             docking.dock()
-            hostApp.shown.subscribe(() => {
+            hostWindow.windowShown$.subscribe(() => {
                 docking.dock()
             })
+            this.registerGlobalHotkey()
             this.updateVibrancy()
         })
 
+        config.changed$.subscribe(() => {
+            this.registerGlobalHotkey()
+        })
+
         themeService.themeChanged$.subscribe(theme => {
             if (hostApp.platform === Platform.macOS) {
-                hostApp.getWindow().setTrafficLightPosition({
+                hostWindow.getWindow().setTrafficLightPosition({
                     x: theme.macOSWindowButtonsInsetX ?? 14,
                     y: theme.macOSWindowButtonsInsetY ?? 11,
                 })
@@ -55,9 +67,9 @@ export default class ElectronModule {
                     return
                 }
                 if (progress !== null) {
-                    hostApp.getWindow().setProgressBar(progress / 100.0, { mode: 'normal' })
+                    hostWindow.getWindow().setProgressBar(progress / 100.0, { mode: 'normal' })
                 } else {
-                    hostApp.getWindow().setProgressBar(-1, { mode: 'none' })
+                    hostWindow.getWindow().setProgressBar(-1, { mode: 'none' })
                 }
                 lastProgress = progress
             })
@@ -66,6 +78,30 @@ export default class ElectronModule {
         config.changed$.subscribe(() => this.updateVibrancy())
     }
 
+    private registerGlobalHotkey () {
+        let value = this.config.store.hotkeys['toggle-window'] || []
+        if (typeof value === 'string') {
+            value = [value]
+        }
+        const specs: string[] = []
+        value.forEach((item: string | string[]) => {
+            item = typeof item === 'string' ? [item] : item
+
+            try {
+                let electronKeySpec = item[0]
+                electronKeySpec = electronKeySpec.replace('Meta', 'Super')
+                electronKeySpec = electronKeySpec.replace('⌘', 'Command')
+                electronKeySpec = electronKeySpec.replace('⌥', 'Alt')
+                electronKeySpec = electronKeySpec.replace(/-/g, '+')
+                specs.push(electronKeySpec)
+            } catch (err) {
+                console.error('Could not register the global hotkey:', err)
+            }
+        })
+
+        this.electron.ipcRenderer.send('app:register-global-hotkey', specs)
+    }
+
     private updateVibrancy () {
         let vibrancyType = this.config.store.appearance.vibrancyType
         if (this.hostApp.platform === Platform.Windows && !isWindowsBuild(WIN_BUILD_FLUENT_BG_SUPPORTED)) {
@@ -74,6 +110,8 @@ export default class ElectronModule {
         document.body.classList.toggle('vibrant', this.config.store.appearance.vibrancy)
         this.electron.ipcRenderer.send('window-set-vibrancy', this.config.store.appearance.vibrancy, vibrancyType)
 
-        this.hostApp.getWindow().setOpacity(this.config.store.appearance.opacity)
+        this.hostWindow.getWindow().setOpacity(this.config.store.appearance.opacity)
     }
 }
+
+export { ElectronHostWindow, ElectronHostAppService }

+ 18 - 11
terminus-electron/src/services/docking.service.ts

@@ -1,24 +1,31 @@
-import { Injectable } from '@angular/core'
+import { Injectable, NgZone } from '@angular/core'
 import type { Display } from 'electron'
-import { ConfigService, ElectronService, HostAppService, Bounds, DockingService, Screen } from 'terminus-core'
+import { ConfigService, ElectronService, DockingService, Screen, PlatformService } from 'terminus-core'
+import { ElectronHostWindow, Bounds } from './hostWindow.service'
 
 @Injectable()
 export class ElectronDockingService extends DockingService {
     constructor (
         private electron: ElectronService,
         private config: ConfigService,
-        private hostApp: HostAppService,
+        private zone: NgZone,
+        private hostWindow: ElectronHostWindow,
+        platform: PlatformService,
     ) {
         super()
-        hostApp.displaysChanged$.subscribe(() => this.repositionWindow())
-        hostApp.displayMetricsChanged$.subscribe(() => this.repositionWindow())
+        this.screensChanged$.subscribe(() => this.repositionWindow())
+        platform.displayMetricsChanged$.subscribe(() => this.repositionWindow())
+
+        electron.ipcRenderer.on('host:displays-changed', () => {
+            this.zone.run(() => this.screensChanged.next())
+        })
     }
 
     dock (): void {
         const dockSide = this.config.store.appearance.dock
 
         if (dockSide === 'off') {
-            this.hostApp.setAlwaysOnTop(false)
+            this.hostWindow.setAlwaysOnTop(false)
             return
         }
 
@@ -33,7 +40,7 @@ export class ElectronDockingService extends DockingService {
 
         const fill = this.config.store.appearance.dockFill <= 1 ? this.config.store.appearance.dockFill : 1
         const space = this.config.store.appearance.dockSpace <= 1 ? this.config.store.appearance.dockSpace : 1
-        const [minWidth, minHeight] = this.hostApp.getWindow().getMinimumSize()
+        const [minWidth, minHeight] = this.hostWindow.getWindow().getMinimumSize()
 
         if (dockSide === 'left' || dockSide === 'right') {
             newBounds.width = Math.max(minWidth, Math.round(fill * display.bounds.width))
@@ -60,9 +67,9 @@ export class ElectronDockingService extends DockingService {
 
         const alwaysOnTop = this.config.store.appearance.dockAlwaysOnTop
 
-        this.hostApp.setAlwaysOnTop(alwaysOnTop)
+        this.hostWindow.setAlwaysOnTop(alwaysOnTop)
         setImmediate(() => {
-            this.hostApp.setBounds(newBounds)
+            this.hostWindow.setBounds(newBounds)
         })
     }
 
@@ -84,7 +91,7 @@ export class ElectronDockingService extends DockingService {
     }
 
     private repositionWindow () {
-        const [x, y] = this.hostApp.getWindow().getPosition()
+        const [x, y] = this.hostWindow.getWindow().getPosition()
         for (const screen of this.electron.screen.getAllDisplays()) {
             const bounds = screen.bounds
             if (x >= bounds.x && x <= bounds.x + bounds.width && y >= bounds.y && y <= bounds.y + bounds.height) {
@@ -92,6 +99,6 @@ export class ElectronDockingService extends DockingService {
             }
         }
         const screen = this.electron.screen.getPrimaryDisplay()
-        this.hostApp.getWindow().setPosition(screen.bounds.x, screen.bounds.y)
+        this.hostWindow.getWindow().setPosition(screen.bounds.x, screen.bounds.y)
     }
 }

+ 85 - 0
terminus-electron/src/services/hostApp.service.ts

@@ -0,0 +1,85 @@
+import { Injectable, NgZone, Injector } from '@angular/core'
+import { ElectronService, isWindowsBuild, WIN_BUILD_FLUENT_BG_SUPPORTED, HostAppService, Platform, CLIHandler } from 'terminus-core'
+
+
+@Injectable({ providedIn: 'root' })
+export class ElectronHostAppService extends HostAppService {
+    get platform (): Platform {
+        return this.configPlatform
+    }
+
+    get configPlatform (): Platform {
+        return {
+            win32: Platform.Windows,
+            darwin: Platform.macOS,
+            linux: Platform.Linux,
+        }[process.platform]
+    }
+
+    constructor (
+        private zone: NgZone,
+        private electron: ElectronService,
+        injector: Injector,
+    ) {
+        super(injector)
+
+        electron.ipcRenderer.on('host:preferences-menu', () => this.zone.run(() => this.settingsUIRequest.next()))
+
+        electron.ipcRenderer.on('cli', (_$event, argv: any, cwd: string, secondInstance: boolean) => this.zone.run(async () => {
+            const event = { argv, cwd, secondInstance }
+            this.logger.info('CLI arguments received:', event)
+
+            const cliHandlers = injector.get(CLIHandler) as unknown as CLIHandler[]
+            cliHandlers.sort((a, b) => b.priority - a.priority)
+
+            let handled = false
+            for (const handler of cliHandlers) {
+                if (handled && handler.firstMatchOnly) {
+                    continue
+                }
+                if (await handler.handle(event)) {
+                    this.logger.info('CLI handler matched:', handler.constructor.name)
+                    handled = true
+                }
+            }
+        }))
+
+        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)
+        }
+    }
+
+    newWindow (): void {
+        this.electron.ipcRenderer.send('app:new-window')
+    }
+
+    /**
+     * Notifies other windows of config file changes
+     */
+    broadcastConfigChange (configStore: Record<string, any>): void {
+        this.electron.ipcRenderer.send('app:config-change', configStore)
+    }
+
+    emitReady (): void {
+        this.electron.ipcRenderer.send('app:ready')
+    }
+
+    relaunch (): void {
+        const isPortable = !!process.env.PORTABLE_EXECUTABLE_FILE
+        if (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()
+    }
+}

+ 54 - 9
terminus-electron/src/services/hostWindow.service.ts

@@ -1,19 +1,24 @@
-import { Injectable, NgZone } from '@angular/core'
-import { Observable, Subject } from 'rxjs'
-import { ElectronService, HostAppService, HostWindowService } from 'terminus-core'
+import type { BrowserWindow, TouchBar } from 'electron'
+import { Injectable, Inject, NgZone } from '@angular/core'
+import { BootstrapData, BOOTSTRAP_DATA, ElectronService, HostWindowService } from 'terminus-core'
+
+export interface Bounds {
+    x: number
+    y: number
+    width: number
+    height: number
+}
 
 @Injectable({ providedIn: 'root' })
 export class ElectronHostWindow extends HostWindowService {
-    get closeRequest$ (): Observable<void> { return this.closeRequest }
     get isFullscreen (): boolean { return this._isFullScreen}
 
-    private closeRequest = new Subject<void>()
     private _isFullScreen = false
 
     constructor (
-        private electron: ElectronService,
-        private hostApp: HostAppService,
         zone: NgZone,
+        private electron: ElectronService,
+        @Inject(BOOTSTRAP_DATA) private bootstrapData: BootstrapData,
     ) {
         super()
         electron.ipcRenderer.on('host:window-enter-full-screen', () => zone.run(() => {
@@ -23,10 +28,34 @@ export class ElectronHostWindow extends HostWindowService {
         electron.ipcRenderer.on('host:window-leave-full-screen', () => zone.run(() => {
             this._isFullScreen = false
         }))
+
+        electron.ipcRenderer.on('host:window-shown', () => {
+            zone.run(() => this.windowShown.next())
+        })
+
+        electron.ipcRenderer.on('host:window-close-request', () => {
+            zone.run(() => this.windowCloseRequest.next())
+        })
+
+        electron.ipcRenderer.on('host:window-moved', () => {
+            zone.run(() => this.windowMoved.next())
+        })
+
+        electron.ipcRenderer.on('host:window-focused', () => {
+            zone.run(() => this.windowFocused.next())
+        })
+    }
+
+    getWindow (): BrowserWindow {
+        return this.electron.BrowserWindow.fromId(this.bootstrapData.windowID)!
+    }
+
+    openDevTools (): void {
+        this.getWindow().webContents.openDevTools({ mode: 'undocked' })
     }
 
     reload (): void {
-        this.hostApp.getWindow().reload()
+        this.getWindow().reload()
     }
 
     setTitle (title?: string): void {
@@ -34,7 +63,7 @@ export class ElectronHostWindow extends HostWindowService {
     }
 
     toggleFullscreen (): void {
-        this.hostApp.getWindow().setFullScreen(!this._isFullScreen)
+        this.getWindow().setFullScreen(!this._isFullScreen)
     }
 
     minimize (): void {
@@ -48,4 +77,20 @@ export class ElectronHostWindow extends HostWindowService {
     close (): void {
         this.electron.ipcRenderer.send('window-close')
     }
+
+    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)
+    }
+
+    setTouchBar (touchBar: TouchBar): void {
+        this.getWindow().setTouchBar(touchBar)
+    }
+
+    bringToFront (): void {
+        this.electron.ipcRenderer.send('window-bring-to-front')
+    }
 }

+ 17 - 6
terminus-electron/src/services/platform.service.ts

@@ -6,6 +6,7 @@ import promiseIpc from 'electron-promise-ipc'
 import { execFile } from 'mz/child_process'
 import { Injectable, NgZone } from '@angular/core'
 import { PlatformService, ClipboardContent, HostAppService, Platform, ElectronService, MenuItemOptions, MessageBoxOptions, MessageBoxResult, FileUpload, FileDownload, FileUploadOptions, wrapPromise } from 'terminus-core'
+import { ElectronHostWindow } from './hostWindow.service'
 const fontManager = require('fontmanager-redux') // eslint-disable-line
 
 /* eslint-disable block-scoped-var */
@@ -20,16 +21,20 @@ try {
 @Injectable()
 export class ElectronPlatformService extends PlatformService {
     supportsWindowControls = true
-    private userPluginsPath: string = (window as any).userPluginsPath
     private configPath: string
 
     constructor (
         private hostApp: HostAppService,
+        private hostWindow: ElectronHostWindow,
         private electron: ElectronService,
         private zone: NgZone,
     ) {
         super()
         this.configPath = path.join(electron.app.getPath('userData'), 'config.yaml')
+
+        electron.ipcRenderer.on('host:display-metrics-changed', () => {
+            this.zone.run(() => this.displayMetricsChanged.next())
+        })
     }
 
     readClipboard (): string {
@@ -41,11 +46,11 @@ export class ElectronPlatformService extends PlatformService {
     }
 
     async installPlugin (name: string, version: string): Promise<void> {
-        await (promiseIpc as any).send('plugin-manager:install', this.userPluginsPath, name, version)
+        await (promiseIpc as any).send('plugin-manager:install', name, version)
     }
 
     async uninstallPlugin (name: string): Promise<void> {
-        await (promiseIpc as any).send('plugin-manager:uninstall', this.userPluginsPath, name)
+        await (promiseIpc as any).send('plugin-manager:uninstall', name)
     }
 
     async isProcessRunning (name: string): Promise<boolean> {
@@ -163,7 +168,7 @@ export class ElectronPlatformService extends PlatformService {
     }
 
     async showMessageBox (options: MessageBoxOptions): Promise<MessageBoxResult> {
-        return this.electron.dialog.showMessageBox(this.hostApp.getWindow(), options)
+        return this.electron.dialog.showMessageBox(this.hostWindow.getWindow(), options)
     }
 
     quit (): void {
@@ -179,7 +184,7 @@ export class ElectronPlatformService extends PlatformService {
         }
 
         const result = await this.electron.dialog.showOpenDialog(
-            this.hostApp.getWindow(),
+            this.hostWindow.getWindow(),
             {
                 buttonLabel: 'Select',
                 properties,
@@ -199,7 +204,7 @@ export class ElectronPlatformService extends PlatformService {
 
     async startDownload (name: string, size: number): Promise<FileDownload|null> {
         const result = await this.electron.dialog.showSaveDialog(
-            this.hostApp.getWindow(),
+            this.hostWindow.getWindow(),
             {
                 defaultPath: name,
             },
@@ -212,6 +217,12 @@ export class ElectronPlatformService extends PlatformService {
         this.fileTransferStarted.next(transfer)
         return transfer
     }
+
+    setErrorHandler (handler: (_: any) => void): void {
+        this.electron.ipcRenderer.on('uncaughtException', (_$event, err) => {
+            handler(err)
+        })
+    }
 }
 
 class ElectronFileUpload extends FileUpload {

+ 3 - 1
terminus-electron/src/services/touchbar.service.ts

@@ -1,6 +1,7 @@
 import { SegmentedControlSegment, TouchBarSegmentedControl } from 'electron'
 import { Injectable, NgZone } from '@angular/core'
 import { AppService, HostAppService, Platform, ElectronService } from 'terminus-core'
+import { ElectronHostWindow } from './hostWindow.service'
 
 /** @hidden */
 @Injectable({ providedIn: 'root' })
@@ -11,6 +12,7 @@ export class TouchbarService {
     private constructor (
         private app: AppService,
         private hostApp: HostAppService,
+        private hostWindow: ElectronHostWindow,
         private electron: ElectronService,
         private zone: NgZone,
     ) {
@@ -68,7 +70,7 @@ export class TouchbarService {
                 this.tabsSegmentedControl,
             ],
         })
-        this.hostApp.setTouchBar(touchBar)
+        this.hostWindow.setTouchBar(touchBar)
     }
 
     private shortenTitle (title: string): string {

+ 7 - 7
terminus-local/src/cli.ts

@@ -1,7 +1,7 @@
 import * as path from 'path'
 import * as fs from 'mz/fs'
 import { Injectable } from '@angular/core'
-import { CLIHandler, CLIEvent, HostAppService, AppService, ConfigService } from 'terminus-core'
+import { CLIHandler, CLIEvent, AppService, ConfigService, HostWindowService } from 'terminus-core'
 import { TerminalService } from './services/terminal.service'
 
 @Injectable()
@@ -11,7 +11,7 @@ export class TerminalCLIHandler extends CLIHandler {
 
     constructor (
         private config: ConfigService,
-        private hostApp: HostAppService,
+        private hostWindow: HostWindowService,
         private terminal: TerminalService,
     ) {
         super()
@@ -40,7 +40,7 @@ export class TerminalCLIHandler extends CLIHandler {
         if (await fs.exists(directory)) {
             if ((await fs.stat(directory)).isDirectory()) {
                 this.terminal.openTab(undefined, directory)
-                this.hostApp.bringToFront()
+                this.hostWindow.bringToFront()
             }
         }
     }
@@ -53,7 +53,7 @@ export class TerminalCLIHandler extends CLIHandler {
                 args: command.slice(1),
             },
         }, null, true)
-        this.hostApp.bringToFront()
+        this.hostWindow.bringToFront()
     }
 
     private handleOpenProfile (profileName: string) {
@@ -63,7 +63,7 @@ export class TerminalCLIHandler extends CLIHandler {
             return
         }
         this.terminal.openTabWithOptions(profile.sessionOptions)
-        this.hostApp.bringToFront()
+        this.hostWindow.bringToFront()
     }
 }
 
@@ -75,7 +75,7 @@ export class OpenPathCLIHandler extends CLIHandler {
 
     constructor (
         private terminal: TerminalService,
-        private hostApp: HostAppService,
+        private hostWindow: HostWindowService,
     ) {
         super()
     }
@@ -86,7 +86,7 @@ export class OpenPathCLIHandler extends CLIHandler {
 
         if (opAsPath && (await fs.lstat(opAsPath)).isDirectory()) {
             this.terminal.openTab(undefined, opAsPath)
-            this.hostApp.bringToFront()
+            this.hostWindow.bringToFront()
             return true
         }
 

+ 3 - 1
terminus-local/src/components/shellSettingsTab.component.ts

@@ -2,6 +2,7 @@ import { Component } from '@angular/core'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { Subscription } from 'rxjs'
 import { ConfigService, ElectronService, HostAppService, Platform, WIN_BUILD_CONPTY_SUPPORTED, WIN_BUILD_CONPTY_STABLE, isWindowsBuild } from 'terminus-core'
+import { ElectronHostWindow } from 'terminus-electron'
 import { EditProfileModalComponent } from './editProfileModal.component'
 import { Shell, Profile } from '../api'
 import { TerminalService } from '../services/terminal.service'
@@ -21,6 +22,7 @@ export class ShellSettingsTabComponent {
     constructor (
         public config: ConfigService,
         public hostApp: HostAppService,
+        public hostWindow: ElectronHostWindow,
         public terminal: TerminalService,
         private electron: ElectronService,
         private ngbModal: NgbModal,
@@ -54,7 +56,7 @@ export class ShellSettingsTabComponent {
             return
         }
         const paths = (await this.electron.dialog.showOpenDialog(
-            this.hostApp.getWindow(),
+            this.hostWindow.getWindow(),
             {
                 defaultPath: shell.fsBase,
                 properties: ['openDirectory', 'showHiddenFiles'],

+ 2 - 2
terminus-plugin-manager/src/components/pluginsSettingsTab.component.ts

@@ -4,8 +4,8 @@ import { debounceTime, distinctUntilChanged, first, tap, flatMap, map } from 'rx
 import semverGt from 'semver/functions/gt'
 
 import { Component, Input } from '@angular/core'
-import { ConfigService, PlatformService } from 'terminus-core'
-import { PluginInfo, PluginManagerService } from '../services/pluginManager.service'
+import { ConfigService, PlatformService, PluginInfo } from 'terminus-core'
+import { PluginManagerService } from '../services/pluginManager.service'
 
 enum BusyState { Installing = 'Installing', Uninstalling = 'Uninstalling' }
 

+ 7 - 17
terminus-plugin-manager/src/services/pluginManager.service.ts

@@ -1,8 +1,8 @@
 import axios from 'axios'
 import { Observable, from } from 'rxjs'
 import { map } from 'rxjs/operators'
-import { Injectable } from '@angular/core'
-import { Logger, LogService, PlatformService } from 'terminus-core'
+import { Injectable, Inject } from '@angular/core'
+import { Logger, LogService, PlatformService, BOOTSTRAP_DATA, BootstrapData, PluginInfo } from 'terminus-core'
 
 const NAME_PREFIX = 'terminus-'
 const KEYWORD = 'terminus-plugin'
@@ -12,30 +12,20 @@ const BLACKLIST = [
     'terminus-shell-selector', // superseded by profiles
 ]
 
-export interface PluginInfo {
-    name: string
-    description: string
-    packageName: string
-    isBuiltin: boolean
-    isOfficial: boolean
-    version: string
-    homepage?: string
-    author: string
-    path?: string
-}
-
 @Injectable({ providedIn: 'root' })
 export class PluginManagerService {
     logger: Logger
-    builtinPluginsPath: string = (window as any).builtinPluginsPath
-    userPluginsPath: string = (window as any).userPluginsPath
-    installedPlugins: PluginInfo[] = (window as any).installedPlugins
+    userPluginsPath: string
+    installedPlugins: PluginInfo[]
 
     private constructor (
         log: LogService,
         private platform: PlatformService,
+        @Inject(BOOTSTRAP_DATA) bootstrapData: BootstrapData,
     ) {
         this.logger = log.create('pluginManager')
+        this.installedPlugins = bootstrapData.installedPlugins
+        this.userPluginsPath = bootstrapData.userPluginsPath
     }
 
     listAvailable (query?: string): Observable<PluginInfo[]> {

+ 1 - 1
terminus-settings/src/buttonProvider.ts

@@ -12,7 +12,7 @@ export class ButtonProvider extends ToolbarButtonProvider {
         private app: AppService,
     ) {
         super()
-        hostApp.preferencesMenu$.subscribe(() => this.open())
+        hostApp.settingsUIRequest$.subscribe(() => this.open())
 
         hotkeys.matchedHotkey.subscribe(async (hotkey) => {
             if (hotkey === 'settings') {

+ 5 - 5
terminus-settings/src/components/settingsTab.component.pug

@@ -24,7 +24,7 @@ button.btn.btn-outline-warning.btn-block(*ngIf='config.restartRequested', '(clic
                             span Report a problem
 
                         button.btn.btn-secondary(
-                            *ngIf='!updateAvailable',
+                            *ngIf='!updateAvailable && hostApp.platform !== Platform.Web',
                             (click)='checkForUpdates()',
                             [disabled]='checkingForUpdate'
                         )
@@ -46,7 +46,7 @@ button.btn.btn-outline-warning.btn-block(*ngIf='config.restartRequested', '(clic
                         .description Allows quickly opening a terminal in the selected folder
                     toggle([ngModel]='isShellIntegrationInstalled', (ngModelChange)='toggleShellIntegration()')
 
-                .form-line
+                .form-line(*ngIf='hostApp.platform !== Platform.Web')
                     .header
                         .title Enable analytics
                         .description We're only tracking your Terminus and OS versions.
@@ -55,17 +55,17 @@ button.btn.btn-outline-warning.btn-block(*ngIf='config.restartRequested', '(clic
                         (ngModelChange)='saveConfiguration(true)',
                     )
 
-                .form-line
+                .form-line(*ngIf='hostApp.platform !== Platform.Web')
                     .header
                         .title Automatic Updates
                         .description Enable automatic installation of updates when they become available.
                     toggle([(ngModel)]='config.store.enableAutomaticUpdates', (ngModelChange)='saveConfiguration()')
 
-                .form-line
+                .form-line(*ngIf='hostApp.platform !== Platform.Web')
                     .header
                         .title Debugging
 
-                    button.btn.btn-secondary((click)='hostApp.openDevTools()')
+                    button.btn.btn-secondary((click)='hostWindow.openDevTools()')
                         i.fas.fa-bug
                         span Open DevTools
 

+ 2 - 0
terminus-settings/src/components/settingsTab.component.ts

@@ -10,6 +10,7 @@ import {
     HomeBaseService,
     UpdaterService,
     PlatformService,
+    HostWindowService,
 } from 'terminus-core'
 
 import { SettingsTabProvider } from '../api'
@@ -36,6 +37,7 @@ export class SettingsTabComponent extends BaseTabComponent {
     constructor (
         public config: ConfigService,
         public hostApp: HostAppService,
+        public hostWindow: HostWindowService,
         public homeBase: HomeBaseService,
         public platform: PlatformService,
         public zone: NgZone,

+ 1 - 1
terminus-settings/src/components/windowSettingsTab.component.ts

@@ -39,7 +39,7 @@ export class WindowSettingsTabComponent extends BaseComponent {
 
         const dockingService = docking
         if (dockingService) {
-            this.subscribeUntilDestroyed(hostApp.displaysChanged$, () => {
+            this.subscribeUntilDestroyed(dockingService.screensChanged$, () => {
                 this.zone.run(() => this.screens = dockingService.getScreens())
             })
             this.screens = dockingService.getScreens()

+ 17 - 10
terminus-ssh/src/buttonProvider.ts

@@ -1,6 +1,6 @@
 /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
 import { Injectable } from '@angular/core'
-import { HotkeysService, ToolbarButtonProvider, ToolbarButton } from 'terminus-core'
+import { HotkeysService, ToolbarButtonProvider, ToolbarButton, HostAppService, Platform } from 'terminus-core'
 import { SSHService } from './services/ssh.service'
 
 /** @hidden */
@@ -8,6 +8,7 @@ import { SSHService } from './services/ssh.service'
 export class ButtonProvider extends ToolbarButtonProvider {
     constructor (
         hotkeys: HotkeysService,
+        private hostApp: HostAppService,
         private ssh: SSHService,
     ) {
         super()
@@ -23,14 +24,20 @@ export class ButtonProvider extends ToolbarButtonProvider {
     }
 
     provide (): ToolbarButton[] {
-        return [{
-            icon: require('./icons/globe.svg'),
-            weight: 5,
-            title: 'SSH connections',
-            touchBarNSImage: 'NSTouchBarOpenInBrowserTemplate',
-            click: () => {
-                this.activate()
-            },
-        }]
+        if (this.hostApp.platform === Platform.Web) {
+            return [{
+                icon: require('../../terminus-local/src/icons/plus.svg'),
+                title: 'SSH connections',
+                click: () => this.activate(),
+            }]
+        } else {
+            return [{
+                icon: require('./icons/globe.svg'),
+                weight: 5,
+                title: 'SSH connections',
+                touchBarNSImage: 'NSTouchBarOpenInBrowserTemplate',
+                click: () => this.activate(),
+            }]
+        }
     }
 }

+ 1 - 3
terminus-ssh/src/components/editConnectionModal.component.ts

@@ -4,7 +4,7 @@ import { NgbModal, NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
 import { Observable } from 'rxjs'
 import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators'
 
-import { ElectronService, HostAppService, ConfigService, PlatformService } from 'terminus-core'
+import { ElectronService, ConfigService, PlatformService } from 'terminus-core'
 import { PasswordStorageService } from '../services/passwordStorage.service'
 import { SSHConnection, LoginScript, ForwardedPortConfig, SSHAlgorithmType, ALGORITHM_BLACKLIST } from '../api'
 import { PromptModalComponent } from './promptModal.component'
@@ -30,7 +30,6 @@ export class EditConnectionModalComponent {
         private modalInstance: NgbActiveModal,
         private electron: ElectronService,
         private platform: PlatformService,
-        private hostApp: HostAppService,
         private passwordStorage: PasswordStorageService,
         private ngbModal: NgbModal,
     ) {
@@ -104,7 +103,6 @@ export class EditConnectionModalComponent {
 
     addPrivateKey () {
         this.electron.dialog.showOpenDialog(
-            this.hostApp.getWindow(),
             {
                 defaultPath: this.connection.privateKeys![0],
                 title: 'Select private key',

+ 5 - 3
terminus-terminal/src/api/baseTerminalTab.component.ts

@@ -3,7 +3,7 @@ import { first } from 'rxjs/operators'
 import colors from 'ansi-colors'
 import { NgZone, OnInit, OnDestroy, Injector, ViewChild, HostBinding, Input, ElementRef, InjectFlags } from '@angular/core'
 import { trigger, transition, style, animate, AnimationTriggerMetadata } from '@angular/animations'
-import { AppService, ConfigService, BaseTabComponent, HostAppService, HotkeysService, NotificationsService, Platform, LogService, Logger, TabContextMenuItemProvider, SplitTabComponent, SubscriptionContainer, MenuItemOptions, PlatformService } from 'terminus-core'
+import { AppService, ConfigService, BaseTabComponent, HostAppService, HotkeysService, NotificationsService, Platform, LogService, Logger, TabContextMenuItemProvider, SplitTabComponent, SubscriptionContainer, MenuItemOptions, PlatformService, HostWindowService } from 'terminus-core'
 
 import { BaseSession } from '../session'
 import { TerminalFrontendService } from '../services/terminalFrontend.service'
@@ -108,6 +108,7 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit
     protected log: LogService
     protected decorators: TerminalDecorator[] = []
     protected contextMenuProviders: TabContextMenuItemProvider[]
+    protected hostWindow: HostWindowService
     // Deps end
 
     protected logger: Logger
@@ -160,6 +161,7 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit
         this.log = injector.get(LogService)
         this.decorators = injector.get<any>(TerminalDecorator, null, InjectFlags.Optional) as TerminalDecorator[]
         this.contextMenuProviders = injector.get<any>(TabContextMenuItemProvider, null, InjectFlags.Optional) as TabContextMenuItemProvider[]
+        this.hostWindow = injector.get(HostWindowService)
 
         this.logger = this.log.create('baseTerminalTab')
         this.setTitle('Terminal')
@@ -596,8 +598,8 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit
             })
         })
 
-        this.termContainerSubscriptions.subscribe(this.hostApp.displayMetricsChanged$, maybeConfigure)
-        this.termContainerSubscriptions.subscribe(this.hostApp.windowMoved$, maybeConfigure)
+        this.termContainerSubscriptions.subscribe(this.platform.displayMetricsChanged$, maybeConfigure)
+        this.termContainerSubscriptions.subscribe(this.hostWindow.windowMoved$, maybeConfigure)
     }
 
     setSession (session: BaseSession|null, destroyOnSessionClose = false): void {

+ 3 - 4
terminus-terminal/src/cli.ts

@@ -1,6 +1,6 @@
 import shellEscape from 'shell-escape'
 import { Injectable } from '@angular/core'
-import { CLIHandler, CLIEvent, HostAppService, AppService } from 'terminus-core'
+import { CLIHandler, CLIEvent, AppService, HostWindowService } from 'terminus-core'
 import { BaseTerminalTabComponent } from './api/baseTerminalTab.component'
 
 @Injectable()
@@ -10,7 +10,7 @@ export class TerminalCLIHandler extends CLIHandler {
 
     constructor (
         private app: AppService,
-        private hostApp: HostAppService,
+        private hostWindow: HostWindowService,
     ) {
         super()
     }
@@ -30,11 +30,10 @@ export class TerminalCLIHandler extends CLIHandler {
         return false
     }
 
-
     private handlePaste (text: string) {
         if (this.app.activeTab instanceof BaseTerminalTabComponent && this.app.activeTab.session) {
             this.app.activeTab.sendInput(text)
-            this.hostApp.bringToFront()
+            this.hostWindow.bringToFront()
         }
     }
 }

+ 3 - 1
terminus-web/src/index.ts

@@ -1,11 +1,12 @@
 import { NgModule } from '@angular/core'
 import { CommonModule } from '@angular/common'
-import { HostWindowService, LogService, PlatformService, UpdaterService } from 'terminus-core'
+import { HostAppService, HostWindowService, LogService, PlatformService, UpdaterService } from 'terminus-core'
 
 import { WebPlatformService } from './platform'
 import { ConsoleLogService } from './services/log.service'
 import { NullUpdaterService } from './services/updater.service'
 import { WebHostWindow } from './services/hostWindow.service'
+import { WebHostApp } from './services/hostApp.service'
 import { MessageBoxModalComponent } from './components/messageBoxModal.component'
 
 import './styles.scss'
@@ -19,6 +20,7 @@ import './styles.scss'
         { provide: LogService, useClass: ConsoleLogService },
         { provide: UpdaterService, useClass: NullUpdaterService },
         { provide: HostWindowService, useClass: WebHostWindow },
+        { provide: HostAppService, useClass: WebHostApp },
     ],
     declarations: [
         MessageBoxModalComponent,

+ 5 - 1
terminus-web/src/platform.ts

@@ -61,7 +61,7 @@ export class WebPlatformService extends PlatformService {
     }
 
     getAppVersion (): string {
-        return '1.0-web'
+        return this.connector.getAppVersion()
     }
 
     async listFonts (): Promise<string[]> {
@@ -136,6 +136,10 @@ export class WebPlatformService extends PlatformService {
             this.fileSelector.click()
         })
     }
+
+    setErrorHandler (handler: (_: any) => void): void {
+        window.addEventListener('error', handler)
+    }
 }
 
 class HTMLFileDownload extends FileDownload {

+ 33 - 0
terminus-web/src/services/hostApp.service.ts

@@ -0,0 +1,33 @@
+import { Injectable, Injector } from '@angular/core'
+import { HostAppService, Platform } from 'terminus-core'
+
+@Injectable()
+export class WebHostApp extends HostAppService {
+    get platform (): Platform {
+        return Platform.Web
+    }
+
+    get configPlatform (): Platform {
+        return Platform.Windows // TODO
+    }
+
+    // Needed for injector metadata
+    // eslint-disable-next-line @typescript-eslint/no-useless-constructor
+    constructor (
+        injector: Injector,
+    ) {
+        super(injector)
+    }
+
+    newWindow (): void {
+        throw new Error('Not implemented')
+    }
+
+    relaunch (): void {
+        location.reload()
+    }
+
+    quit (): void {
+        window.close()
+    }
+}

+ 5 - 3
terminus-web/src/services/hostWindow.service.ts

@@ -1,13 +1,15 @@
 import { Injectable } from '@angular/core'
-import { Observable, Subject } from 'rxjs'
 import { HostWindowService } from 'terminus-core'
 
 @Injectable({ providedIn: 'root' })
 export class WebHostWindow extends HostWindowService {
-    get closeRequest$ (): Observable<void> { return this.closeRequest }
     get isFullscreen (): boolean { return !!document.fullscreenElement }
 
-    private closeRequest = new Subject<void>()
+    constructor () {
+        super()
+        this.windowShown.next()
+        this.windowFocused.next()
+    }
 
     reload (): void {
         location.reload()