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, defaults: Record) { 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 { return this.ready } private ready = new AsyncSubject() private changed = new Subject() private _store: any private defaults: any private servicesCache: Record|null = null // eslint-disable-line @typescript-eslint/ban-types get changed$ (): Observable { 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 { 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 { 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 { 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 { 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 (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, } } }