import * as fs from 'fs'; import * as path from 'path'; import * as temp from 'temp'; import * as yaml from 'js-yaml'; import { promisify } from 'util'; import * as grpc from '@grpc/grpc-js'; import * as deepmerge from 'deepmerge'; import { injectable, inject, named } from 'inversify'; import URI from '@theia/core/lib/common/uri'; import { ILogger } from '@theia/core/lib/common/logger'; import { FileUri } from '@theia/core/lib/node/file-uri'; import { Event, Emitter } from '@theia/core/lib/common/event'; import { BackendApplicationContribution } from '@theia/core/lib/node/backend-application'; import { ConfigService, Config, NotificationServiceServer, Network } from '../common/protocol'; import { spawnCommand } from './exec-util'; import { WriteRequest, RawData } from './cli-protocol/settings/settings_pb'; import { SettingsClient } from './cli-protocol/settings/settings_grpc_pb'; import * as serviceGrpcPb from './cli-protocol/settings/settings_grpc_pb'; import { ArduinoDaemonImpl } from './arduino-daemon-impl'; import { DefaultCliConfig, CLI_CONFIG } from './cli-config'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; import { deepClone } from '@theia/core'; const track = temp.track(); @injectable() export class ConfigServiceImpl implements BackendApplicationContribution, ConfigService { @inject(ILogger) @named('config') protected readonly logger: ILogger; @inject(EnvVariablesServer) protected readonly envVariablesServer: EnvVariablesServer; @inject(ArduinoDaemonImpl) protected readonly daemon: ArduinoDaemonImpl; @inject(NotificationServiceServer) protected readonly notificationService: NotificationServiceServer; protected config: Config; protected cliConfig: DefaultCliConfig | undefined; protected ready = new Deferred(); protected readonly configChangeEmitter = new Emitter(); async onStart(): Promise { await this.ensureCliConfigExists(); this.cliConfig = await this.loadCliConfig(); if (this.cliConfig) { const config = await this.mapCliConfigToAppConfig(this.cliConfig); if (config) { this.config = config; this.ready.resolve(); return; } } this.fireInvalidConfig(); } async getCliConfigFileUri(): Promise { const configDirUri = await this.envVariablesServer.getConfigDirUri(); return new URI(configDirUri).resolve(CLI_CONFIG).toString(); } async getConfiguration(): Promise { await this.ready.promise; return this.config; } async setConfiguration(config: Config): Promise { await this.ready.promise; if (Config.sameAs(this.config, config)) { return; } let copyDefaultCliConfig: DefaultCliConfig | undefined = deepClone(this.cliConfig); if (!copyDefaultCliConfig) { copyDefaultCliConfig = await this.getFallbackCliConfig(); } const { additionalUrls, dataDirUri, downloadsDirUri, sketchDirUri, network } = config; copyDefaultCliConfig.directories = { data: FileUri.fsPath(dataDirUri), downloads: FileUri.fsPath(downloadsDirUri), user: FileUri.fsPath(sketchDirUri) }; copyDefaultCliConfig.board_manager = { additional_urls: [ ...additionalUrls ] }; const proxy = Network.stringify(network); copyDefaultCliConfig.network = { proxy }; const { port } = copyDefaultCliConfig.daemon; await this.updateDaemon(port, copyDefaultCliConfig); await this.writeDaemonState(port); this.config = deepClone(config); this.cliConfig = copyDefaultCliConfig; this.fireConfigChanged(this.config); } get cliConfiguration(): DefaultCliConfig | undefined { return this.cliConfig; } get onConfigChange(): Event { return this.configChangeEmitter.event; } async getVersion(): Promise> { return this.daemon.getVersion(); } async isInDataDir(uri: string): Promise { return this.getConfiguration().then(({ dataDirUri }) => new URI(dataDirUri).isEqualOrParent(new URI(uri))); } async isInSketchDir(uri: string): Promise { return this.getConfiguration().then(({ sketchDirUri }) => new URI(sketchDirUri).isEqualOrParent(new URI(uri))); } protected async loadCliConfig(): Promise { const cliConfigFileUri = await this.getCliConfigFileUri(); const cliConfigPath = FileUri.fsPath(cliConfigFileUri); try { const content = await promisify(fs.readFile)(cliConfigPath, { encoding: 'utf8' }); const model = yaml.safeLoad(content) || {}; // The CLI can run with partial (missing `port`, `directories`), the app cannot, we merge the default with the user's config. const fallbackModel = await this.getFallbackCliConfig(); return deepmerge(fallbackModel, model) as DefaultCliConfig; } catch (error) { this.logger.error(`Error occurred when loading CLI config from ${cliConfigPath}.`, error); } return undefined; } protected async getFallbackCliConfig(): Promise { const cliPath = await this.daemon.getExecPath(); const throwawayDirPath = await new Promise((resolve, reject) => { track.mkdir({}, (err, dirPath) => { if (err) { reject(err); return; } resolve(dirPath); }); }); await spawnCommand(`"${cliPath}"`, ['config', 'init', '--dest-dir', `"${throwawayDirPath}"`]); const rawYaml = await promisify(fs.readFile)(path.join(throwawayDirPath, CLI_CONFIG), { encoding: 'utf-8' }); const model = yaml.safeLoad(rawYaml.trim()); return model as DefaultCliConfig; } protected async ensureCliConfigExists(): Promise { const cliConfigFileUri = await this.getCliConfigFileUri(); const cliConfigPath = FileUri.fsPath(cliConfigFileUri); let exists = await promisify(fs.exists)(cliConfigPath); if (!exists) { await this.initCliConfigTo(path.dirname(cliConfigPath)); exists = await promisify(fs.exists)(cliConfigPath); if (!exists) { throw new Error(`Could not initialize the default CLI configuration file at ${cliConfigPath}.`); } } } protected async initCliConfigTo(fsPathToDir: string): Promise { const cliPath = await this.daemon.getExecPath(); await spawnCommand(`"${cliPath}"`, ['config', 'init', '--dest-dir', `"${fsPathToDir}"`]); } protected async mapCliConfigToAppConfig(cliConfig: DefaultCliConfig): Promise { const { directories } = cliConfig; const { data, user, downloads } = directories; const additionalUrls: Array = []; if (cliConfig.board_manager && cliConfig.board_manager.additional_urls) { additionalUrls.push(...Array.from(new Set(cliConfig.board_manager.additional_urls))); } const network = Network.parse(cliConfig.network?.proxy); return { dataDirUri: FileUri.create(data).toString(), sketchDirUri: FileUri.create(user).toString(), downloadsDirUri: FileUri.create(downloads).toString(), additionalUrls, network }; } protected fireConfigChanged(config: Config): void { this.configChangeEmitter.fire(config); this.notificationService.notifyConfigChanged({ config }); } protected fireInvalidConfig(): void { this.notificationService.notifyConfigChanged({ config: undefined }); } protected async updateDaemon(port: string | number, config: DefaultCliConfig): Promise { const client = this.createClient(port); const data = new RawData(); const json = JSON.stringify(config, null, 2); data.setJsondata(json); console.log(`Updating daemon with 'data': ${json}`); return new Promise((resolve, reject) => { client.merge(data, error => { try { if (error) { reject(error); return; } resolve(); } finally { client.close(); } }); }); } protected async writeDaemonState(port: string | number): Promise { const client = this.createClient(port); const req = new WriteRequest(); const cliConfigUri = await this.getCliConfigFileUri(); const cliConfigPath = FileUri.fsPath(cliConfigUri); req.setFilepath(cliConfigPath); return new Promise((resolve, reject) => { client.write(req, error => { try { if (error) { reject(error); return; } resolve(); } finally { client.close(); } }); }); } private createClient(port: string | number): SettingsClient { // https://github.com/agreatfool/grpc_tools_node_protoc_ts/blob/master/doc/grpcjs_support.md#usage // @ts-ignore const SettingsClient = grpc.makeClientConstructor(serviceGrpcPb['cc.arduino.cli.settings.Settings'], 'SettingsService') as any; return new SettingsClient(`localhost:${port}`, grpc.credentials.createInsecure()) as SettingsClient; } }