diff --git a/.gitignore b/.gitignore index 06994923..e24314c5 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ Thumbs.db /.project node_modules + +src/electron/node_modules +src/electron/package-lock.json diff --git a/README.md b/README.md index bfa3679f..306eed5a 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ Although the object is in the global scope, it is not available to applications - OS X - Windows* - Browser +- Electron \* _These platforms do not support `FileReader.readAsArrayBuffer` nor `FileWriter.write(blob)`._ @@ -226,6 +227,21 @@ If interfacing with the external file system is a requirement for your applicati \* The OS may periodically clear this directory +### Electron File System Layout +Varies according to the Operating System. The installation method also has an impact on the file path and where it is installed. The file path will be different if you're running it using the command `cordova run electron --nobuild` (debug) or if you've published to a store and running the program through the app installed from the store. The list can be expanded. + +_Note: The current Linux file paths are tested in Ubuntu and might be different depending on the distribution of linux_ +| Device Path | `cordova.file.*` | r/w? | persistent? | OS clears | private | +|:------------------------------------------------------|:----------------------------|:----:|:-----------:|:---------:|:-------:| +| __Windows:__
_Debug:_ `{cwd}\\platforms\\electron`
_Installer:_`~\\AppData\\Local\\Programs\\{appId}`
__Linux:__
_Debug:_ `{cwd}/platforms/electron`
_Package Installer:_ `/opt/{appName}/resources`
__Mac:__
_Debug:_ `{cwd}/platforms/electron`
_Installer:_`/Applications/{appName.app}/Contents/Resources` | applicationDirectory | r | N/A | N/A | Yes | +| __Windows__:
_Debug:_ `~\\AppData\\Roaming\\Electron\\`
_Installer:_`~\\AppData\\Local\\Programs\\{appId}`
__Linux:__
_Debug:_ `~/.config/Electron/`
_Package Installer:_ `~/.config/{appId}/`
__Mac:__ `~/Library/Application Support/{appId}` | dataDirectory | r/w | Yes | No | Yes | +| __Windows__:
_Debug:_ `~\\AppData\\Roaming\\`
_Installer:_`~\\AppData\\Roaming\\`
__Linux:__
_Debug:_ `~/.cache/`
_Installer:_`~/.cache/`
__Mac:__ `~/Library/Caches` | cacheDirectory | r/w | No | Yes\* | Yes | +| __Windows__:
_Debug:_ `~\\AppData\\Local\\Temp\\`
_Installer:_`~\AppData\\Local\\Temp\\`
__Linux:__
_Debug:_ `/tmp/`
_Package Installer:_ `/tmp/`
__Mac:__ `varies` | tempDirectory | r/w | No | Yes\* | Yes | +| Windows: `~\\Documents`
Linux: `~/Documents/`
Mac: `~/Documents` | documentsDirectory | - | - | - | - | + +\* The OS may periodically clear this directory + + ## Android Quirks ### Android Persistent storage location @@ -386,6 +402,10 @@ should be in the form `filesystem:file:///persistent/somefile.txt` as opposed to - INVALID_MODIFICATION_ERR (code: 9) is thrown instead of NO_MODIFICATION_ALLOWED_ERR(code: 6) on trying to call removeRecursively on the root file system. - INVALID_MODIFICATION_ERR (code: 9) is thrown instead of NOT_FOUND_ERR(code: 1) on trying to moveTo directory that does not exist. +### Electron quirks +- When using `cordova run electron`, the applicationDirectory is the electron platforms directory in the cordova project. i.e. `path/to/your/cordova/code/plaforms/electron/` +- When using a debug version, electron may switch to using the folder `Electron` instead of the folder `{appId}` as your dataDirectory. + ### IndexedDB-based impl quirks (Firefox and IE) - `.` and `..` are not supported. - IE does not support `file:///`-mode; only hosted mode is supported (http://localhost:xxxx). diff --git a/package-lock.json b/package-lock.json index b0f3c353..778a8f57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,8 @@ "cordova-android": ">=6.3.0" }, "7.0.0": { - "cordova-android": ">=10.0.0" + "cordova-android": ">=10.0.0", + "cordova-electron": ">=3.0.0" }, "8.0.0": { "cordova-android": ">=12.0.0" diff --git a/package.json b/package.json index 6ed51ac2..c20d0815 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "platforms": [ "android", "browser", + "electron", "ios", "osx", "windows" @@ -23,7 +24,8 @@ "cordova-browser", "cordova-ios", "cordova-osx", - "cordova-windows" + "cordova-windows", + "cordova-electron" ], "scripts": { "test": "npm run lint", @@ -37,7 +39,8 @@ "cordova-android": ">=6.3.0" }, "7.0.0": { - "cordova-android": ">=10.0.0" + "cordova-android": ">=10.0.0", + "cordova-electron": ">=3.0.0" }, "8.0.0": { "cordova-android": ">=12.0.0" diff --git a/plugin.xml b/plugin.xml index f4a978f7..d542aaec 100644 --- a/plugin.xml +++ b/plugin.xml @@ -264,4 +264,13 @@ to config.xml in order for the application to find previously stored files. + + + + + + + + + diff --git a/src/electron/index.js b/src/electron/index.js new file mode 100644 index 00000000..a654501c --- /dev/null +++ b/src/electron/index.js @@ -0,0 +1,712 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + */ + +const { Buffer } = require('buffer'); +const path = require('path'); +const fs = require('fs-extra'); +const { app } = require('electron'); + +const pathsPrefix = { + applicationDirectory: path.dirname(app.getAppPath()) + path.sep, + dataDirectory: app.getPath('userData') + path.sep, + cacheDirectory: app.getPath('cache') + path.sep, + tempDirectory: app.getPath('temp') + path.sep, + documentsDirectory: app.getPath('documents') + path.sep +}; + +const FileError = { + // Found in DOMException + NOT_FOUND_ERR: 1, + SECURITY_ERR: 2, + ABORT_ERR: 3, + + // Added by File API specification + NOT_READABLE_ERR: 4, + ENCODING_ERR: 5, + NO_MODIFICATION_ALLOWED_ERR: 6, + INVALID_STATE_ERR: 7, + SYNTAX_ERR: 8, + INVALID_MODIFICATION_ERR: 9, + QUOTA_EXCEEDED_ERR: 10, + TYPE_MISMATCH_ERR: 11, + PATH_EXISTS_ERR: 12 + +}; + +/** + * Returns an an object that's converted by cordova to a FileEntry or a DirectoryEntry. + * @param {boolean} isFile - is the object a file or a directory. true for file and false for directory. + * @param {String} name - the name of the file. + * @param {String} fullPath - the full path to the file. + * @param {String} [filesystem = null] - the filesystem. + * @param {String} [nativeURL = null] - the native URL of to the file. + * @returns {Promise} - An object containing Entry information. +*/ +function returnEntry (isFile, name, fullPath, filesystem = null, nativeURL = null) { + return { + isFile, + isDirectory: !isFile, + name, + fullPath, + filesystem, + nativeURL: nativeURL ?? fullPath + }; +} + +module.exports = { + /** + * Read the file contents as text + * + * @param {[fullPath: String]} params + * fullPath - the full path of the directory to read entries from + * @returns {Promise} - An array of Entries in that directory + * + */ + readEntries: function ([fullPath]) { + return new Promise((resolve, reject) => { + fs.readdir(fullPath, { withFileTypes: true }, (err, files) => { + if (err) { + reject(new Error(FileError.NOT_FOUND_ERR)); + return; + } + + const result = []; + + files.forEach(d => { + let absolutePath = fullPath + d.name; + + if (d.isDirectory()) { + absolutePath += path.sep; + } + + result.push({ + isDirectory: d.isDirectory(), + isFile: d.isFile(), + name: d.name, + fullPath: absolutePath, + filesystemName: 'temporary', + nativeURL: absolutePath + }); + }); + + resolve(result); + }); + }); + }, + + /** + * Get the file given the path and fileName. + * + * @param {[dstDir: String, dstName: String, options: Object]} param + * dstDir: The fullPath to the directory the file is in. + * dstName: The filename including the extension. + * options: fileOptions {create: boolean, exclusive: boolean}. + * + * @returns {Promise} - The file object that is converted to FileEntry by cordova. + */ + getFile, + + /** + * get the file Metadata. + * + * @param {[fullPath: String]} param + * fullPath: the full path of the file including the extension. + * @returns {Promise} - An Object containing the file metadata. + */ + getFileMetadata: function ([fullPath]) { + return new Promise((resolve, reject) => { + fs.stat(fullPath, (err, stats) => { + if (err) { + reject(new Error(FileError.NOT_FOUND_ERR)); + return; + } + + resolve({ + name: path.basename(fullPath), + localURL: fullPath, + type: '', + lastModified: stats.mtime, + size: stats.size, + lastModifiedDate: stats.mtime + }); + }); + }); + }, + + /** + * get the file or directory Metadata. + * + * @param {[fullPath: String]} param + * fullPath: the full path of the file or directory. + * @returns {Promise} - An Object containing the metadata. + */ + getMetadata: function ([url]) { + return new Promise((resolve, reject) => { + fs.stat(url, (err, stats) => { + if (err) { + reject(new Error(FileError.NOT_FOUND_ERR)); + return; + } + + resolve({ + modificationTime: stats.mtime, + size: stats.size + }); + }); + }); + }, + + /** + * set the file or directory Metadata. + * + * @param {[fullPath: String, metadataObject: Object]} param + * fullPath: the full path of the file including the extension. + * metadataObject: the object containing metadataValues (currently only supports modificationTime) + * @returns {Promise} - An Object containing the file metadata. + */ + setMetadata: function ([fullPath, metadataObject]) { + return new Promise((resolve, reject) => { + const modificationTime = metadataObject.modificationTime; + const utimesError = function (err) { + if (err) { + reject(new Error(FileError.NOT_FOUND_ERR)); + return; + } + resolve(); + }; + + fs.utimes(fullPath, modificationTime, modificationTime, utimesError); + }); + }, + + /** + * Read the file contents as text + * + * @param {[fileName: String, enc: String, startPos: number, endPos: number]} param + * fileName: The fullPath of the file to be read. + * enc: The encoding to use to read the file. + * startPos: The start position from which to begin reading the file. + * endPos: The end position at which to stop reading the file. + * + * @returns {Promise} The string value within the file. + */ + readAsText: function ([fileName, enc, startPos, endPos]) { + return readAs('text', fileName, enc, startPos, endPos); + }, + + /** + * Read the file as a data URL. + * + * @param {[fileName: String, startPos: number, endPos: number]} param + * fileName: The fullPath of the file to be read. + * startPos: The start position from which to begin reading the file. + * endPos: The end position at which to stop reading the file. + * + * @returns {Promise} the file as a dataUrl. + */ + readAsDataURL: function ([fileName, startPos, endPos]) { + return readAs('dataURL', fileName, null, startPos, endPos); + }, + + /** + * Read the file contents as binary string. + * + * @param {[fileName: String, startPos: number, endPos: number]} param + * fileName: The fullPath of the file to be read. + * startPos: The start position from which to begin reading the file. + * endPos: The end position at which to stop reading the file. + * + * @returns {Promise} The file as a binary string. + */ + readAsBinaryString: function ([fileName, startPos, endPos]) { + return readAs('binaryString', fileName, null, startPos, endPos); + }, + + /** + * Read the file contents as text + * + * @param {[fileName: String, startPos: number, endPos: number]} param + * fileName: The fullPath of the file to be read. + * startPos: The start position from which to begin reading the file. + * endPos: The end position at which to stop reading the file. + * + * @returns {Promise} The file as an arrayBuffer. + */ + readAsArrayBuffer: function ([fileName, startPos, endPos]) { + return readAs('arrayBuffer', fileName, null, startPos, endPos); + }, + + /** + * Remove the file or directory + * + * @param {[fullPath: String]} param + * fullePath: The fullPath of the file or directory. + * + * @returns {Promise} resolves when file or directory is deleted. + */ + remove: function ([fullPath]) { + return new Promise((resolve, reject) => { + fs.stat(fullPath, (err, stats) => { + if (err) { + reject(new Error(FileError.NOT_FOUND_ERR)); + return; + } + if (stats.isDirectory() && fs.readdirSync(fullPath).length !== 0) { + reject(new Error(FileError.INVALID_MODIFICATION_ERR)); + return; + } + fs.remove(fullPath) + .then(() => resolve()) + .catch(() => { + reject(new Error(FileError.NO_MODIFICATION_ALLOWED_ERR)); + }); + }); + }); + }, + + /** + * Remove the file or directory + * + * @param {[fullPath: String]} param + * fullePath: The fullPath of the file or directory. + * + * @returns {Promise} resolves when file or directory is deleted. + */ + removeRecursively: function ([fullPath]) { + return new Promise((resolve, reject) => { + fs.stat(fullPath, (err, stats) => { + if (err) { + reject(new Error(FileError.NOT_FOUND_ERR)); + return; + } + + fs.remove(fullPath, (err) => { + if (err) { + reject(new Error(FileError.NO_MODIFICATION_ALLOWED_ERR)); + return; + } + resolve(); + }); + }); + }); + }, + + /** + * Get the directory given the path and directory name. + * + * @param {[dstDir: String, dstName: String, options: Object]} param + * dstDir: The fullPath to the directory the directory is in. + * dstName: The name of the directory. + * options: options {create: boolean, exclusive: boolean}. + * + * @returns {Promise} The directory object that is converted to DirectoryEntry by cordova. + */ + getDirectory, + + /** + * Get the Parent directory + * + * @param {[url: String]} param + * url: The fullPath to the directory the directory is in. + * + * @returns {Promise} The parent directory object that is converted to DirectoryEntry by cordova. + */ + getParent: function ([url]) { + const parentPath = path.dirname(url); + const parentName = path.basename(parentPath); + const fullPath = path.dirname(parentPath) + path.sep; + + return getDirectory([fullPath, parentName, { create: false }]); + }, + + /** + * Copy File + * + * @param {[srcPath: String, dstDir: String, dstName: String]} param + * srcPath: The fullPath to the file including extension. + * dstDir: The destination directory. + * dstName: The destination file name. + * + * @returns {Promise} The copied file. + */ + copyTo: function ([srcPath, dstDir, dstName]) { + return new Promise((resolve, reject) => { + if (dstName.indexOf('/') !== -1 || path.resolve(srcPath) === path.resolve(dstDir + dstName)) { + reject(new Error(FileError.INVALID_MODIFICATION_ERR)); + return; + } + if (!dstDir || !dstName) { + reject(new Error(FileError.INVALID_MODIFICATION_ERR)); + return; + } + fs.stat(srcPath) + .then((stats) => { + fs.copy(srcPath, dstDir + dstName, { recursive: stats.isDirectory() }) + .then(async () => resolve(await stats.isDirectory() ? getDirectory([dstDir, dstName]) : getFile([dstDir, dstName]))) + .catch(() => reject(new Error(FileError.ENCODING_ERR))); + }) + .catch(() => reject(new Error(FileError.NOT_FOUND_ERR))); + }); + }, + + /** + * Move File. Always Overwrites. + * + * @param {[srcPath: String, dstDir: String, dstName: String]} param + * srcPath: The fullPath to the file including extension. + * dstDir: The destination directory. + * dstName: The destination file name. + * + * @returns {Promise} The moved file. + */ + moveTo: function ([srcPath, dstDir, dstName]) { + return new Promise((resolve, reject) => { + if (dstName.indexOf('/') !== -1 || path.resolve(srcPath) === path.resolve(dstDir + dstName)) { + reject(new Error(FileError.INVALID_MODIFICATION_ERR)); + return; + } + if (!dstDir || !dstName) { + reject(new Error(FileError.INVALID_MODIFICATION_ERR)); + return; + } + fs.stat(srcPath) + .then((stats) => { + fs.move(srcPath, dstDir + dstName) + .then(async () => resolve(await stats.isDirectory() ? getDirectory([dstDir, dstName]) : getFile([dstDir, dstName]))) + .catch(() => reject(new Error(FileError.ENCODING_ERR))); + }) + .catch(() => reject(new Error(FileError.NOT_FOUND_ERR))); + }); + }, + + /** + * resolve the File system URL as a FileEntry or a DirectoryEntry. + * + * @param {[uri: String]} param + * uri: The full path for the file. + * @returns {Promise} The entry for the file or directory. + */ + resolveLocalFileSystemURI: function ([uri]) { + return new Promise((resolve, reject) => { + // support for encodeURI + if (/\%5/g.test(uri) || /\%20/g.test(uri)) { // eslint-disable-line no-useless-escape + uri = decodeURI(uri); + } + + // support for cdvfile + if (uri.trim().substr(0, 7) === 'cdvfile') { + if (uri.indexOf('cdvfile://localhost') === -1) { + reject(new Error(FileError.ENCODING_ERR)); + return; + } + + const indexApplication = uri.indexOf('application'); + const indexPersistent = uri.indexOf('persistent'); + const indexTemporary = uri.indexOf('temporary'); + + if (indexApplication !== -1) { // cdvfile://localhost/application/path/to/file + uri = pathsPrefix.applicationDirectory + uri.substr(indexApplication + 12); + } else if (indexPersistent !== -1) { // cdvfile://localhost/persistent/path/to/file + uri = pathsPrefix.dataDirectory + uri.substr(indexPersistent + 11); + } else if (indexTemporary !== -1) { // cdvfile://localhost/temporary/path/to/file + uri = pathsPrefix.tempDirectory + uri.substr(indexTemporary + 10); + } else { + reject(new Error(FileError.ENCODING_ERR)); + return; + } + } + + fs.stat(uri, (err, stats) => { + if (err) { + reject(new Error(FileError.NOT_FOUND_ERR)); + return; + } + + const baseName = path.basename(uri); + if (stats.isDirectory()) { + // add trailing slash if it is missing + if ((uri) && !/\/$/.test(uri)) { + uri += '/'; + } + + resolve(returnEntry(false, baseName, uri)); + } else { + // remove trailing slash if it is present + if (uri && /\/$/.test(uri)) { + uri = uri.substring(0, uri.length - 1); + } + + resolve(returnEntry(true, baseName, uri)); + } + }); + }); + }, + + /** + * Gets all the path URLs. + * + * @returns {Object} returns an object with all the paths. + */ + requestAllPaths: function () { + return pathsPrefix; + }, + + /** + * Write to a file. + * + * @param {[fileName: String, data: String, position: Number]} param + * fileName: the full path of the file including fileName and extension. + * data: the data to be written to the file. + * position: the position offset to start writing from. + * @returns {Promise} An object with information about the amount of bytes written. + */ + write: function ([fileName, data, position]) { + return new Promise((resolve, reject) => { + if (!data) { + reject(new Error(FileError.INVALID_MODIFICATION_ERR)); + return; + } + + const buf = Buffer.from(data); + let bytesWritten = 0; + + fs.open(fileName, 'a') + .then(fd => { + return fs.write(fd, buf, 0, buf.length, position) + .then(bw => { bytesWritten = bw.bytesWritten; }) + .finally(() => fs.close(fd)); + }) + .then(() => resolve(bytesWritten)) + .catch(() => reject(new Error(FileError.INVALID_MODIFICATION_ERR))); + }); + }, + + /** + * Truncate the file. + * + * @param {[fullPath: String, size: Number]} param + * fullPath: the full path of the file including file extension + * size: the length of the file to truncate to. + * @returns {Promise} + */ + truncate: function ([fullPath, size]) { + return new Promise((resolve, reject) => { + fs.truncate(fullPath, size, err => { + if (err) { + reject(new Error(FileError.INVALID_STATE_ERR)); + return; + } + + resolve(size); + }); + }); + }, + + requestFileSystem: function ([type, size]) { + if (type !== 0 && type !== 1) { + throw new Error(FileError.INVALID_MODIFICATION_ERR); + } + + const name = type === 0 ? 'temporary' : 'persistent'; + return { + name, + root: returnEntry(false, name, '/') + }; + } +}; + +/** * Helpers ***/ + +/** + * Read the file contents as specified. + * + * @param {[what: String, fileName: String, enc: String, startPos: number, endPos: number]} param + * what: what to read the file as. accepts 'text', 'dataURL', 'arrayBuffer' and 'binaryString' + * fileName: The fullPath of the file to be read. + * enc: The encoding to use to read the file. + * startPos: The start position from which to begin reading the file. + * endPos: The end position at which to stop reading the file. + * + * @returns {Promise} The string value within the file. + */ +function readAs (what, fullPath, encoding, startPos, endPos) { + return new Promise((resolve, reject) => { + fs.open(fullPath, 'r', (err, fd) => { + if (err) { + reject(new Error(FileError.NOT_FOUND_ERR)); + return; + } + + const buf = Buffer.alloc(endPos - startPos); + + fs.read(fd, buf, 0, buf.length, startPos) + .then(() => { + switch (what) { + case 'text': + resolve(buf.toString(encoding)); + break; + case 'dataURL': + resolve('data:;base64,' + buf.toString('base64')); + break; + case 'arrayBuffer': + resolve(buf); + break; + case 'binaryString': + resolve(buf.toString('binary')); + break; + } + }) + .catch(() => reject(new Error(FileError.NOT_READABLE_ERR))) + .then(() => fs.close(fd)); + }); + }); +} + +/** + * Get the file given the path and fileName. + * + * @param {[dstDir: String, dstName: String, options: Object]} param + * dstDir: The fullPath to the directory the file is in. + * dstName: The filename including the extension. + * options: fileOptions {create: boolean, exclusive: boolean}. + * + * @returns {Promise} The file object that is converted to FileEntry by cordova. + */ +function getFile ([dstDir, dstName, options]) { + const absolutePath = path.join(dstDir, dstName); + options = options || {}; + return new Promise((resolve, reject) => { + fs.stat(absolutePath, (err, stats) => { + if (err && err.message && err.message.indexOf('ENOENT') !== 0) { + reject(new Error(FileError.INVALID_STATE_ERR)); + return; + } + + const exists = !err; + const baseName = path.basename(absolutePath); + + function createFile () { + fs.open(absolutePath, 'w', (err, fd) => { + if (err) { + reject(new Error(FileError.INVALID_STATE_ERR)); + return; + } + + fs.close(fd, (err) => { + if (err) { + reject(new Error(FileError.INVALID_STATE_ERR)); + return; + } + resolve(returnEntry(true, baseName, absolutePath.replace('\\', '/'))); + }); + }); + } + + if (options.create === true && options.exclusive === true && exists) { + // If create and exclusive are both true, and the path already exists, + // getFile must fail. + reject(new Error(FileError.PATH_EXISTS_ERR)); + } else if (options.create === true && !exists) { + // If create is true, the path doesn't exist, and no other error occurs, + // getFile must create it as a zero-length file and return a corresponding + // FileEntry. + createFile(); + } else if (options.create === true && exists) { + if (stats.isFile()) { + // Overwrite file, delete then create new. + createFile(); + } else { + reject(new Error(FileError.INVALID_MODIFICATION_ERR)); + } + } else if (!options.create && !exists) { + // If create is not true and the path doesn't exist, getFile must fail. + reject(new Error(FileError.NOT_FOUND_ERR)); + } else if (!options.create && exists && stats.isDirectory()) { + // If create is not true and the path exists, but is a directory, getFile + // must fail. + reject(new Error(FileError.TYPE_MISMATCH_ERR)); + } else { + // Otherwise, if no other error occurs, getFile must return a FileEntry + // corresponding to path. + resolve(returnEntry(true, baseName, absolutePath.replace('\\', '/'))); + } + }); + }); +} + +/** + * Get the directory given the path and directory name. + * + * @param {[dstDir: String, dstName: String, options: Object]} param + * dstDir: The fullPath to the directory the directory is in. + * dstName: The name of the directory. + * options: options {create: boolean, exclusive: boolean}. + * + * @returns {Promise} The directory object that is converted to DirectoryEntry by cordova. + */ +function getDirectory ([dstDir, dstName, options]) { + const absolutePath = dstDir + dstName; + options = options || {}; + return new Promise((resolve, reject) => { + fs.stat(absolutePath, (err, stats) => { + if (err && err.message && err.message.indexOf('ENOENT') !== 0) { + reject(new Error(FileError.INVALID_STATE_ERR)); + return; + } + + const exists = !err; + const baseName = path.basename(absolutePath); + if (options.create === true && options.exclusive === true && exists) { + // If create and exclusive are both true, and the path already exists, + // getDirectory must fail. + reject(new Error(FileError.PATH_EXISTS_ERR)); + } else if (options.create === true && !exists) { + // If create is true, the path doesn't exist, and no other error occurs, + // getDirectory must create it as a zero-length file and return a corresponding + // MyDirectoryEntry. + fs.mkdir(absolutePath, (err) => { + if (err) { + reject(new Error(FileError.PATH_EXISTS_ERR)); + return; + } + resolve(returnEntry(false, baseName, absolutePath)); + }); + } else if (options.create === true && exists) { + if (stats.isDirectory()) { + resolve(returnEntry(false, baseName, absolutePath)); + } else { + reject(new Error(FileError.INVALID_MODIFICATION_ERR)); + } + } else if (!options.create && !exists) { + // If create is not true and the path doesn't exist, getDirectory must fail. + reject(new Error(FileError.NOT_FOUND_ERR)); + } else if (!options.create && exists && stats.isFile()) { + // If create is not true and the path exists, but is a file, getDirectory + // must fail. + reject(new Error(FileError.TYPE_MISMATCH_ERR)); + } else { + // Otherwise, if no other error occurs, getDirectory must return a + // DirectoryEntry corresponding to path. + resolve(returnEntry(false, baseName, absolutePath)); + } + }); + }); +} diff --git a/src/electron/package.json b/src/electron/package.json new file mode 100644 index 00000000..e6edf05b --- /dev/null +++ b/src/electron/package.json @@ -0,0 +1,20 @@ +{ + "name": "cordova-plugin-file-electron", + "version": "1.0.0", + "description": "Electron Native Support for Cordova File Plugin", + "main": "index.js", + "keywords": [ + "cordova", + "electron", + "file", + "native" + ], + "author": "Apache Software Foundation", + "license": "Apache-2.0", + "dependencies": { + "fs-extra": "^11.1.0" + }, + "cordova": { + "serviceName": "File" + } +} diff --git a/tests/tests.js b/tests/tests.js index d42dae42..014ebcdf 100644 --- a/tests/tests.js +++ b/tests/tests.js @@ -30,6 +30,7 @@ exports.defineAutoTests = function () { const isIndexedDBShim = isBrowser && !isChrome; // Firefox and IE for example const isWindows = cordova.platformId === 'windows'; + const isElectron = cordova.platformId === 'electron'; /* eslint-enable no-undef */ const MEDIUM_TIMEOUT = 15000; @@ -58,7 +59,10 @@ exports.defineAutoTests = function () { toBeFileError: function () { return { compare: function (error, code) { - const pass = error.code === code; + let pass = error.code === code; + if (isElectron && error && error.code && error.code.message) { + pass = error.code.message.indexOf(code) > -1; + } return { pass, message: 'Expected FileError with code ' + fileErrorMap[error.code] + ' (' + error.code + ') to be ' + fileErrorMap[code] + '(' + code + ')' @@ -266,7 +270,7 @@ exports.defineAutoTests = function () { }); it('file.spec.6 should error if you request a file system that is too large', function (done) { - if (isBrowser) { + if (isBrowser || isElectron) { /* window.requestFileSystem TEMPORARY and PERSISTENT filesystem quota is not limited in Chrome. Firefox filesystem size is not limited but every 50MB request user permission. IE10 allows up to 10mb of combined AppCache and IndexedDB used in implementation @@ -400,6 +404,9 @@ exports.defineAutoTests = function () { }); it('file.spec.10 resolve valid file name with parameters', function (done) { + if (isElectron) { + pending('fs does not take parameters in file'); + } const fileName = 'resolve.file.uri.params'; const win = function (fileEntry) { expect(fileEntry).toBeDefined(); @@ -437,6 +444,8 @@ exports.defineAutoTests = function () { expect(error).toBeDefined(); if (isChrome) { // O.o chrome returns error code 0 + } else if (isElectron) { + // electron returns a not found error with error code 1 } else { expect(error).toBeFileError(FileError.ENCODING_ERR); // eslint-disable-line no-undef } @@ -638,6 +647,10 @@ exports.defineAutoTests = function () { (http://www.w3.org/TR/2011/WD-file-system-api-20110419/#naming-restrictions). */ pending(); } + if (isElectron) { + /* the fs plugin will consider this a valid fileName and return a NOT FOUND ERROR */ + pending(); + } const fileName = 'de:invalid:path'; const fail = function (error) { @@ -847,6 +860,11 @@ exports.defineAutoTests = function () { pending(); } + if (isElectron) { + /* the fs plugin will consider this a valid fileName and return a NOT FOUND ERROR */ + pending(); + } + const dirName = 'de:invalid:path'; const fail = function (error) { expect(error).toBeDefined(); @@ -943,6 +961,10 @@ exports.defineAutoTests = function () { }); it('file.spec.36 removeRecursively on root file system', function (done) { + if (cordova.platformId === 'electron') { + pending('The "root.removeRecursively" method will not be tested in Electron as it would remove the entire source code and cause further tests to fail.'); + return; + } const remove = function (error) { expect(error).toBeDefined(); if (isChrome) { @@ -1288,7 +1310,11 @@ exports.defineAutoTests = function () { }, function (entryFile) { const uri = entryFile.toURL(); expect(uri).toBeDefined(); - expect(uri).toContain('/num%201/num%202/'); + if (isElectron) { + expect(uri).toContain('/num 1/num 2/'); + } else { + expect(uri).toContain('/num%201/num%202/'); + } expect(uri.indexOf(rootPath)).not.toBe(-1); // cleanup deleteEntry(dirName_1, done); @@ -1393,7 +1419,7 @@ exports.defineAutoTests = function () { // remove entry that doesn't exist root.remove(succeed.bind(null, done, 'entry.remove - Unexpected success callback, it should not remove entry that it does not exists'), function (error) { expect(error).toBeDefined(); - if (isChrome) { + if (isChrome || isElectron) { /* INVALID_MODIFICATION_ERR (code: 9) or ??? (code: 13) is thrown instead of NO_MODIFICATION_ALLOWED_ERR(code: 6) on trying to call removeRecursively on the root file system. */ @@ -1618,7 +1644,7 @@ exports.defineAutoTests = function () { if (isChrome) { // chrome returns unknown error with code 13 } else { - expect(error).toBeFileError(FileError.INVALID_MODIFICATION_ERR); // eslint-disable-line no-undef + expect(error).toBeFileError(isElectron ? FileError.ENCODING_ERR : FileError.INVALID_MODIFICATION_ERR); // eslint-disable-line no-undef } root.getDirectory(srcDir, { create: false @@ -1634,6 +1660,10 @@ exports.defineAutoTests = function () { }); it('file.spec.63 copyTo: directory that does not exist', function (done) { + if (isElectron) { + // Electron creates the folder if it doesn't exist + pending(); + } const file1 = 'entry.copy.dnf.file1'; const dirName = 'dir-foo'; createFile(file1, function (fileEntry) { @@ -2019,7 +2049,7 @@ exports.defineAutoTests = function () { if (isChrome) { // chrome returns unknown error with code 13 } else { - expect(error).toBeFileError(FileError.INVALID_MODIFICATION_ERR); // eslint-disable-line no-undef + expect(error).toBeFileError(isElectron ? FileError.ENCODING_ERR : FileError.INVALID_MODIFICATION_ERR); // eslint-disable-line no-undef } // make sure original directory still exists root.getDirectory(srcDir, { @@ -2106,7 +2136,7 @@ exports.defineAutoTests = function () { if (isChrome) { // chrome returns unknown error with code 13 } else { - expect(error).toBeFileError(FileError.INVALID_MODIFICATION_ERR); // eslint-disable-line no-undef + expect(error).toBeFileError(isElectron ? FileError.ENCODING_ERR : FileError.INVALID_MODIFICATION_ERR); // eslint-disable-line no-undef } // check that original dir still exists directory.getDirectory(subDir, { @@ -2155,7 +2185,7 @@ exports.defineAutoTests = function () { if (isChrome) { // chrome returns unknown error with code 13 } else { - expect(error).toBeFileError(FileError.INVALID_MODIFICATION_ERR); // eslint-disable-line no-undef + expect(error).toBeFileError(isElectron ? FileError.ENCODING_ERR : FileError.INVALID_MODIFICATION_ERR); // eslint-disable-line no-undef } // test that original directory exists root.getDirectory(srcDir, { @@ -2203,7 +2233,7 @@ exports.defineAutoTests = function () { if (isChrome) { // chrome returns unknown error with code 13 } else { - expect(error).toBeFileError(FileError.INVALID_MODIFICATION_ERR); // eslint-disable-line no-undef + expect(error).toBeFileError(isElectron ? FileError.ENCODING_ERR : FileError.INVALID_MODIFICATION_ERR); // eslint-disable-line no-undef } // check that original dir still exists root.getDirectory(srcDir, { @@ -2258,7 +2288,7 @@ exports.defineAutoTests = function () { if (isChrome) { // chrome returns unknown error with code 13 } else { - expect(error).toBeFileError(FileError.INVALID_MODIFICATION_ERR); // eslint-disable-line no-undef + expect(error).toBeFileError(isElectron ? FileError.ENCODING_ERR : FileError.INVALID_MODIFICATION_ERR); // eslint-disable-line no-undef } // making sure destination directory still exists directory.getDirectory(subDir, { @@ -2287,6 +2317,9 @@ exports.defineAutoTests = function () { }); it('file.spec.77 moveTo: file replace existing file', function (done) { + if (isElectron) { + pending('Electron throws an error because of file overwrites'); + } const file1 = 'entry.move.frf.file1'; const file2 = 'entry.move.frf.file2'; const file2Path = joinURL(root.fullPath, file2); @@ -2335,6 +2368,9 @@ exports.defineAutoTests = function () { /* `copyTo` and `moveTo` functions do not support directories (Firefox, IE) */ pending(); } + if (isElectron) { + pending('Electron throws an error because of overwrites'); + } const file1 = 'file1'; const srcDir = 'entry.move.drd.srcDir'; @@ -2393,6 +2429,9 @@ exports.defineAutoTests = function () { if (isChrome) { pending('chrome freak out about non-existend dir not being a DirectoryEntry'); } + if (isElectron) { + pending('Electron creates the directory if it doesn\'t exist'); + } const file1 = 'entry.move.dnf.file1'; const dstDir = 'entry.move.dnf.dstDir'; const dstPath = joinURL(root.fullPath, dstDir); @@ -2898,6 +2937,10 @@ exports.defineAutoTests = function () { }); it('file.spec.98 should be able to seek to the middle of the file and write more data than file.length', function (done) { + if (isElectron) { + pending('Electron implements fs-extra for node. This means writing from a particular seek point doesn\'t remove data from the back'); + return; + } const fileName = 'writer.seek.write'; // file content const content = 'This is our sentence.'; // for checking file length const exception = 'newer sentence.'; @@ -2942,6 +2985,10 @@ exports.defineAutoTests = function () { i.e. the length is not being changed from content.length and writer length will be equal 21 */ pending(); } + if (isElectron) { + pending('Electron implements fs-extra for node. This means writing from a particular seek point doesn\'t remove data from the back'); + return; + } const fileName = 'writer.seek.write2'; // file content const content = 'This is our sentence.'; // for checking file length @@ -3450,6 +3497,8 @@ exports.defineAutoTests = function () { pathExpect = 'app://'; } else if (isChrome) { pathExpect = 'filesystem:http://'; + } else if (isElectron) { + pathExpect = '/native'; } it('file.spec.114 fileEntry should have a toNativeURL method', function (done) { @@ -4006,7 +4055,6 @@ exports.defineAutoTests = function () { }, failed.bind(this, done, 'root.getDirectory - Error creating directory : ' + dirName)); }); }); - // Content and Asset URLs if (cordova.platformId === 'android') { // eslint-disable-line no-undef describe('content: URLs', function () {