import * as path from 'path' import * as fs from 'fs/promises' import * as fsSync from 'fs' import * as os from 'os' import promiseIpc, { RendererProcessType } from 'electron-promise-ipc' import { execFile } from 'mz/child_process' import { Injectable, NgZone } from '@angular/core' import { PlatformService, ClipboardContent, Platform, MenuItemOptions, MessageBoxOptions, MessageBoxResult, DirectoryUpload, FileUpload, FileDownload, DirectoryDownload, FileUploadOptions, wrapPromise, TranslateService, FileTransfer, PlatformTheme } from 'tabby-core' import { ElectronService } from '../services/electron.service' import { ElectronHostWindow } from './hostWindow.service' import { ShellIntegrationService } from './shellIntegration.service' import { ElectronHostAppService } from './hostApp.service' import { configPath } from '../../../app/lib/config' const fontManager = require('fontmanager-redux') // eslint-disable-line /* eslint-disable block-scoped-var */ try { // eslint-disable-next-line no-var var windowsProcessTreeNative = require('@tabby-gang/windows-process-tree/build/Release/windows_process_tree.node') // eslint-disable-next-line no-var var wnr = require('windows-native-registry') } catch { } @Injectable({ providedIn: 'root' }) export class ElectronPlatformService extends PlatformService { supportsWindowControls = true private configPath: string constructor ( private hostApp: ElectronHostAppService, private hostWindow: ElectronHostWindow, private electron: ElectronService, private zone: NgZone, private shellIntegration: ShellIntegrationService, private translate: TranslateService, ) { super() this.configPath = configPath electron.ipcRenderer.on('host:display-metrics-changed', () => { this.zone.run(() => this.displayMetricsChanged.next()) }) electron.nativeTheme.on('updated', () => { this.zone.run(() => this.themeChanged.next(this.getTheme())) }) } async getAllFiles (dir: string, root: DirectoryUpload): Promise { const items = await fs.readdir(dir, { withFileTypes: true }) for (const item of items) { if (item.isDirectory()) { root.pushChildren(await this.getAllFiles(path.join(dir, item.name), new DirectoryUpload(item.name))) } else { const file = new ElectronFileUpload(path.join(dir, item.name), this.electron) root.pushChildren(file) await wrapPromise(this.zone, file.open()) this.fileTransferStarted.next(file) } } return root } readClipboard (): string { return this.electron.clipboard.readText() } setClipboard (content: ClipboardContent): void { require('@electron/remote').clipboard.write(content) } async installPlugin (name: string, version: string): Promise { await (promiseIpc as RendererProcessType).send('plugin-manager:install', name, version) } async uninstallPlugin (name: string): Promise { await (promiseIpc as RendererProcessType).send('plugin-manager:uninstall', name) } async isProcessRunning (name: string): Promise { if (this.hostApp.platform === Platform.Windows) { return new Promise(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 } async exec (app: string, argv: string[]): Promise { await execFile(app, argv) } isShellIntegrationSupported (): boolean { return this.hostApp.platform !== Platform.Linux } async isShellIntegrationInstalled (): Promise { return this.shellIntegration.isInstalled() } async installShellIntegration (): Promise { await this.shellIntegration.install() } async uninstallShellIntegration (): Promise { await this.shellIntegration.remove() } async loadConfig (): Promise { if (fsSync.existsSync(this.configPath)) { return fs.readFile(this.configPath, 'utf8') } else { return '' } } async saveConfig (content: string): Promise { await this.hostApp.saveConfig(content) } 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 { if (this.hostApp.platform === Platform.Windows || this.hostApp.platform === Platform.macOS) { let fonts = await new Promise(resolve => fontManager.getAvailableFonts(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 { return this.electron.dialog.showMessageBox(this.hostWindow.getWindow(), options) } quit (): void { this.electron.app.exit(0) } async startUpload (options?: FileUploadOptions, paths?: string[]): Promise { options ??= { multiple: false } const properties: any[] = ['openFile', 'treatPackageAsDirectory'] if (options.multiple) { properties.push('multiSelections') } if (!paths) { const result = await this.electron.dialog.showOpenDialog( this.hostWindow.getWindow(), { buttonLabel: this.translate.instant('Select'), properties, }, ) if (result.canceled) { return [] } paths = result.filePaths } return Promise.all(paths.map(async p => { const transfer = new ElectronFileUpload(p, this.electron) await wrapPromise(this.zone, transfer.open()) this.fileTransferStarted.next(transfer) return transfer })) } async startUploadDirectory (paths?: string[]): Promise { const properties: any[] = ['openFile', 'treatPackageAsDirectory', 'openDirectory'] if (!paths) { const result = await this.electron.dialog.showOpenDialog( this.hostWindow.getWindow(), { buttonLabel: this.translate.instant('Select'), properties, }, ) if (result.canceled) { return new DirectoryUpload() } paths = result.filePaths } const root = new DirectoryUpload() root.pushChildren(await this.getAllFiles(paths[0].split(path.sep).join(path.posix.sep), new DirectoryUpload(path.basename(paths[0])))) return root } async startDownload (name: string, mode: number, size: number, filePath?: string): Promise { if (!filePath) { const result = await this.electron.dialog.showSaveDialog( this.hostWindow.getWindow(), { defaultPath: name, }, ) if (!result.filePath) { return null } filePath = result.filePath } const transfer = new ElectronFileDownload(filePath, mode, size, this.electron) await wrapPromise(this.zone, transfer.open()) this.fileTransferStarted.next(transfer) return transfer } async startDownloadDirectory (name: string, estimatedSize?: number): Promise { const selectedFolder = await this.pickDirectory(this.translate.instant('Select destination folder for {name}', { name }), this.translate.instant('Download here')) if (!selectedFolder) { return null } let downloadPath = path.join(selectedFolder, name) let counter = 1 while (fsSync.existsSync(downloadPath)) { downloadPath = path.join(selectedFolder, `${name} (${counter})`) counter++ } const transfer = new ElectronDirectoryDownload(downloadPath, name, estimatedSize ?? 0, this.electron, this.zone) await wrapPromise(this.zone, transfer.open()) this.fileTransferStarted.next(transfer) return transfer } _registerFileTransfer (transfer: FileTransfer): void { this.fileTransferStarted.next(transfer) } setErrorHandler (handler: (_: any) => void): void { this.electron.ipcRenderer.on('uncaughtException', (_$event, err) => { handler(err) }) } async pickDirectory (title?: string, buttonLabel?: string): Promise { const result = await this.electron.dialog.showOpenDialog( this.hostWindow.getWindow(), { title, buttonLabel, properties: ['openDirectory', 'showHiddenFiles'], }, ) if (result.canceled || !result.filePaths.length) { return null } return result.filePaths[0] } getTheme (): PlatformTheme { if (this.electron.nativeTheme.shouldUseDarkColors) { return 'dark' } else { return 'light' } } } class ElectronFileUpload extends FileUpload { private size: number private mode: number private file: fs.FileHandle private buffer: Uint8Array private powerSaveBlocker = 0 constructor (private filePath: string, private electron: ElectronService) { super() this.buffer = new Uint8Array(256 * 1024) this.powerSaveBlocker = electron.powerSaveBlocker.start('prevent-app-suspension') } async open (): Promise { const stat = await fs.stat(this.filePath) this.size = stat.size this.mode = stat.mode this.setTotalSize(this.size) this.file = await fs.open(this.filePath, 'r') } getName (): string { return path.basename(this.filePath) } getMode (): number { return this.mode } getSize (): number { return this.size } async read (): Promise { const result = await this.file.read(this.buffer, 0, this.buffer.length, null) this.increaseProgress(result.bytesRead) if (this.getCompletedBytes() >= this.getSize()) { this.setCompleted(true) } return this.buffer.slice(0, result.bytesRead) } close (): void { this.electron.powerSaveBlocker.stop(this.powerSaveBlocker) this.file.close() } } class ElectronFileDownload extends FileDownload { private file: fs.FileHandle private powerSaveBlocker = 0 constructor ( private filePath: string, private mode: number, private size: number, private electron: ElectronService, ) { super() this.powerSaveBlocker = electron.powerSaveBlocker.start('prevent-app-suspension') this.setTotalSize(size) } async open (): Promise { this.file = await fs.open(this.filePath, 'w', this.mode) } getName (): string { return path.basename(this.filePath) } getSize (): number { return this.size } async write (buffer: Uint8Array): Promise { 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 } if (this.getCompletedBytes() >= this.getSize()) { this.setCompleted(true) } } close (): void { this.electron.powerSaveBlocker.stop(this.powerSaveBlocker) this.file.close() } } class ElectronDirectoryDownload extends DirectoryDownload { private powerSaveBlocker = 0 constructor ( private basePath: string, private name: string, estimatedSize: number, private electron: ElectronService, private zone: NgZone, ) { super() this.powerSaveBlocker = electron.powerSaveBlocker.start('prevent-app-suspension') this.setTotalSize(estimatedSize) } async open (): Promise { await fs.mkdir(this.basePath, { recursive: true }) } getName (): string { return this.name } getSize (): number { return this.getTotalSize() } async createDirectory (relativePath: string): Promise { const fullPath = path.join(this.basePath, relativePath) await fs.mkdir(fullPath, { recursive: true }) } async createFile (relativePath: string, mode: number, size: number): Promise { const fullPath = path.join(this.basePath, relativePath) await fs.mkdir(path.dirname(fullPath), { recursive: true }) const fileDownload = new ElectronFileDownload(fullPath, mode, size, this.electron) await wrapPromise(this.zone, fileDownload.open()) return fileDownload } close (): void { this.electron.powerSaveBlocker.stop(this.powerSaveBlocker) } }