// Copyright (c) 2015-2016 Yuya Ochiai // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import fs from 'fs'; import os from 'os'; import path from 'path'; import {EventEmitter} from 'events'; import { AnyConfig, BuildConfig, CombinedConfig, ConfigServer, Config as ConfigType, RegistryConfig as RegistryConfigType, } from 'types/config'; import {Logger} from 'common/log'; import {getDefaultViewsForConfigServer} from 'common/views/View'; import Utils, {copy} from 'common/utils/util'; import * as Validator from 'common/Validator'; import defaultPreferences, {getDefaultDownloadLocation} from './defaultPreferences'; import upgradeConfigData from './upgradePreferences'; import buildConfig from './buildConfig'; import RegistryConfig, {REGISTRY_READ_EVENT} from './RegistryConfig'; import migrateConfigItems from './migrationPreferences'; const log = new Logger('Config'); export class Config extends EventEmitter { private configFilePath?: string; private appName?: string; private appPath?: string; private registryConfig: RegistryConfig; private _predefinedServers: ConfigServer[]; private useNativeWindow: boolean; private combinedData?: CombinedConfig; private localConfigData?: ConfigType; private registryConfigData?: Partial; private defaultConfigData?: ConfigType; private buildConfigData?: BuildConfig; private canUpgradeValue?: boolean; constructor() { super(); this.registryConfig = new RegistryConfig(); this._predefinedServers = []; if (buildConfig.defaultServers) { this._predefinedServers.push(...buildConfig.defaultServers.map((server, index) => getDefaultViewsForConfigServer({...server, order: index}))); } try { this.useNativeWindow = os.platform() === 'win32' && !Utils.isVersionGreaterThanOrEqualTo(os.release(), '6.2'); } catch { this.useNativeWindow = false; } } init = (configFilePath: string, appName: string, appPath: string) => { this.configFilePath = configFilePath; this.appName = appName; this.appPath = appPath; this.canUpgradeValue = this.checkWriteableApp(); this.reload(); } initRegistry = () => { if (process.platform !== 'win32') { return Promise.resolve(); } return new Promise((resolve) => { this.registryConfig = new RegistryConfig(); this.registryConfig.once(REGISTRY_READ_EVENT, (data) => { this.onLoadRegistry(data); resolve(); }); this.registryConfig.init(); }); } /** * Reload all sources of config data * * @param {boolean} synchronize determines whether or not to emit a synchronize event once config has been reloaded * @emits {update} emitted once all data has been loaded and merged * @emits {synchronize} emitted when requested by a call to method; used to notify other config instances of changes */ reload = (): void => { this.defaultConfigData = copy(defaultPreferences); this.buildConfigData = copy(buildConfig); const loadedConfig = this.loadLocalConfigFile(); this.localConfigData = this.checkForConfigUpdates(loadedConfig); this.regenerateCombinedConfigData(); this.emit('update', this.combinedData); } /********************* * Setters and Getters *********************/ /** * Used to save a single config property * * @param {string} key name of config property to be saved * @param {*} data value to save for provided key */ set = (key: keyof ConfigType, data: ConfigType[keyof ConfigType]): void => { log.debug('set'); this.setMultiple({[key]: data}); } setConfigPath = (configPath: string) => { this.configFilePath = configPath; } /** * Used to save an array of config properties in one go * * @param {array} properties an array of config properties to save */ setMultiple = (newData: Partial) => { log.debug('setMultiple', newData); if (newData.darkMode && newData.darkMode !== this.darkMode) { this.emit('darkModeChange', newData.darkMode); } this.localConfigData = Object.assign({}, this.localConfigData, {...newData, teams: this.localConfigData?.teams}); this.regenerateCombinedConfigData(); this.saveLocalConfigData(); } setServers = (servers: ConfigServer[], lastActiveServer?: number) => { log.debug('setServers', servers, lastActiveServer); this.localConfigData = Object.assign({}, this.localConfigData, {teams: servers, lastActiveTeam: lastActiveServer ?? this.localConfigData?.lastActiveTeam}); this.regenerateCombinedConfigData(); this.saveLocalConfigData(); } // getters for accessing the various config data inputs get data() { return this.combinedData; } get localData() { return this.localConfigData ?? defaultPreferences; } get defaultData() { return this.defaultConfigData ?? defaultPreferences; } get buildData() { return this.buildConfigData ?? buildConfig; } get registryData() { return this.registryConfigData; } // convenience getters get version() { return this.combinedData?.version ?? defaultPreferences.version; } get darkMode() { return this.combinedData?.darkMode ?? defaultPreferences.darkMode; } get localServers() { return this.localConfigData?.teams ?? defaultPreferences.teams; } get predefinedServers() { return this._predefinedServers; } get enableHardwareAcceleration() { return this.combinedData?.enableHardwareAcceleration ?? defaultPreferences.enableHardwareAcceleration; } get startInFullscreen() { return this.combinedData?.startInFullscreen ?? defaultPreferences.startInFullscreen; } get enableServerManagement() { return this.combinedData?.enableServerManagement ?? buildConfig.enableServerManagement; } get enableAutoUpdater() { return this.combinedData?.enableAutoUpdater ?? buildConfig.enableAutoUpdater; } get autostart() { return this.combinedData?.autostart ?? defaultPreferences.autostart; } get hideOnStart() { return this.combinedData?.hideOnStart ?? defaultPreferences.hideOnStart; } get notifications() { return this.combinedData?.notifications ?? defaultPreferences.notifications; } get showUnreadBadge() { return this.combinedData?.showUnreadBadge ?? defaultPreferences.showUnreadBadge; } get useSpellChecker() { return this.combinedData?.useSpellChecker ?? defaultPreferences.useSpellChecker; } get spellCheckerURL(): (string|undefined) { return this.combinedData?.spellCheckerURL; } get spellCheckerLocales() { return this.combinedData?.spellCheckerLocales ?? defaultPreferences.spellCheckerLocales; } get showTrayIcon() { return this.combinedData?.showTrayIcon ?? defaultPreferences.showTrayIcon; } get trayIconTheme() { return this.combinedData?.trayIconTheme ?? defaultPreferences.trayIconTheme; } get downloadLocation() { return this.combinedData?.downloadLocation ?? getDefaultDownloadLocation(); } get helpLink() { return this.combinedData?.helpLink; } get minimizeToTray() { return this.combinedData?.minimizeToTray; } get lastActiveServer() { return this.combinedData?.lastActiveTeam; } get alwaysClose() { return this.combinedData?.alwaysClose; } get alwaysMinimize() { return this.combinedData?.alwaysMinimize; } get canUpgrade() { return process.env.NODE_ENV === 'test' || (this.canUpgradeValue && this.buildConfigData?.enableAutoUpdater && !(process.platform === 'linux' && !process.env.APPIMAGE) && !(process.platform === 'win32' && this.registryConfigData?.enableAutoUpdater === false)); } get autoCheckForUpdates() { return this.combinedData?.autoCheckForUpdates; } get appLanguage() { return this.combinedData?.appLanguage; } /** * Gets the servers from registry into the config object and reload * * @param {object} registryData Server configuration from the registry and if servers can be managed by user */ private onLoadRegistry = (registryData: Partial): void => { log.debug('loadRegistry', {registryData}); this.registryConfigData = registryData; if (this.registryConfigData.servers) { this._predefinedServers.push(...this.registryConfigData.servers.map((server, index) => getDefaultViewsForConfigServer({...server, order: index}))); } this.reload(); } /** * Config file loading methods */ /** * Used to save the current set of local config data to disk * * @emits {update} emitted once all data has been saved * @emits {synchronize} emitted once all data has been saved; used to notify other config instances of changes * @emits {error} emitted if saving local config data to file fails */ private saveLocalConfigData = (): void => { if (!(this.configFilePath && this.localConfigData)) { return; } log.verbose('Saving config data to file...'); try { this.writeFile(this.configFilePath, this.localConfigData, (error: NodeJS.ErrnoException | null) => { if (error) { if (error.code === 'EBUSY') { this.saveLocalConfigData(); } else { this.emit('error', error); } } this.emit('update', this.combinedData); }); } catch (error) { this.emit('error', error); } } /** * Loads and returns locally stored config data from the filesystem or returns app defaults if no file is found */ private loadLocalConfigFile = (): AnyConfig => { if (!this.configFilePath) { throw new Error('Unable to read from config, no path specified'); } let configData: AnyConfig; try { configData = JSON.parse(fs.readFileSync(this.configFilePath, 'utf8')); // validate based on config file version configData = Validator.validateConfigData(configData); if (!configData) { throw new Error('Provided configuration file does not validate, using defaults instead.'); } } catch (e) { log.warn('Failed to load configuration file from the filesystem. Using defaults.'); configData = copy(this.defaultConfigData); this.writeFile(this.configFilePath, configData); } return configData; } /** * Determines if locally stored data needs to be updated and upgrades as needed * * @param {*} data locally stored data */ private checkForConfigUpdates = (data: AnyConfig) => { if (!this.configFilePath) { throw new Error('Config not initialized'); } let configData = data; if (this.defaultConfigData) { try { if (configData.version !== this.defaultConfigData.version) { configData = upgradeConfigData(configData); this.writeFile(this.configFilePath, configData); log.info(`Configuration updated to version ${this.defaultConfigData.version} successfully.`); } const didMigrate = migrateConfigItems(configData); if (didMigrate) { this.writeFile(this.configFilePath, configData); log.info('Migrating config items successfully.'); } } catch (error) { log.error(`Failed to update configuration to version ${this.defaultConfigData.version}.`); } } return configData as ConfigType; } /** * Properly combines all sources of data into a single, manageable set of all config data */ private regenerateCombinedConfigData = () => { if (!this.appName) { throw new Error('Config not initialized, cannot regenerate'); } // combine all config data in the correct order this.combinedData = Object.assign({}, this.defaultConfigData, this.localConfigData, this.buildConfigData, this.registryConfigData, {useNativeWindow: this.useNativeWindow}, ); // We don't want to include the servers in the combined config, they should only be accesible via the ServerManager delete (this.combinedData as any).teams; delete (this.combinedData as any).servers; delete (this.combinedData as any).defaultServers; if (this.combinedData) { this.combinedData.appName = this.appName; } } // helper functions private writeFile = (filePath: string, configData: Partial, callback?: fs.NoParamCallback) => { if (!this.defaultConfigData) { return; } if (configData.version !== this.defaultConfigData.version) { throw new Error('version ' + configData.version + ' is not equal to ' + this.defaultConfigData.version); } const json = JSON.stringify(configData, null, ' '); if (callback) { fs.writeFile(filePath, json, 'utf8', callback); } else { const dir = path.dirname(filePath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir); } fs.writeFileSync(filePath, json, 'utf8'); } } private checkWriteableApp = () => { if (!this.appPath) { throw new Error('Config not initialized, cannot regenerate'); } if (process.platform === 'win32') { try { fs.accessSync(path.join(path.dirname(this.appPath), '../../'), fs.constants.W_OK); // check to make sure that app-update.yml exists if (!fs.existsSync(path.join(process.resourcesPath, 'app-update.yml'))) { log.warn('app-update.yml does not exist, disabling auto-updates'); return false; } } catch (error) { log.info(`${this.appPath}: ${error}`); log.warn('autoupgrade disabled'); return false; } // eslint-disable-next-line no-undef // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore return __CAN_UPGRADE__; // prevent showing the option if the path is not writeable, like in a managed environment. } // temporarily disabling auto updater for macOS due to security issues // eslint-disable-next-line no-undef // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore return process.platform !== 'darwin' && __CAN_UPGRADE__; } } const config = new Config(); export default config;