import { injectable, inject } from 'inversify'; import * as minimatch from 'minimatch'; import * as fs from 'fs'; import * as os from 'os'; import * as temp from 'temp'; import * as path from 'path'; import * as crypto from 'crypto'; import { ncp } from 'ncp'; import { promisify } from 'util'; import URI from '@theia/core/lib/common/uri'; import { FileUri } from '@theia/core/lib/node'; import { isWindows } from '@theia/core/lib/common/os'; import { ConfigService } from '../common/protocol/config-service'; import { SketchesService, Sketch, SketchContainer, } from '../common/protocol/sketches-service'; import { firstToLowerCase } from '../common/utils'; import { NotificationServiceServerImpl } from './notification-service-server'; import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; import { CoreClientAware } from './core-client-provider'; import { ArchiveSketchRequest, LoadSketchRequest, } from './cli-protocol/cc/arduino/cli/commands/v1/commands_pb'; const WIN32_DRIVE_REGEXP = /^[a-zA-Z]:\\/; const prefix = '.arduinoIDE-unsaved'; @injectable() export class SketchesServiceImpl extends CoreClientAware implements SketchesService { @inject(ConfigService) protected readonly configService: ConfigService; @inject(NotificationServiceServerImpl) protected readonly notificationService: NotificationServiceServerImpl; @inject(EnvVariablesServer) protected readonly envVariableServer: EnvVariablesServer; async getSketches({ uri, exclude, }: { uri?: string; exclude?: string[]; }): Promise { const start = Date.now(); let sketchbookPath: undefined | string; if (!uri) { const { sketchDirUri } = await this.configService.getConfiguration(); sketchbookPath = FileUri.fsPath(sketchDirUri); if (!(await promisify(fs.exists)(sketchbookPath))) { await promisify(fs.mkdir)(sketchbookPath, { recursive: true }); } } else { sketchbookPath = FileUri.fsPath(uri); } const container: SketchContainerWithDetails = { label: uri ? path.basename(sketchbookPath) : 'Sketchbook', sketches: [], children: [], }; if (!(await promisify(fs.exists)(sketchbookPath))) { return container; } const stat = await promisify(fs.stat)(sketchbookPath); if (!stat.isDirectory()) { return container; } const recursivelyLoad = async ( fsPath: string, containerToLoad: SketchContainerWithDetails ) => { const filenames = await promisify(fs.readdir)(fsPath); for (const name of filenames) { const childFsPath = path.join(fsPath, name); let skip = false; for (const pattern of exclude || [ '**/libraries/**', '**/hardware/**', ]) { if (!skip && minimatch(childFsPath, pattern)) { skip = true; } } if (skip) { continue; } try { const stat = await promisify(fs.stat)(childFsPath); if (stat.isDirectory()) { const sketch = await this._isSketchFolder( FileUri.create(childFsPath).toString() ); if (sketch) { containerToLoad.sketches.push({ ...sketch, mtimeMs: stat.mtimeMs, }); } else { const childContainer: SketchContainerWithDetails = { label: name, children: [], sketches: [], }; await recursivelyLoad(childFsPath, childContainer); if (!SketchContainer.isEmpty(childContainer)) { containerToLoad.children.push(childContainer); } } } } catch { console.warn(`Could not load sketch from ${childFsPath}.`); } } containerToLoad.sketches.sort( (left, right) => right.mtimeMs - left.mtimeMs ); return containerToLoad; }; await recursivelyLoad(sketchbookPath, container); SketchContainer.prune(container); console.debug( `Loading the sketches from ${sketchbookPath} took ${ Date.now() - start } ms.` ); return container; } async loadSketch(uri: string): Promise { await this.coreClientProvider.initialized; const { client, instance } = await this.coreClient(); const req = new LoadSketchRequest(); req.setSketchPath(FileUri.fsPath(uri)); req.setInstance(instance); const sketch = await new Promise((resolve, reject) => { client.loadSketch(req, async (err, resp) => { if (err) { reject(err); return; } const sketchFolderPath = resp.getLocationPath(); const { mtimeMs } = await promisify(fs.lstat)(sketchFolderPath); resolve({ name: path.basename(sketchFolderPath), uri: FileUri.create(sketchFolderPath).toString(), mainFileUri: FileUri.create(resp.getMainFile()).toString(), otherSketchFileUris: resp .getOtherSketchFilesList() .map((p) => FileUri.create(p).toString()), additionalFileUris: resp .getAdditionalFilesList() .map((p) => FileUri.create(p).toString()), rootFolderFileUris: resp .getRootFolderFilesList() .map((p) => FileUri.create(p).toString()), mtimeMs, }); }); }); return sketch; } async maybeLoadSketch(uri: string): Promise { return this._isSketchFolder(uri); } private get recentSketchesFsPath(): Promise { return this.envVariableServer .getConfigDirUri() .then((uri) => path.join(FileUri.fsPath(uri), 'recent-sketches.json')); } private async loadRecentSketches( fsPath: string ): Promise> { let data: Record = {}; try { const raw = await promisify(fs.readFile)(fsPath, { encoding: 'utf8', }); data = JSON.parse(raw); } catch {} return data; } async markAsRecentlyOpened(uri: string): Promise { let sketch: Sketch | undefined = undefined; try { sketch = await this.loadSketch(uri); } catch { return; } if (await this.isTemp(sketch)) { return; } const fsPath = await this.recentSketchesFsPath; const data = await this.loadRecentSketches(fsPath); const now = Date.now(); data[sketch.uri] = now; let toDeleteUri: string | undefined = undefined; if (Object.keys(data).length > 10) { let min = Number.MAX_SAFE_INTEGER; for (const uri of Object.keys(data)) { if (min > data[uri]) { min = data[uri]; toDeleteUri = uri; } } } if (toDeleteUri) { delete data[toDeleteUri]; } await promisify(fs.writeFile)(fsPath, JSON.stringify(data, null, 2)); this.recentlyOpenedSketches().then((sketches) => this.notificationService.notifyRecentSketchesChanged({ sketches }) ); } async recentlyOpenedSketches(): Promise { const configDirUri = await this.envVariableServer.getConfigDirUri(); const fsPath = path.join( FileUri.fsPath(configDirUri), 'recent-sketches.json' ); let data: Record = {}; try { const raw = await promisify(fs.readFile)(fsPath, { encoding: 'utf8', }); data = JSON.parse(raw); } catch {} const sketches: SketchWithDetails[] = []; for (const uri of Object.keys(data).sort( (left, right) => data[right] - data[left] )) { try { const sketch = await this.loadSketch(uri); sketches.push(sketch); } catch {} } return sketches; } async cloneExample(uri: string): Promise { const sketch = await this.loadSketch(uri); const parentPath = await new Promise((resolve, reject) => { temp.mkdir({ prefix }, (err, dirPath) => { if (err) { reject(err); return; } resolve(dirPath); }); }); const destinationUri = FileUri.create( path.join(parentPath, sketch.name) ).toString(); const copiedSketchUri = await this.copy(sketch, { destinationUri }); return this.loadSketch(copiedSketchUri); } async createNewSketch(): Promise { const monthNames = [ 'jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec', ]; const today = new Date(); const parentPath = await new Promise((resolve, reject) => { temp.mkdir({ prefix }, (err, dirPath) => { if (err) { reject(err); return; } resolve(dirPath); }); }); const sketchBaseName = `sketch_${ monthNames[today.getMonth()] }${today.getDate()}`; const config = await this.configService.getConfiguration(); const user = FileUri.fsPath(config.sketchDirUri); let sketchName: string | undefined; for (let i = 97; i < 97 + 26; i++) { const sketchNameCandidate = `${sketchBaseName}${String.fromCharCode(i)}`; // Note: we check the future destination folder (`directories.user`) for name collision and not the temp folder! if (await promisify(fs.exists)(path.join(user, sketchNameCandidate))) { continue; } sketchName = sketchNameCandidate; break; } if (!sketchName) { throw new Error('Cannot create a unique sketch name'); } const sketchDir = path.join(parentPath, sketchName); const sketchFile = path.join(sketchDir, `${sketchName}.ino`); await promisify(fs.mkdir)(sketchDir, { recursive: true }); await promisify(fs.writeFile)( sketchFile, `void setup() { // put your setup code here, to run once: } void loop() { // put your main code here, to run repeatedly: } `, { encoding: 'utf8' } ); return this.loadSketch(FileUri.create(sketchDir).toString()); } async getSketchFolder(uri: string): Promise { if (!uri) { return undefined; } let currentUri = new URI(uri); while (currentUri && !currentUri.path.isRoot) { const sketch = await this._isSketchFolder(currentUri.toString()); if (sketch) { return sketch; } currentUri = currentUri.parent; } return undefined; } async isSketchFolder(uri: string): Promise { const sketch = await this._isSketchFolder(uri); return !!sketch; } private async _isSketchFolder( uri: string ): Promise { const fsPath = FileUri.fsPath(uri); let stat: fs.Stats | undefined; try { stat = await promisify(fs.lstat)(fsPath); } catch {} if (stat && stat.isDirectory()) { const basename = path.basename(fsPath); const files = await promisify(fs.readdir)(fsPath); for (let i = 0; i < files.length; i++) { if (files[i] === basename + '.ino' || files[i] === basename + '.pde') { try { const sketch = await this.loadSketch( FileUri.create(fsPath).toString() ); return sketch; } catch {} } } } return undefined; } async isTemp(sketch: Sketch): Promise { let sketchPath = FileUri.fsPath(sketch.uri); let temp = await promisify(fs.realpath)(os.tmpdir()); // Note: VS Code URI normalizes the drive letter. `C:` will be converted into `c:`. // https://github.com/Microsoft/vscode/issues/68325#issuecomment-462239992 if (isWindows) { if (WIN32_DRIVE_REGEXP.exec(sketchPath)) { sketchPath = firstToLowerCase(sketchPath); } if (WIN32_DRIVE_REGEXP.exec(temp)) { temp = firstToLowerCase(temp); } } return sketchPath.indexOf(prefix) !== -1 && sketchPath.startsWith(temp); } async copy( sketch: Sketch, { destinationUri }: { destinationUri: string } ): Promise { const source = FileUri.fsPath(sketch.uri); const exists = await promisify(fs.exists)(source); if (!exists) { throw new Error(`Sketch does not exist: ${sketch}`); } // Nothing to do when source and destination are the same. if (sketch.uri === destinationUri) { await this.loadSketch(sketch.uri); // Sanity check. return sketch.uri; } const copy = async (sourcePath: string, destinationPath: string) => { return new Promise((resolve, reject) => { ncp.ncp(sourcePath, destinationPath, async (error) => { if (error) { reject(error); return; } const newName = path.basename(destinationPath); try { const oldPath = path.join( destinationPath, new URI(sketch.mainFileUri).path.base ); const newPath = path.join(destinationPath, `${newName}.ino`); if (oldPath !== newPath) { await promisify(fs.rename)(oldPath, newPath); } await this.loadSketch(FileUri.create(destinationPath).toString()); // Sanity check. resolve(); } catch (e) { reject(e); } }); }); }; // https://github.com/arduino/arduino-ide/issues/65 // When copying `/path/to/sketchbook/sketch_A` to `/path/to/sketchbook/sketch_A/anything` on a non-POSIX filesystem, // `ncp` makes a recursion and copies the folders over and over again. In such cases, we copy the source into a temp folder, // then move it to the desired destination. const destination = FileUri.fsPath(destinationUri); let tempDestination = await new Promise((resolve, reject) => { temp.track().mkdir({ prefix }, async (err, dirPath) => { if (err) { reject(err); return; } resolve(dirPath); }); }); tempDestination = path.join(tempDestination, sketch.name); await fs.promises.mkdir(tempDestination, { recursive: true }); await copy(source, tempDestination); await copy(tempDestination, destination); return FileUri.create(destination).toString(); } async archive(sketch: Sketch, destinationUri: string): Promise { await this.coreClientProvider.initialized; await this.loadSketch(sketch.uri); // sanity check const { client } = await this.coreClient(); const archivePath = FileUri.fsPath(destinationUri); // The CLI cannot override existing archives, so we have to wipe it manually: https://github.com/arduino/arduino-cli/issues/1160 if (await promisify(fs.exists)(archivePath)) { await promisify(fs.unlink)(archivePath); } const req = new ArchiveSketchRequest(); req.setSketchPath(FileUri.fsPath(sketch.uri)); req.setArchivePath(archivePath); await new Promise((resolve, reject) => { client.archiveSketch(req, (err) => { if (err) { reject(err); return; } resolve(destinationUri); }); }); return destinationUri; } async getIdeTempFolderUri(sketch: Sketch): Promise { const genBuildPath = await this.getIdeTempFolderPath(sketch); return FileUri.create(genBuildPath).toString(); } async getIdeTempFolderPath(sketch: Sketch): Promise { const sketchPath = FileUri.fsPath(sketch.uri); await fs.promises.readdir(sketchPath); // Validates the sketch folder and rejects if not accessible. const suffix = crypto.createHash('md5').update(sketchPath).digest('hex'); return path.join(os.tmpdir(), `arduino-ide2-${suffix}`); } } interface SketchWithDetails extends Sketch { readonly mtimeMs: number; } interface SketchContainerWithDetails extends SketchContainer { readonly label: string; readonly children: SketchContainerWithDetails[]; readonly sketches: SketchWithDetails[]; }