mirror of
https://github.com/Eugeny/tabby.git
synced 2025-07-20 02:18:01 +00:00
project rename
This commit is contained in:
105
tabby-electron/src/services/docking.service.ts
Normal file
105
tabby-electron/src/services/docking.service.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Injectable, NgZone } from '@angular/core'
|
||||
import type { Display } from 'electron'
|
||||
import { ConfigService, DockingService, Screen, PlatformService } from 'tabby-core'
|
||||
import { ElectronService } from '../services/electron.service'
|
||||
import { ElectronHostWindow, Bounds } from './hostWindow.service'
|
||||
|
||||
@Injectable()
|
||||
export class ElectronDockingService extends DockingService {
|
||||
constructor (
|
||||
private electron: ElectronService,
|
||||
private config: ConfigService,
|
||||
private zone: NgZone,
|
||||
private hostWindow: ElectronHostWindow,
|
||||
platform: PlatformService,
|
||||
) {
|
||||
super()
|
||||
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.hostWindow.setAlwaysOnTop(false)
|
||||
return
|
||||
}
|
||||
|
||||
let display = this.electron.screen.getAllDisplays()
|
||||
.filter(x => x.id === this.config.store.appearance.dockScreen)[0]
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!display) {
|
||||
display = this.getCurrentScreen()
|
||||
}
|
||||
|
||||
const newBounds: Bounds = { x: 0, y: 0, width: 0, height: 0 }
|
||||
|
||||
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.hostWindow.getWindow().getMinimumSize()
|
||||
|
||||
if (dockSide === 'left' || dockSide === 'right') {
|
||||
newBounds.width = Math.max(minWidth, Math.round(fill * display.bounds.width))
|
||||
newBounds.height = Math.round(display.bounds.height * space)
|
||||
}
|
||||
if (dockSide === 'top' || dockSide === 'bottom') {
|
||||
newBounds.width = Math.round(display.bounds.width * space)
|
||||
newBounds.height = Math.max(minHeight, Math.round(fill * display.bounds.height))
|
||||
}
|
||||
if (dockSide === 'right') {
|
||||
newBounds.x = display.bounds.x + display.bounds.width - newBounds.width
|
||||
} else if (dockSide === 'left') {
|
||||
newBounds.x = display.bounds.x
|
||||
} else {
|
||||
newBounds.x = display.bounds.x + Math.round(display.bounds.width / 2 * (1 - space))
|
||||
}
|
||||
if (dockSide === 'bottom') {
|
||||
newBounds.y = display.bounds.y + display.bounds.height - newBounds.height
|
||||
} else if (dockSide === 'top') {
|
||||
newBounds.y = display.bounds.y
|
||||
} else {
|
||||
newBounds.y = display.bounds.y + Math.round(display.bounds.height / 2 * (1 - space))
|
||||
}
|
||||
|
||||
const alwaysOnTop = this.config.store.appearance.dockAlwaysOnTop
|
||||
|
||||
this.hostWindow.setAlwaysOnTop(alwaysOnTop)
|
||||
setImmediate(() => {
|
||||
this.hostWindow.setBounds(newBounds)
|
||||
})
|
||||
}
|
||||
|
||||
getScreens (): Screen[] {
|
||||
const primaryDisplayID = this.electron.screen.getPrimaryDisplay().id
|
||||
return this.electron.screen.getAllDisplays().sort((a, b) =>
|
||||
a.bounds.x === b.bounds.x ? a.bounds.y - b.bounds.y : a.bounds.x - b.bounds.x
|
||||
).map((display, index) => {
|
||||
return {
|
||||
...display,
|
||||
id: display.id,
|
||||
name: display.id === primaryDisplayID ? 'Primary Display' : `Display ${index + 1}`,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private getCurrentScreen (): Display {
|
||||
return this.electron.screen.getDisplayNearestPoint(this.electron.screen.getCursorScreenPoint())
|
||||
}
|
||||
|
||||
private repositionWindow () {
|
||||
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) {
|
||||
return
|
||||
}
|
||||
}
|
||||
const screen = this.electron.screen.getPrimaryDisplay()
|
||||
this.hostWindow.getWindow().setPosition(screen.bounds.x, screen.bounds.y)
|
||||
}
|
||||
}
|
47
tabby-electron/src/services/electron.service.ts
Normal file
47
tabby-electron/src/services/electron.service.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { App, IpcRenderer, Shell, Dialog, Clipboard, GlobalShortcut, Screen, Remote, AutoUpdater, TouchBar, BrowserWindow, Menu, MenuItem, NativeImage } from 'electron'
|
||||
import * as remote from '@electron/remote'
|
||||
|
||||
export interface MessageBoxResponse {
|
||||
response: number
|
||||
checkboxChecked?: boolean
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ElectronService {
|
||||
app: App
|
||||
ipcRenderer: IpcRenderer
|
||||
shell: Shell
|
||||
dialog: Dialog
|
||||
clipboard: Clipboard
|
||||
globalShortcut: GlobalShortcut
|
||||
nativeImage: typeof NativeImage
|
||||
screen: Screen
|
||||
remote: Remote
|
||||
process: any
|
||||
autoUpdater: AutoUpdater
|
||||
TouchBar: typeof TouchBar
|
||||
BrowserWindow: typeof BrowserWindow
|
||||
Menu: typeof Menu
|
||||
MenuItem: typeof MenuItem
|
||||
|
||||
/** @hidden */
|
||||
private constructor () {
|
||||
const electron = require('electron')
|
||||
this.shell = electron.shell
|
||||
this.clipboard = electron.clipboard
|
||||
this.ipcRenderer = electron.ipcRenderer
|
||||
|
||||
this.process = remote.getGlobal('process')
|
||||
this.app = remote.app
|
||||
this.screen = remote.screen
|
||||
this.dialog = remote.dialog
|
||||
this.globalShortcut = remote.globalShortcut
|
||||
this.nativeImage = remote.nativeImage
|
||||
this.autoUpdater = remote.autoUpdater
|
||||
this.TouchBar = remote.TouchBar
|
||||
this.BrowserWindow = remote.BrowserWindow
|
||||
this.Menu = remote.Menu
|
||||
this.MenuItem = remote.MenuItem
|
||||
}
|
||||
}
|
41
tabby-electron/src/services/fileProvider.service.ts
Normal file
41
tabby-electron/src/services/fileProvider.service.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { FileProvider } from 'tabby-core'
|
||||
import { ElectronService } from '../services/electron.service'
|
||||
import { ElectronHostWindow } from './hostWindow.service'
|
||||
|
||||
@Injectable()
|
||||
export class ElectronFileProvider extends FileProvider {
|
||||
name = 'Filesystem'
|
||||
|
||||
constructor (
|
||||
private electron: ElectronService,
|
||||
private hostWindow: ElectronHostWindow,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async selectAndStoreFile (description: string): Promise<string> {
|
||||
const result = await this.electron.dialog.showOpenDialog(
|
||||
this.hostWindow.getWindow(),
|
||||
{
|
||||
buttonLabel: `Select ${description}`,
|
||||
properties: ['openFile', 'treatPackageAsDirectory'],
|
||||
},
|
||||
)
|
||||
if (result.canceled || !result.filePaths.length) {
|
||||
throw new Error('canceled')
|
||||
}
|
||||
|
||||
return `file://${result.filePaths[0]}`
|
||||
}
|
||||
|
||||
async retrieveFile (key: string): Promise<Buffer> {
|
||||
if (key.startsWith('file://')) {
|
||||
key = key.substring('file://'.length)
|
||||
} else if (key.includes('://')) {
|
||||
throw new Error('Incorrect type')
|
||||
}
|
||||
return fs.readFile(key, { encoding: null })
|
||||
}
|
||||
}
|
86
tabby-electron/src/services/hostApp.service.ts
Normal file
86
tabby-electron/src/services/hostApp.service.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Injectable, NgZone, Injector } from '@angular/core'
|
||||
import { isWindowsBuild, WIN_BUILD_FLUENT_BG_SUPPORTED, HostAppService, Platform, CLIHandler } from 'tabby-core'
|
||||
import { ElectronService } from '../services/electron.service'
|
||||
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
105
tabby-electron/src/services/hostWindow.service.ts
Normal file
105
tabby-electron/src/services/hostWindow.service.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { BrowserWindow, TouchBar } from 'electron'
|
||||
import { Injectable, Inject, NgZone } from '@angular/core'
|
||||
import { BootstrapData, BOOTSTRAP_DATA, HostWindowService } from 'tabby-core'
|
||||
import { ElectronService } from '../services/electron.service'
|
||||
|
||||
export interface Bounds {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ElectronHostWindow extends HostWindowService {
|
||||
get isFullscreen (): boolean { return this._isFullScreen}
|
||||
|
||||
private _isFullScreen = false
|
||||
|
||||
constructor (
|
||||
zone: NgZone,
|
||||
private electron: ElectronService,
|
||||
@Inject(BOOTSTRAP_DATA) private bootstrapData: BootstrapData,
|
||||
) {
|
||||
super()
|
||||
electron.ipcRenderer.on('host:window-enter-full-screen', () => zone.run(() => {
|
||||
this._isFullScreen = true
|
||||
}))
|
||||
|
||||
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.getWindow().reload()
|
||||
}
|
||||
|
||||
setTitle (title?: string): void {
|
||||
this.electron.ipcRenderer.send('window-set-title', title ?? 'Tabby')
|
||||
}
|
||||
|
||||
toggleFullscreen (): void {
|
||||
this.getWindow().setFullScreen(!this._isFullScreen)
|
||||
}
|
||||
|
||||
minimize (): void {
|
||||
this.electron.ipcRenderer.send('window-minimize')
|
||||
}
|
||||
|
||||
isMaximized (): boolean {
|
||||
return this.getWindow().isMaximized()
|
||||
}
|
||||
|
||||
toggleMaximize (): void {
|
||||
if (this.getWindow().isMaximized()) {
|
||||
this.getWindow().unmaximize()
|
||||
} else {
|
||||
this.getWindow().maximize()
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
55
tabby-electron/src/services/log.service.ts
Normal file
55
tabby-electron/src/services/log.service.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as winston from 'winston'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ConsoleLogger, Logger } from 'tabby-core'
|
||||
import { ElectronService } from '../services/electron.service'
|
||||
|
||||
const initializeWinston = (electron: ElectronService) => {
|
||||
const logDirectory = electron.app.getPath('userData')
|
||||
// eslint-disable-next-line
|
||||
const winston = require('winston')
|
||||
|
||||
if (!fs.existsSync(logDirectory)) {
|
||||
fs.mkdirSync(logDirectory)
|
||||
}
|
||||
|
||||
return winston.createLogger({
|
||||
transports: [
|
||||
new winston.transports.File({
|
||||
level: 'debug',
|
||||
filename: path.join(logDirectory, 'log.txt'),
|
||||
format: winston.format.simple(),
|
||||
handleExceptions: false,
|
||||
maxsize: 5242880,
|
||||
maxFiles: 5,
|
||||
}),
|
||||
],
|
||||
exitOnError: false,
|
||||
})
|
||||
}
|
||||
|
||||
export class WinstonAndConsoleLogger extends ConsoleLogger {
|
||||
constructor (private winstonLogger: winston.Logger, name: string) {
|
||||
super(name)
|
||||
}
|
||||
|
||||
protected doLog (level: string, ...args: any[]): void {
|
||||
super.doLog(level, ...args)
|
||||
this.winstonLogger[level](...args)
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ElectronLogService {
|
||||
private log: winston.Logger
|
||||
|
||||
/** @hidden */
|
||||
constructor (electron: ElectronService) {
|
||||
this.log = initializeWinston(electron)
|
||||
}
|
||||
|
||||
create (name: string): Logger {
|
||||
return new WinstonAndConsoleLogger(this.log, name)
|
||||
}
|
||||
}
|
299
tabby-electron/src/services/platform.service.ts
Normal file
299
tabby-electron/src/services/platform.service.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import * as path from 'path'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as fsSync from 'fs'
|
||||
import * as os from 'os'
|
||||
import promiseIpc from 'electron-promise-ipc'
|
||||
import { execFile } from 'mz/child_process'
|
||||
import { Injectable, NgZone } from '@angular/core'
|
||||
import { PlatformService, ClipboardContent, HostAppService, Platform, MenuItemOptions, MessageBoxOptions, MessageBoxResult, FileUpload, FileDownload, FileUploadOptions, wrapPromise } from 'tabby-core'
|
||||
import { ElectronService } from '../services/electron.service'
|
||||
import { ElectronHostWindow } from './hostWindow.service'
|
||||
import { ShellIntegrationService } from './shellIntegration.service'
|
||||
const fontManager = require('fontmanager-redux') // eslint-disable-line
|
||||
|
||||
/* eslint-disable block-scoped-var */
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-var
|
||||
var windowsProcessTreeNative = require('windows-process-tree/build/Release/windows_process_tree.node')
|
||||
// eslint-disable-next-line no-var
|
||||
var wnr = require('windows-native-registry')
|
||||
} catch { }
|
||||
|
||||
@Injectable()
|
||||
export class ElectronPlatformService extends PlatformService {
|
||||
supportsWindowControls = true
|
||||
private configPath: string
|
||||
|
||||
constructor (
|
||||
private hostApp: HostAppService,
|
||||
private hostWindow: ElectronHostWindow,
|
||||
private electron: ElectronService,
|
||||
private zone: NgZone,
|
||||
private shellIntegration: ShellIntegrationService,
|
||||
) {
|
||||
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 {
|
||||
return this.electron.clipboard.readText()
|
||||
}
|
||||
|
||||
setClipboard (content: ClipboardContent): void {
|
||||
require('@electron/remote').clipboard.write(content)
|
||||
}
|
||||
|
||||
async installPlugin (name: string, version: string): Promise<void> {
|
||||
await (promiseIpc as any).send('plugin-manager:install', name, version)
|
||||
}
|
||||
|
||||
async uninstallPlugin (name: string): Promise<void> {
|
||||
await (promiseIpc as any).send('plugin-manager:uninstall', name)
|
||||
}
|
||||
|
||||
async isProcessRunning (name: string): Promise<boolean> {
|
||||
if (this.hostApp.platform === Platform.Windows) {
|
||||
return new Promise<boolean>(resolve => {
|
||||
windowsProcessTreeNative.getProcessList(list => { // eslint-disable-line block-scoped-var
|
||||
resolve(list.some(x => x.name === name))
|
||||
}, 0)
|
||||
})
|
||||
} else {
|
||||
throw new Error('Not supported')
|
||||
}
|
||||
}
|
||||
|
||||
getWinSCPPath (): string|null {
|
||||
const key = wnr.getRegistryKey(wnr.HK.CR, 'WinSCP.Url\\DefaultIcon')
|
||||
if (key?.['']) {
|
||||
let detectedPath = key[''].value?.split(',')[0]
|
||||
detectedPath = detectedPath?.substring(1, detectedPath.length - 1)
|
||||
return detectedPath
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
exec (app: string, argv: string[]): void {
|
||||
execFile(app, argv)
|
||||
}
|
||||
|
||||
isShellIntegrationSupported (): boolean {
|
||||
return this.hostApp.platform !== Platform.Linux
|
||||
}
|
||||
|
||||
async isShellIntegrationInstalled (): Promise<boolean> {
|
||||
return this.shellIntegration.isInstalled()
|
||||
}
|
||||
|
||||
async installShellIntegration (): Promise<void> {
|
||||
await this.shellIntegration.install()
|
||||
}
|
||||
|
||||
async uninstallShellIntegration (): Promise<void> {
|
||||
await this.shellIntegration.remove()
|
||||
}
|
||||
|
||||
async loadConfig (): Promise<string> {
|
||||
if (fsSync.existsSync(this.configPath)) {
|
||||
return fs.readFile(this.configPath, 'utf8')
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async saveConfig (content: string): Promise<void> {
|
||||
await fs.writeFile(this.configPath, content, 'utf8')
|
||||
}
|
||||
|
||||
getConfigPath (): string|null {
|
||||
return this.configPath
|
||||
}
|
||||
|
||||
showItemInFolder (p: string): void {
|
||||
this.electron.shell.showItemInFolder(p)
|
||||
}
|
||||
|
||||
openExternal (url: string): void {
|
||||
this.electron.shell.openExternal(url)
|
||||
}
|
||||
|
||||
openPath (p: string): void {
|
||||
this.electron.shell.openPath(p)
|
||||
}
|
||||
|
||||
getOSRelease (): string {
|
||||
return os.release()
|
||||
}
|
||||
|
||||
getAppVersion (): string {
|
||||
return this.electron.app.getVersion()
|
||||
}
|
||||
|
||||
async listFonts (): Promise<string[]> {
|
||||
if (this.hostApp.platform === Platform.Windows || this.hostApp.platform === Platform.macOS) {
|
||||
let fonts = await new Promise<any[]>((resolve) => fontManager.findFonts({ monospace: true }, resolve))
|
||||
fonts = fonts.map(x => x.family.trim())
|
||||
return fonts
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (this.hostApp.platform === Platform.Linux) {
|
||||
const stdout = (await execFile('fc-list', [':spacing=mono']))[0]
|
||||
const fonts = stdout.toString()
|
||||
.split('\n')
|
||||
.filter(x => !!x)
|
||||
.map(x => x.split(':')[1].trim())
|
||||
.map(x => x.split(',')[0].trim())
|
||||
fonts.sort()
|
||||
return fonts
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
popupContextMenu (menu: MenuItemOptions[], _event?: MouseEvent): void {
|
||||
this.electron.Menu.buildFromTemplate(menu.map(item => this.rewrapMenuItemOptions(item))).popup({})
|
||||
}
|
||||
|
||||
rewrapMenuItemOptions (menu: MenuItemOptions): MenuItemOptions {
|
||||
return {
|
||||
...menu,
|
||||
click: () => {
|
||||
this.zone.run(() => {
|
||||
menu.click?.()
|
||||
})
|
||||
},
|
||||
submenu: menu.submenu ? menu.submenu.map(x => this.rewrapMenuItemOptions(x)) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async showMessageBox (options: MessageBoxOptions): Promise<MessageBoxResult> {
|
||||
return this.electron.dialog.showMessageBox(this.hostWindow.getWindow(), options)
|
||||
}
|
||||
|
||||
quit (): void {
|
||||
this.electron.app.exit(0)
|
||||
}
|
||||
|
||||
async startUpload (options?: FileUploadOptions): Promise<FileUpload[]> {
|
||||
options ??= { multiple: false }
|
||||
|
||||
const properties: any[] = ['openFile', 'treatPackageAsDirectory']
|
||||
if (options.multiple) {
|
||||
properties.push('multiSelections')
|
||||
}
|
||||
|
||||
const result = await this.electron.dialog.showOpenDialog(
|
||||
this.hostWindow.getWindow(),
|
||||
{
|
||||
buttonLabel: 'Select',
|
||||
properties,
|
||||
},
|
||||
)
|
||||
if (result.canceled) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Promise.all(result.filePaths.map(async p => {
|
||||
const transfer = new ElectronFileUpload(p)
|
||||
await wrapPromise(this.zone, transfer.open())
|
||||
this.fileTransferStarted.next(transfer)
|
||||
return transfer
|
||||
}))
|
||||
}
|
||||
|
||||
async startDownload (name: string, size: number): Promise<FileDownload|null> {
|
||||
const result = await this.electron.dialog.showSaveDialog(
|
||||
this.hostWindow.getWindow(),
|
||||
{
|
||||
defaultPath: name,
|
||||
},
|
||||
)
|
||||
if (!result.filePath) {
|
||||
return null
|
||||
}
|
||||
const transfer = new ElectronFileDownload(result.filePath, size)
|
||||
await wrapPromise(this.zone, transfer.open())
|
||||
this.fileTransferStarted.next(transfer)
|
||||
return transfer
|
||||
}
|
||||
|
||||
setErrorHandler (handler: (_: any) => void): void {
|
||||
this.electron.ipcRenderer.on('uncaughtException', (_$event, err) => {
|
||||
handler(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class ElectronFileUpload extends FileUpload {
|
||||
private size: number
|
||||
private file: fs.FileHandle
|
||||
private buffer: Buffer
|
||||
|
||||
constructor (private filePath: string) {
|
||||
super()
|
||||
this.buffer = Buffer.alloc(256 * 1024)
|
||||
}
|
||||
|
||||
async open (): Promise<void> {
|
||||
this.size = (await fs.stat(this.filePath)).size
|
||||
this.file = await fs.open(this.filePath, 'r')
|
||||
}
|
||||
|
||||
getName (): string {
|
||||
return path.basename(this.filePath)
|
||||
}
|
||||
|
||||
getSize (): number {
|
||||
return this.size
|
||||
}
|
||||
|
||||
async read (): Promise<Buffer> {
|
||||
const result = await this.file.read(this.buffer, 0, this.buffer.length, null)
|
||||
this.increaseProgress(result.bytesRead)
|
||||
return this.buffer.slice(0, result.bytesRead)
|
||||
}
|
||||
|
||||
close (): void {
|
||||
this.file.close()
|
||||
}
|
||||
}
|
||||
|
||||
class ElectronFileDownload extends FileDownload {
|
||||
private file: fs.FileHandle
|
||||
|
||||
constructor (
|
||||
private filePath: string,
|
||||
private size: number,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async open (): Promise<void> {
|
||||
this.file = await fs.open(this.filePath, 'w')
|
||||
}
|
||||
|
||||
getName (): string {
|
||||
return path.basename(this.filePath)
|
||||
}
|
||||
|
||||
getSize (): number {
|
||||
return this.size
|
||||
}
|
||||
|
||||
async write (buffer: Buffer): Promise<void> {
|
||||
let pos = 0
|
||||
while (pos < buffer.length) {
|
||||
const result = await this.file.write(buffer, pos, buffer.length - pos, null)
|
||||
this.increaseProgress(result.bytesWritten)
|
||||
pos += result.bytesWritten
|
||||
}
|
||||
}
|
||||
|
||||
close (): void {
|
||||
this.file.close()
|
||||
}
|
||||
}
|
105
tabby-electron/src/services/shellIntegration.service.ts
Normal file
105
tabby-electron/src/services/shellIntegration.service.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import * as path from 'path'
|
||||
import * as fs from 'mz/fs'
|
||||
import { exec } from 'mz/child_process'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { HostAppService, Platform } from 'tabby-core'
|
||||
import { ElectronService } from '../services/electron.service'
|
||||
|
||||
/* eslint-disable block-scoped-var */
|
||||
|
||||
try {
|
||||
var wnr = require('windows-native-registry') // eslint-disable-line @typescript-eslint/no-var-requires, no-var
|
||||
} catch (_) { }
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ShellIntegrationService {
|
||||
private automatorWorkflows = ['Open Tabby here.workflow', 'Paste path into Tabby.workflow']
|
||||
private automatorWorkflowsLocation: string
|
||||
private automatorWorkflowsDestination: string
|
||||
private registryKeys = [
|
||||
{
|
||||
path: 'Software\\Classes\\Directory\\Background\\shell\\Tabby',
|
||||
value: 'Open Tabby here',
|
||||
command: 'open "%V"',
|
||||
},
|
||||
{
|
||||
path: 'SOFTWARE\\Classes\\Directory\\shell\\Tabby',
|
||||
value: 'Open Tabby here',
|
||||
command: 'open "%V"',
|
||||
},
|
||||
{
|
||||
path: 'Software\\Classes\\*\\shell\\Tabby',
|
||||
value: 'Paste path into Tabby',
|
||||
command: 'paste "%V"',
|
||||
},
|
||||
]
|
||||
private constructor (
|
||||
private electron: ElectronService,
|
||||
private hostApp: HostAppService,
|
||||
) {
|
||||
if (this.hostApp.platform === Platform.macOS) {
|
||||
this.automatorWorkflowsLocation = path.join(
|
||||
path.dirname(path.dirname(this.electron.app.getPath('exe'))),
|
||||
'Resources',
|
||||
'extras',
|
||||
'automator-workflows',
|
||||
)
|
||||
this.automatorWorkflowsDestination = path.join(process.env.HOME!, 'Library', 'Services')
|
||||
}
|
||||
this.updatePaths()
|
||||
}
|
||||
|
||||
async isInstalled (): Promise<boolean> {
|
||||
if (this.hostApp.platform === Platform.macOS) {
|
||||
return fs.exists(path.join(this.automatorWorkflowsDestination, this.automatorWorkflows[0]))
|
||||
} else if (this.hostApp.platform === Platform.Windows) {
|
||||
return !!wnr.getRegistryKey(wnr.HK.CU, this.registryKeys[0].path)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async install (): Promise<void> {
|
||||
const exe: string = process.env.PORTABLE_EXECUTABLE_FILE ?? this.electron.app.getPath('exe')
|
||||
if (this.hostApp.platform === Platform.macOS) {
|
||||
for (const wf of this.automatorWorkflows) {
|
||||
await exec(`cp -r "${this.automatorWorkflowsLocation}/${wf}" "${this.automatorWorkflowsDestination}"`)
|
||||
}
|
||||
} else if (this.hostApp.platform === Platform.Windows) {
|
||||
for (const registryKey of this.registryKeys) {
|
||||
wnr.createRegistryKey(wnr.HK.CU, registryKey.path)
|
||||
wnr.createRegistryKey(wnr.HK.CU, registryKey.path + '\\command')
|
||||
wnr.setRegistryValue(wnr.HK.CU, registryKey.path, '', wnr.REG.SZ, registryKey.value)
|
||||
wnr.setRegistryValue(wnr.HK.CU, registryKey.path, 'Icon', wnr.REG.SZ, exe)
|
||||
wnr.setRegistryValue(wnr.HK.CU, registryKey.path + '\\command', '', wnr.REG.SZ, exe + ' ' + registryKey.command)
|
||||
}
|
||||
|
||||
if (wnr.getRegistryKey(wnr.HK.CU, 'Software\\Classes\\Directory\\Background\\shell\\Open Tabby here')) {
|
||||
wnr.deleteRegistryKey(wnr.HK.CU, 'Software\\Classes\\Directory\\Background\\shell\\Open Tabby here')
|
||||
}
|
||||
if (wnr.getRegistryKey(wnr.HK.CU, 'Software\\Classes\\*\\shell\\Paste path into Tabby')) {
|
||||
wnr.deleteRegistryKey(wnr.HK.CU, 'Software\\Classes\\*\\shell\\Paste path into Tabby')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async remove (): Promise<void> {
|
||||
if (this.hostApp.platform === Platform.macOS) {
|
||||
for (const wf of this.automatorWorkflows) {
|
||||
await exec(`rm -rf "${this.automatorWorkflowsDestination}/${wf}"`)
|
||||
}
|
||||
} else if (this.hostApp.platform === Platform.Windows) {
|
||||
for (const registryKey of this.registryKeys) {
|
||||
wnr.deleteRegistryKey(wnr.HK.CU, registryKey.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async updatePaths (): Promise<void> {
|
||||
// Update paths in case of an update
|
||||
if (this.hostApp.platform === Platform.Windows) {
|
||||
if (await this.isInstalled()) {
|
||||
await this.install()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
83
tabby-electron/src/services/touchbar.service.ts
Normal file
83
tabby-electron/src/services/touchbar.service.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { SegmentedControlSegment, TouchBarSegmentedControl } from 'electron'
|
||||
import { Injectable, NgZone } from '@angular/core'
|
||||
import { AppService, HostAppService, Platform } from 'tabby-core'
|
||||
import { ElectronService } from '../services/electron.service'
|
||||
import { ElectronHostWindow } from './hostWindow.service'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TouchbarService {
|
||||
private tabsSegmentedControl: TouchBarSegmentedControl
|
||||
private tabSegments: SegmentedControlSegment[] = []
|
||||
|
||||
private constructor (
|
||||
private app: AppService,
|
||||
private hostApp: HostAppService,
|
||||
private hostWindow: ElectronHostWindow,
|
||||
private electron: ElectronService,
|
||||
private zone: NgZone,
|
||||
) {
|
||||
if (this.hostApp.platform !== Platform.macOS) {
|
||||
return
|
||||
}
|
||||
app.tabsChanged$.subscribe(() => this.updateTabs())
|
||||
app.activeTabChange$.subscribe(() => this.updateTabs())
|
||||
|
||||
const activityIconPath = `${electron.app.getAppPath()}/assets/activity.png`
|
||||
const activityIcon = this.electron.nativeImage.createFromPath(activityIconPath)
|
||||
app.tabOpened$.subscribe(tab => {
|
||||
tab.titleChange$.subscribe(title => {
|
||||
const segment = this.tabSegments[app.tabs.indexOf(tab)]
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (segment) {
|
||||
segment.label = this.shortenTitle(title)
|
||||
this.tabsSegmentedControl.segments = this.tabSegments
|
||||
}
|
||||
})
|
||||
tab.activity$.subscribe(hasActivity => {
|
||||
const showIcon = this.app.activeTab !== tab && hasActivity
|
||||
const segment = this.tabSegments[app.tabs.indexOf(tab)]
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (segment) {
|
||||
segment.icon = showIcon ? activityIcon : undefined
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
updateTabs (): void {
|
||||
this.tabSegments = this.app.tabs.map(tab => ({
|
||||
label: this.shortenTitle(tab.title),
|
||||
}))
|
||||
this.tabsSegmentedControl.segments = this.tabSegments
|
||||
this.tabsSegmentedControl.selectedIndex = this.app.activeTab ? this.app.tabs.indexOf(this.app.activeTab) : 0
|
||||
}
|
||||
|
||||
update (): void {
|
||||
if (this.hostApp.platform !== Platform.macOS) {
|
||||
return
|
||||
}
|
||||
|
||||
this.tabsSegmentedControl = new this.electron.TouchBar.TouchBarSegmentedControl({
|
||||
segments: this.tabSegments,
|
||||
selectedIndex: this.app.activeTab ? this.app.tabs.indexOf(this.app.activeTab) : undefined,
|
||||
change: (selectedIndex) => this.zone.run(() => {
|
||||
this.app.selectTab(this.app.tabs[selectedIndex])
|
||||
}),
|
||||
})
|
||||
|
||||
const touchBar = new this.electron.TouchBar({
|
||||
items: [
|
||||
this.tabsSegmentedControl,
|
||||
],
|
||||
})
|
||||
this.hostWindow.setTouchBar(touchBar)
|
||||
}
|
||||
|
||||
private shortenTitle (title: string): string {
|
||||
if (title.length > 15) {
|
||||
title = title.substring(0, 15) + '...'
|
||||
}
|
||||
return title
|
||||
}
|
||||
}
|
138
tabby-electron/src/services/updater.service.ts
Normal file
138
tabby-electron/src/services/updater.service.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import axios from 'axios'
|
||||
|
||||
import { Logger, LogService, ConfigService, UpdaterService, PlatformService } from 'tabby-core'
|
||||
import { ElectronService } from '../services/electron.service'
|
||||
|
||||
const UPDATES_URL = 'https://api.github.com/repos/eugeny/terminus/releases/latest'
|
||||
|
||||
@Injectable()
|
||||
export class ElectronUpdaterService extends UpdaterService {
|
||||
private logger: Logger
|
||||
private downloaded: Promise<boolean>
|
||||
private electronUpdaterAvailable = true
|
||||
private updateURL: string
|
||||
|
||||
constructor (
|
||||
log: LogService,
|
||||
config: ConfigService,
|
||||
private platform: PlatformService,
|
||||
private electron: ElectronService,
|
||||
) {
|
||||
super()
|
||||
this.logger = log.create('updater')
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
this.electronUpdaterAvailable = false
|
||||
return
|
||||
}
|
||||
|
||||
electron.autoUpdater.on('update-available', () => {
|
||||
this.logger.info('Update available')
|
||||
})
|
||||
|
||||
electron.autoUpdater.on('update-not-available', () => {
|
||||
this.logger.info('No updates')
|
||||
})
|
||||
|
||||
electron.autoUpdater.on('error', err => {
|
||||
this.logger.error(err)
|
||||
this.electronUpdaterAvailable = false
|
||||
})
|
||||
|
||||
this.downloaded = new Promise<boolean>(resolve => {
|
||||
electron.autoUpdater.once('update-downloaded', () => resolve(true))
|
||||
})
|
||||
|
||||
|
||||
config.ready$.toPromise().then(() => {
|
||||
if (config.store.enableAutomaticUpdates && this.electronUpdaterAvailable && !process.env.TABBY_DEV) {
|
||||
this.logger.debug('Checking for updates')
|
||||
try {
|
||||
electron.autoUpdater.setFeedURL({
|
||||
url: `https://update.electronjs.org/eugeny/terminus/${process.platform}-${process.arch}/${electron.app.getVersion()}`,
|
||||
})
|
||||
electron.autoUpdater.checkForUpdates()
|
||||
} catch (e) {
|
||||
this.electronUpdaterAvailable = false
|
||||
this.logger.info('Electron updater unavailable, falling back', e)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async check (): Promise<boolean> {
|
||||
if (this.electronUpdaterAvailable) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations, prefer-const
|
||||
let cancel
|
||||
const onNoUpdate = () => {
|
||||
cancel()
|
||||
resolve(false)
|
||||
}
|
||||
const onUpdate = () => {
|
||||
cancel()
|
||||
resolve(this.downloaded)
|
||||
}
|
||||
const onError = (err) => {
|
||||
cancel()
|
||||
reject(err)
|
||||
}
|
||||
cancel = () => {
|
||||
this.electron.autoUpdater.off('error', onError)
|
||||
this.electron.autoUpdater.off('update-not-available', onNoUpdate)
|
||||
this.electron.autoUpdater.off('update-available', onUpdate)
|
||||
}
|
||||
this.electron.autoUpdater.on('error', onError)
|
||||
this.electron.autoUpdater.on('update-not-available', onNoUpdate)
|
||||
this.electron.autoUpdater.on('update-available', onUpdate)
|
||||
try {
|
||||
this.electron.autoUpdater.checkForUpdates()
|
||||
} catch (e) {
|
||||
this.electronUpdaterAvailable = false
|
||||
this.logger.info('Electron updater unavailable, falling back', e)
|
||||
}
|
||||
})
|
||||
|
||||
this.electron.autoUpdater.on('update-available', () => {
|
||||
this.logger.info('Update available')
|
||||
})
|
||||
|
||||
this.electron.autoUpdater.once('update-not-available', () => {
|
||||
this.logger.info('No updates')
|
||||
})
|
||||
|
||||
} else {
|
||||
this.logger.debug('Checking for updates through fallback method.')
|
||||
const response = await axios.get(UPDATES_URL)
|
||||
const data = response.data
|
||||
const version = data.tag_name.substring(1)
|
||||
if (this.electron.app.getVersion() !== version) {
|
||||
this.logger.info('Update available')
|
||||
this.updateURL = data.html_url
|
||||
return true
|
||||
}
|
||||
this.logger.info('No updates')
|
||||
return false
|
||||
}
|
||||
return this.downloaded
|
||||
}
|
||||
|
||||
async update (): Promise<void> {
|
||||
if (!this.electronUpdaterAvailable) {
|
||||
this.electron.shell.openExternal(this.updateURL)
|
||||
} else {
|
||||
if ((await this.platform.showMessageBox(
|
||||
{
|
||||
type: 'warning',
|
||||
message: 'Installing the update will close all tabs and restart Tabby.',
|
||||
buttons: ['Cancel', 'Update'],
|
||||
defaultId: 1,
|
||||
}
|
||||
)).response === 1) {
|
||||
await this.downloaded
|
||||
this.electron.autoUpdater.quitAndInstall()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user