mirror of
https://github.com/Eugeny/tabby.git
synced 2025-06-08 21:40:03 +00:00
441 lines
15 KiB
TypeScript
441 lines
15 KiB
TypeScript
import deepClone from 'clone-deep'
|
|
import deepEqual from 'deep-equal'
|
|
import { v4 as uuidv4 } from 'uuid'
|
|
import * as yaml from 'js-yaml'
|
|
import { Observable, Subject, AsyncSubject, lastValueFrom } from 'rxjs'
|
|
import { Injectable, Inject } from '@angular/core'
|
|
import { TranslateService } from '@ngx-translate/core'
|
|
import { ConfigProvider } from '../api/configProvider'
|
|
import { PlatformService } from '../api/platform'
|
|
import { HostAppService } from '../api/hostApp'
|
|
import { Vault, VaultService } from './vault.service'
|
|
import { serializeFunction } from '../utils'
|
|
const deepmerge = require('deepmerge')
|
|
|
|
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
|
export const configMerge = (a, b) => deepmerge(a, b, { arrayMerge: (_d, s) => s }) // eslint-disable-line @typescript-eslint/no-var-requires
|
|
|
|
const LATEST_VERSION = 1
|
|
|
|
function isStructuralMember (v) {
|
|
return v instanceof Object && !(v instanceof Array) &&
|
|
Object.keys(v).length > 0 && !v.__nonStructural
|
|
}
|
|
|
|
function isNonStructuralObjectMember (v): boolean {
|
|
return v instanceof Object && (v instanceof Array || v.__nonStructural)
|
|
}
|
|
|
|
/** @hidden */
|
|
export class ConfigProxy {
|
|
constructor (real: Record<string, any>, defaults: Record<string, any>) {
|
|
for (const key in defaults) {
|
|
if (isStructuralMember(defaults[key])) {
|
|
if (!real[key]) {
|
|
real[key] = {}
|
|
}
|
|
const proxy = new ConfigProxy(real[key], defaults[key])
|
|
Object.defineProperty(
|
|
this,
|
|
key,
|
|
{
|
|
enumerable: true,
|
|
configurable: false,
|
|
get: () => proxy,
|
|
},
|
|
)
|
|
} else {
|
|
Object.defineProperty(
|
|
this,
|
|
key,
|
|
{
|
|
enumerable: true,
|
|
configurable: false,
|
|
get: () => this.__getValue(key),
|
|
set: (value) => {
|
|
this.__setValue(key, value)
|
|
},
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
this.__getValue = (key: string) => { // eslint-disable-line @typescript-eslint/unbound-method
|
|
if (real[key] !== undefined) {
|
|
return real[key]
|
|
} else {
|
|
if (isNonStructuralObjectMember(defaults[key])) {
|
|
// The object might be modified outside
|
|
real[key] = this.__getDefault(key)
|
|
delete real[key].__nonStructural
|
|
return real[key]
|
|
}
|
|
return this.__getDefault(key)
|
|
}
|
|
}
|
|
|
|
this.__getDefault = (key: string) => { // eslint-disable-line @typescript-eslint/unbound-method
|
|
return deepClone(defaults[key])
|
|
}
|
|
|
|
this.__setValue = (key: string, value: any) => { // eslint-disable-line @typescript-eslint/unbound-method
|
|
if (deepEqual(value, this.__getDefault(key))) {
|
|
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
|
delete real[key]
|
|
} else {
|
|
real[key] = value
|
|
}
|
|
}
|
|
|
|
this.__cleanup = () => { // eslint-disable-line @typescript-eslint/unbound-method
|
|
// Trigger removal of default values
|
|
for (const key in defaults) {
|
|
if (isStructuralMember(defaults[key])) {
|
|
this[key].__cleanup()
|
|
} else {
|
|
const v = this.__getValue(key)
|
|
this.__setValue(key, v)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-empty-function
|
|
__getValue (_key: string): any { }
|
|
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-empty-function
|
|
__setValue (_key: string, _value: any) { }
|
|
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-empty-function
|
|
__getDefault (_key: string): any { }
|
|
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-empty-function
|
|
__cleanup () { }
|
|
}
|
|
|
|
@Injectable({ providedIn: 'root' })
|
|
export class ConfigService {
|
|
/**
|
|
* Contains the actual config values
|
|
*/
|
|
store: any
|
|
|
|
/**
|
|
* Whether an app restart is required due to recent changes
|
|
*/
|
|
restartRequested: boolean
|
|
|
|
/** Fires once when the config is loaded */
|
|
get ready$ (): Observable<boolean> { return this.ready }
|
|
|
|
private ready = new AsyncSubject<boolean>()
|
|
private changed = new Subject<void>()
|
|
private _store: any
|
|
private defaults: any
|
|
private servicesCache: Record<string, Function[]>|null = null // eslint-disable-line @typescript-eslint/ban-types
|
|
|
|
get changed$ (): Observable<void> { return this.changed }
|
|
|
|
/** @hidden */
|
|
private constructor (
|
|
private hostApp: HostAppService,
|
|
private platform: PlatformService,
|
|
private vault: VaultService,
|
|
private translate: TranslateService,
|
|
@Inject(ConfigProvider) private configProviders: ConfigProvider[],
|
|
) {
|
|
this.defaults = this.mergeDefaults()
|
|
setTimeout(() => this.init())
|
|
vault.contentChanged$.subscribe(() => {
|
|
this.store.vault = vault.store
|
|
this.save()
|
|
})
|
|
this.save = serializeFunction(this.save.bind(this))
|
|
}
|
|
|
|
mergeDefaults (): unknown {
|
|
const providers = this.configProviders
|
|
return providers.map(provider => {
|
|
let defaults = provider.platformDefaults[this.hostApp.configPlatform] ?? {}
|
|
defaults = configMerge(
|
|
defaults,
|
|
provider.platformDefaults[this.hostApp.platform] ?? {},
|
|
)
|
|
if (provider.defaults) {
|
|
defaults = configMerge(provider.defaults, defaults)
|
|
}
|
|
return defaults
|
|
}).reduce(configMerge)
|
|
}
|
|
|
|
getDefaults (): Record<string, any> {
|
|
const cleanup = o => {
|
|
if (o instanceof Array) {
|
|
return o.map(cleanup)
|
|
} else if (o instanceof Object) {
|
|
const r = {}
|
|
for (const k of Object.keys(o)) {
|
|
if (k !== '__nonStructural') {
|
|
r[k] = cleanup(o[k])
|
|
}
|
|
}
|
|
return r
|
|
} else {
|
|
return o
|
|
}
|
|
}
|
|
return cleanup(this.defaults)
|
|
}
|
|
|
|
async load (): Promise<void> {
|
|
const content = await this.platform.loadConfig()
|
|
if (content) {
|
|
this._store = yaml.load(content)
|
|
} else {
|
|
this._store = { version: LATEST_VERSION }
|
|
}
|
|
this._store = await this.maybeDecryptConfig(this._store)
|
|
this.migrate(this._store)
|
|
this.store = new ConfigProxy(this._store, this.defaults)
|
|
this.vault.setStore(this.store.vault)
|
|
}
|
|
|
|
async save (): Promise<void> {
|
|
await lastValueFrom(this.ready$)
|
|
if (!this._store) {
|
|
throw new Error('Cannot save an empty store')
|
|
}
|
|
// Scrub undefined values
|
|
let cleanStore = JSON.parse(JSON.stringify(this._store))
|
|
cleanStore = await this.maybeEncryptConfig(cleanStore)
|
|
await this.platform.saveConfig(yaml.dump(cleanStore))
|
|
this.emitChange()
|
|
}
|
|
|
|
/**
|
|
* Reads config YAML as string
|
|
*/
|
|
readRaw (): string {
|
|
// Scrub undefined values
|
|
const cleanStore = JSON.parse(JSON.stringify(this._store))
|
|
return yaml.dump(cleanStore)
|
|
}
|
|
|
|
/**
|
|
* Writes config YAML as string
|
|
*/
|
|
async writeRaw (data: string): Promise<void> {
|
|
this._store = yaml.load(data)
|
|
await this.save()
|
|
await this.load()
|
|
this.emitChange()
|
|
}
|
|
|
|
requestRestart (): void {
|
|
this.restartRequested = true
|
|
}
|
|
|
|
/**
|
|
* Filters a list of Angular services to only include those provided
|
|
* by plugins that are enabled
|
|
*
|
|
* @typeparam T Base provider type
|
|
*/
|
|
enabledServices<T extends object> (services: T[]|undefined): T[] { // eslint-disable-line @typescript-eslint/ban-types
|
|
if (!services) {
|
|
return []
|
|
}
|
|
if (!this.servicesCache) {
|
|
this.servicesCache = {}
|
|
for (const imp of window['pluginModules']) {
|
|
const module = imp.ngModule || imp
|
|
if (module.ɵinj?.providers) {
|
|
this.servicesCache[module.pluginName] = module.ɵinj.providers.map(provider => {
|
|
return provider.useClass ?? provider.useExisting ?? provider
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return services.filter(service => {
|
|
for (const pluginName in this.servicesCache) {
|
|
if (this.servicesCache[pluginName].includes(service.constructor)) {
|
|
const id = `${pluginName}:${service.constructor.name}`
|
|
return !this.store?.pluginBlacklist?.includes(pluginName)
|
|
&& !this.store?.providerBlacklist?.includes(id)
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
private async init () {
|
|
await this.load()
|
|
this.ready.next(true)
|
|
this.ready.complete()
|
|
|
|
this.hostApp.configChangeBroadcast$.subscribe(async () => {
|
|
await this.load()
|
|
this.emitChange()
|
|
})
|
|
}
|
|
|
|
private emitChange (): void {
|
|
this.vault.setStore(this.store.vault)
|
|
this.changed.next()
|
|
}
|
|
|
|
private migrate (config) {
|
|
config.version ??= 0
|
|
if (config.version < 1) {
|
|
for (const connection of config.ssh?.connections ?? []) {
|
|
if (connection.privateKey) {
|
|
connection.privateKeys = [connection.privateKey]
|
|
delete connection.privateKey
|
|
}
|
|
}
|
|
config.version = 1
|
|
}
|
|
if (config.version < 2) {
|
|
config.profiles ??= []
|
|
if (config.terminal?.recoverTabs !== undefined) {
|
|
config.recoverTabs = config.terminal.recoverTabs
|
|
delete config.terminal.recoverTabs
|
|
}
|
|
for (const profile of config.terminal?.profiles ?? []) {
|
|
if (profile.sessionOptions) {
|
|
profile.options = profile.sessionOptions
|
|
delete profile.sessionOptions
|
|
}
|
|
profile.type = 'local'
|
|
profile.id = `local:custom:${uuidv4()}`
|
|
}
|
|
if (config.terminal?.profiles) {
|
|
config.profiles = config.terminal.profiles
|
|
delete config.terminal.profiles
|
|
delete config.terminal.environment
|
|
config.terminal.profile = `local:${config.terminal.profile}`
|
|
}
|
|
config.version = 2
|
|
}
|
|
if (config.version < 3) {
|
|
delete config.ssh?.recentConnections
|
|
for (const c of config.ssh?.connections ?? []) {
|
|
const p = {
|
|
id: `ssh:${uuidv4()}`,
|
|
type: 'ssh',
|
|
icon: 'fas fa-desktop',
|
|
name: c.name,
|
|
group: c.group ?? undefined,
|
|
color: c.color,
|
|
disableDynamicTitle: c.disableDynamicTitle,
|
|
options: c,
|
|
}
|
|
config.profiles.push(p)
|
|
}
|
|
for (const p of config.profiles ?? []) {
|
|
if (p.type === 'ssh') {
|
|
if (p.options.jumpHost) {
|
|
p.options.jumpHost = config.profiles.find(x => x.name === p.options.jumpHost)?.id
|
|
}
|
|
}
|
|
}
|
|
for (const c of config.serial?.connections ?? []) {
|
|
const p = {
|
|
id: `serial:${uuidv4()}`,
|
|
type: 'serial',
|
|
icon: 'fas fa-microchip',
|
|
name: c.name,
|
|
group: c.group ?? undefined,
|
|
color: c.color,
|
|
options: c,
|
|
}
|
|
config.profiles.push(p)
|
|
}
|
|
delete config.ssh?.connections
|
|
delete config.serial?.connections
|
|
delete window.localStorage.lastSerialConnection
|
|
config.version = 3
|
|
}
|
|
if (config.version < 4) {
|
|
for (const p of config.profiles ?? []) {
|
|
if (!p.id) {
|
|
p.id = `${p.type}:custom:${uuidv4()}`
|
|
}
|
|
}
|
|
config.version = 4
|
|
}
|
|
}
|
|
|
|
private async maybeDecryptConfig (store) {
|
|
if (!store.encrypted) {
|
|
return store
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/init-declarations
|
|
let decryptedVault: Vault
|
|
while (true) {
|
|
try {
|
|
const passphrase = await this.vault.getPassphrase()
|
|
decryptedVault = await this.vault.decrypt(store.vault, passphrase)
|
|
break
|
|
} catch (e) {
|
|
let result = await this.platform.showMessageBox({
|
|
type: 'error',
|
|
message: this.translate.instant('Could not decrypt config'),
|
|
detail: e.toString(),
|
|
buttons: [
|
|
this.translate.instant('Try again'),
|
|
this.translate.instant('Erase config'),
|
|
this.translate.instant('Quit'),
|
|
],
|
|
defaultId: 0,
|
|
})
|
|
if (result.response === 2) {
|
|
this.platform.quit()
|
|
}
|
|
if (result.response === 1) {
|
|
result = await this.platform.showMessageBox({
|
|
type: 'warning',
|
|
message: this.translate.instant('Are you sure?'),
|
|
detail: e.toString(),
|
|
buttons: [
|
|
this.translate.instant('Erase config'),
|
|
this.translate.instant('Quit'),
|
|
],
|
|
defaultId: 1,
|
|
cancelId: 1,
|
|
})
|
|
if (result.response === 1) {
|
|
this.platform.quit()
|
|
}
|
|
return {}
|
|
}
|
|
}
|
|
}
|
|
delete decryptedVault.config.vault
|
|
delete decryptedVault.config.encrypted
|
|
delete decryptedVault.config.configSync
|
|
return {
|
|
...decryptedVault.config,
|
|
vault: store.vault,
|
|
encrypted: store.encrypted,
|
|
configSync: store.configSync,
|
|
}
|
|
}
|
|
|
|
private async maybeEncryptConfig (store) {
|
|
if (!store.encrypted) {
|
|
return store
|
|
}
|
|
const vault = await this.vault.load()
|
|
if (!vault) {
|
|
throw new Error('Vault not configured')
|
|
}
|
|
vault.config = { ...store }
|
|
delete vault.config.vault
|
|
delete vault.config.encrypted
|
|
delete vault.config.configSync
|
|
return {
|
|
vault: await this.vault.encrypt(vault),
|
|
encrypted: true,
|
|
configSync: store.configSync,
|
|
}
|
|
}
|
|
}
|