From 9cdc5e8847e75939332398cf071a7643fb67abe9 Mon Sep 17 00:00:00 2001 From: Geomitron <22552797+Geomitron@users.noreply.github.com> Date: Sun, 17 May 2020 23:30:25 -0400 Subject: [PATCH] Moved filesystem checks before everything else --- src/electron/ipc/download/ChartDownload.ts | 62 ++++----- src/electron/ipc/download/FileDownloader.ts | 18 +-- src/electron/ipc/download/FileExtractor.ts | 2 +- src/electron/ipc/download/FileTransfer.ts | 2 +- .../ipc/download/FilesystemChecker.ts | 120 ++++++++++++++++++ 5 files changed, 155 insertions(+), 49 deletions(-) create mode 100644 src/electron/ipc/download/FilesystemChecker.ts diff --git a/src/electron/ipc/download/ChartDownload.ts b/src/electron/ipc/download/ChartDownload.ts index 3cb8289..20d4aae 100644 --- a/src/electron/ipc/download/ChartDownload.ts +++ b/src/electron/ipc/download/ChartDownload.ts @@ -1,19 +1,15 @@ import { FileDownloader } from './FileDownloader' -import { tempPath } from '../../shared/Paths' import { join } from 'path' import { FileExtractor } from './FileExtractor' import { sanitizeFilename, interpolate } from '../../shared/UtilFunctions' import { emitIPCEvent } from '../../main' import { promisify } from 'util' -import { randomBytes as _randomBytes } from 'crypto' -import { mkdir as _mkdir } from 'fs' import { ProgressType, NewDownload } from 'src/electron/shared/interfaces/download.interface' import { DriveFile } from 'src/electron/shared/interfaces/songDetails.interface' import { FileTransfer } from './FileTransfer' import * as _rimraf from 'rimraf' +import { FilesystemChecker } from './FilesystemChecker' -const randomBytes = promisify(_randomBytes) -const mkdir = promisify(_mkdir) const rimraf = promisify(_rimraf) type EventCallback = { @@ -33,7 +29,7 @@ export class ChartDownload { private callbacks = {} as Callbacks private files: DriveFile[] private percent = 0 // Needs to be stored here because errors won't know the exact percent - private chartPath: string + private tempPath: string private dropFastUpdate = false private readonly individualFileProgressPortion: number @@ -88,7 +84,7 @@ export class ChartDownload { this.cancelFn = undefined cancelFn() try { - rimraf(this.chartPath) // Delete temp folder + rimraf(this.tempPath) // Delete temp folder } catch (e) { /** Do nothing */ } } } @@ -130,18 +126,18 @@ export class ChartDownload { * Starts the download process. */ async beginDownload() { - // CREATE DOWNLOAD DIRECTORY - try { - this.chartPath = await this.createDownloadFolder() - } catch (err) { - this.retryFn = () => this.beginDownload() - this.updateGUI('Access Error', err.message, 'error') - return - } + // CHECK FILESYSTEM ACCESS + const checker = new FilesystemChecker(this.destinationFolderName) + this.cancelFn = () => checker.cancelCheck() + + const checkerComplete = this.addFilesystemCheckerEventListeners(checker) + checker.beginCheck() + await checkerComplete // DOWNLOAD FILES for (let i = 0; i < this.files.length; i++) { - const downloader = new FileDownloader(this.files[i].webContentLink, join(this.chartPath, this.files[i].name)) + if (this.files[i].name == 'ch.dat') { continue } + const downloader = new FileDownloader(this.files[i].webContentLink, join(this.tempPath, this.files[i].name)) this.cancelFn = () => downloader.cancelDownload() const downloadComplete = this.addDownloadEventListeners(downloader, i) @@ -151,7 +147,7 @@ export class ChartDownload { // EXTRACT FILES if (this.isArchive) { - const extractor = new FileExtractor(this.chartPath) + const extractor = new FileExtractor(this.tempPath) this.cancelFn = () => extractor.cancelExtract() const extractComplete = this.addExtractorEventListeners(extractor) @@ -160,7 +156,7 @@ export class ChartDownload { } // TRANSFER FILES - const transfer = new FileTransfer(this.chartPath, this.destinationFolderName) + const transfer = new FileTransfer(this.tempPath, this.destinationFolderName) this.cancelFn = () => transfer.cancelTransfer() const transferComplete = this.addTransferEventListeners(transfer) @@ -171,26 +167,22 @@ export class ChartDownload { } /** - * Attempts to create a unique folder in Bridge's data paths. - * @returns the new folder's path. - * @throws an error if this fails. + * Defines what happens in reponse to `FilesystemChecker` events. + * @returns a `Promise` that resolves when the filesystem has been checked. */ - private async createDownloadFolder() { - let retryCount = 0 - let chartPath = '' + private addFilesystemCheckerEventListeners(checker: FilesystemChecker) { + checker.on('start', () => { + this.updateGUI('Checking filesystem...', '', 'good') + }) - while (retryCount < 5) { - chartPath = join(tempPath, `chart_${(await randomBytes(5)).toString('hex')}`) - try { - await mkdir(chartPath) - return chartPath - } catch (e) { - console.log(`Error creating folder [${chartPath}], retrying with a different folder...`) - retryCount++ - } - } + checker.on('error', this.handleError.bind(this)) - throw new Error(`Bridge was unable to create a directory at [${chartPath}]`) + return new Promise(resolve => { + checker.on('complete', (tempPath) => { + this.tempPath = tempPath + resolve() + }) + }) } /** diff --git a/src/electron/ipc/download/FileDownloader.ts b/src/electron/ipc/download/FileDownloader.ts index 377f4a7..bd3e449 100644 --- a/src/electron/ipc/download/FileDownloader.ts +++ b/src/electron/ipc/download/FileDownloader.ts @@ -3,7 +3,6 @@ import { createWriteStream } from 'fs' import * as needle from 'needle' // TODO: replace needle with got (for cancel() method) (if before-headers event is possible?) // TODO: add download throttle library and setting -import { getSettings } from '../SettingsHandler.ipc' import { googleTimer } from './GoogleTimer' import { DownloadError } from './ChartDownload' @@ -19,7 +18,6 @@ type EventCallback = { type Callbacks = { [E in keyof EventCallback]: EventCallback[E] } const downloadErrors = { - libraryFolder: () => { return { header: 'Library folder not specified', body: 'Please go to the settings to set your library folder.' } }, timeout: (type: string) => { return { header: 'Timeout', body: `The download server could not be reached. (type=${type})` } }, connectionError: (err: Error) => { return { header: 'Connection Error', body: `${err.name}: ${err.message}` } }, responseError: (statusCode: number) => { return { header: 'Connection failed', body: `Server returned status code: ${statusCode}` } }, @@ -57,17 +55,13 @@ export class FileDownloader { * Download the file after waiting for the google rate limit. */ beginDownload() { - if (getSettings().libraryPath == undefined) { - this.failDownload(downloadErrors.libraryFolder()) - } else { - googleTimer.on('waitProgress', this.cancelable((remainingSeconds, totalSeconds) => { - this.callbacks.waitProgress(remainingSeconds, totalSeconds) - })) + googleTimer.on('waitProgress', this.cancelable((remainingSeconds, totalSeconds) => { + this.callbacks.waitProgress(remainingSeconds, totalSeconds) + })) - googleTimer.on('complete', this.cancelable(() => { - this.requestDownload() - })) - } + googleTimer.on('complete', this.cancelable(() => { + this.requestDownload() + })) } /** diff --git a/src/electron/ipc/download/FileExtractor.ts b/src/electron/ipc/download/FileExtractor.ts index b47171e..45c63f7 100644 --- a/src/electron/ipc/download/FileExtractor.ts +++ b/src/electron/ipc/download/FileExtractor.ts @@ -36,7 +36,7 @@ export class FileExtractor { constructor(private sourceFolder: string) { } /** - * Calls `callback` when `event` fires. (no events will be fired after `this.cancelDownload()` is called) + * Calls `callback` when `event` fires. (no events will be fired after `this.cancelExtract()` is called) */ on(event: E, callback: EventCallback[E]) { this.callbacks[event] = callback diff --git a/src/electron/ipc/download/FileTransfer.ts b/src/electron/ipc/download/FileTransfer.ts index 35f0168..4c589c3 100644 --- a/src/electron/ipc/download/FileTransfer.ts +++ b/src/electron/ipc/download/FileTransfer.ts @@ -39,7 +39,7 @@ export class FileTransfer { } /** - * Calls `callback` when `event` fires. (no events will be fired after `this.cancelDownload()` is called) + * Calls `callback` when `event` fires. (no events will be fired after `this.cancelTransfer()` is called) */ on(event: E, callback: EventCallback[E]) { this.callbacks[event] = callback diff --git a/src/electron/ipc/download/FilesystemChecker.ts b/src/electron/ipc/download/FilesystemChecker.ts new file mode 100644 index 0000000..a761847 --- /dev/null +++ b/src/electron/ipc/download/FilesystemChecker.ts @@ -0,0 +1,120 @@ +import { DownloadError } from './ChartDownload' +import { tempPath } from '../../shared/Paths' +import { AnyFunction } from 'src/electron/shared/UtilFunctions' +import { randomBytes as _randomBytes } from 'crypto' +import { mkdir, access, constants } from 'fs' +import { join } from 'path' +import { promisify } from 'util' +import { getSettings } from '../SettingsHandler.ipc' + +const randomBytes = promisify(_randomBytes) + +type EventCallback = { + 'start': () => void + 'error': (err: DownloadError, retry: () => void | Promise) => void + 'complete': (tempPath: string) => void +} +type Callbacks = { [E in keyof EventCallback]: EventCallback[E] } + +const filesystemErrors = { + libraryFolder: () => { return { header: 'Library folder not specified', body: 'Please go to the settings to set your library folder.' } }, + libraryAccess: (err: NodeJS.ErrnoException) => fsError(err, 'Failed to access library folder.'), + destinationFolderExists: (destinationPath: string) => { + return { header: 'This chart already exists in your library folder.', body: destinationPath } + }, + mkdirError: (err: NodeJS.ErrnoException) => fsError(err, 'Failed to create temporary folder.') +} + +function fsError(err: NodeJS.ErrnoException, description: string) { + return { header: description, body: `${err.name}: ${err.message}` } +} + +export class FilesystemChecker { + + private callbacks = {} as Callbacks + private wasCanceled = false + constructor(private destinationFolderName: string) { } + + /** + * Calls `callback` when `event` fires. (no events will be fired after `this.cancelDownload()` is called) + */ + on(event: E, callback: EventCallback[E]) { + this.callbacks[event] = callback + } + + /** + * Check that the filesystem is set up for the download. + */ + beginCheck() { + this.callbacks.start() + this.checkLibraryFolder() + } + + /** + * Verifies that the user has specified a library folder. + */ + private checkLibraryFolder() { + if (getSettings().libraryPath == undefined) { + this.callbacks.error(filesystemErrors.libraryFolder(), () => this.beginCheck()) + } else { + access(getSettings().libraryPath, constants.W_OK, this.cancelable((err) => { + if (err) { + this.callbacks.error(filesystemErrors.libraryAccess(err), () => this.beginCheck()) + } else { + this.checkDestinationFolder() + } + })) + } + } + + /** + * Checks that the destination folder doesn't already exist. + */ + private checkDestinationFolder() { + const destinationPath = join(getSettings().libraryPath, this.destinationFolderName) + access(destinationPath, constants.F_OK, this.cancelable((err) => { + if (err) { // File does not exist + this.createDownloadFolder() + } else { + this.callbacks.error(filesystemErrors.destinationFolderExists(destinationPath), () => this.beginCheck()) + } + })) + } + + /** + * Attempts to create a unique folder in Bridge's data paths. + */ + private async createDownloadFolder(retryCount = 0) { + const tempChartPath = join(tempPath, `chart_${(await randomBytes(5)).toString('hex')}`) + + mkdir(tempChartPath, this.cancelable((err) => { + if (err) { + if (retryCount < 5) { + console.log(`Error creating folder [${tempChartPath}], retrying with a different folder...`) + this.createDownloadFolder(retryCount + 1) + } else { + this.callbacks.error(filesystemErrors.mkdirError(err), () => this.createDownloadFolder()) + } + } else { + this.callbacks.complete(tempChartPath) + } + })) + } + + /** + * Stop the process of checking the filesystem permissions. (no more events will be fired after this is called) + */ + cancelCheck() { + this.wasCanceled = true + } + + /** + * Wraps a function that is able to be prevented if `this.cancelCheck()` was called. + */ + private cancelable(fn: F) { + return (...args: Parameters): ReturnType => { + if (this.wasCanceled) { return } + return fn(...Array.from(args)) + } + } +} \ No newline at end of file