diff --git a/README.md b/README.md index 72ada51..a13929b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ const updater = new Updater(nw.App.manifest); let updateStatus = ""; let newManifest = ""; +let downloadedFilePath = ""; + +// Check for new version via current running application. updater.checkNewVersion((err, newerVersionExists, remoteManifest) => { if (err) { updateStatus = `Error checking for updates: ${err.message}`; @@ -33,6 +36,7 @@ updater.checkNewVersion((err, newerVersionExists, remoteManifest) => { } }); +// Download to temporary directory if new version is available. let downloadStatus = ""; updater.download((err, filePath) => { if (err) { @@ -40,84 +44,29 @@ updater.download((err, filePath) => { return; } downloadStatus = "Update downloaded successfully at " + filePath; + downloadedFilePath = filePath; }, newManifest); -``` - -It gives you low-level API to: - -1. Check the manifest for version (from your running "old" app). -2. If the version is different from the running one, download new package to a temp directory. -3. Unpack the package in temp. -4. Run new app from temp and kill the old one (i.e. still all from the running app). -5. The new app (in temp) will copy itself to the original folder, overwriting the old app. -6. The new app will run itself from original folder and exit the process. - -## API - - - -#### new updater(manifest, options) - -Creates new instance of updater. Manifest could be a `package.json` of project. - -Note that compressed apps are assumed to be downloaded in the format produced by [nw-builder](https://github.com/nwutils/nw-builder) (or [grunt-nw-builder](https://github.com/nwjs/grunt-nw-builder)). - -**Params** - -- manifest `object` - See the [manifest schema](#manifest-schema) below. -- options `object` - Optional - - - -#### updater.checkNewVersion(cb) - -Will check the latest available version of the application by requesting the manifest specified in `manifestUrl`. - -The callback will always be called; the second parameter indicates whether or not there's a newer version. -This function assumes you use [Semantic Versioning](http://semver.org) and enforces it; if your local version is `0.2.0` and the remote one is `0.1.23456` then the callback will be called with `false` as the second paramter. If on the off chance you don't use semantic versioning, you could manually download the remote manifest and call `download` if you're happy that the remote version is newer. - -**Params** - -- cb `function` - Callback arguments: error, newerVersionExists (`Boolean`), remoteManifest - +// Unpack the application in the temporary directory +updater.unpack(); -#### updater.download(cb, newManifest) +// Run the new application from the temporary directory and kill the old one -Downloads the new app to a temporary folder. +// The new application will copy itself from the temporary directory to the directory where the previous application was running. -**Params** - -- cb `function` - called when download completes. Callback arguments: error, downloaded filepath -- newManifest `Object` - see [manifest schema](#manifest-schema) below - -**Returns**: `Request` - Request - stream, the stream contains `manifest` property with new manifest and 'content-length' property with the size of package. - - -#### updater.getAppPath() - -Returns executed application path - -**Returns**: `string` - - -#### updater.getAppExec() - -Returns current application executable - -**Returns**: `string` - - -#### updater.unpack(filename, cb, manifest) - -Will unpack the `filename` in temporary folder. -For Windows, [unzip](https://www.mkssoftware.com/docs/man1/unzip.1.asp) is used (which is [not signed](https://github.com/edjafarov/node-webkit-updater/issues/68)). +// The new application will run itself from the original directory and exit the process. +``` -**Params** +## API Schema -- filename `string` -- cb `function` - Callback arguments: error, unpacked directory -- manifest `object` +| Method | Arguments | Return Type | Description | +| ------ | --------- | ----------- | ----------- | +| new Updater | `manifest: object, options: object \| undefined` | `void` | Creates a new instance of Updater. See the [manifest schema](#manifest-schema) below. | +| checkNewVersion | `cb: (error: Error, newerVersionExists: boolean, remoteManifest: object) => void` | `void` | Checks the latest version of the application by requesting manifest at `manifestUrl`. Semantic versioning is used when comparing versions. | +| download | `cb: (error: Error, filepath: string) => void, newManifest: object` | `void` | Checks the latest version of the application by requesting manifest at `manifestUrl`. Downloads the new app to a temporary folder. | +| getAppPath | | `string` | Returns the executed application path. | +| getAppExec | | `string` | Returns the current application path. | +| unpack | `filename: string, cb: (error: Error, unpackedDir: string) => void, manifest: object` | `string` | Returns the executed application path. | @@ -161,29 +110,29 @@ Note: if this doesn't work, try `gui.Shell.openItem(execPath)` (see [node-webkit ## Manifest Schema -An example manifest: +Example usage: ```json { - "name": "updapp", - "version": "0.0.2", - "author": "Eldar Djafarov ", - "manifestUrl": "http://localhost:3000/package.json", + "name": "demo", + "version": "0.0.1", + "author": "NW.js Utils ", + "manifestUrl": "http://localhost:3000/manifest.json", "packages": { - "mac": { - "url": "http://localhost:3000/releases/updapp/mac/updapp.zip" + "linux-x64": { + "url": "http://localhost:3000/demo-0.0.1-linux-x64.zip" }, - "win": { - "url": "http://localhost:3000/releases/updapp/win/updapp.zip" + "osx-arm64": { + "url": "http://localhost:3000/demo-0.0.1-osx-arm64.zip" + }, + "win-x64": { + "url": "http://localhost:3000/demo-0.0.1-win-x64.zip" }, - "linux32": { - "url": "http://localhost:3000/releases/updapp/linux32/updapp.tar.gz" - } } } ``` -The manifest could be a `package.json` of project, but doesn't have to be. +> Note: The manifest could be a `package.json` of project, but doesn't have to be. ### manifest.name @@ -211,18 +160,14 @@ It's assumed your app is stored at the root of your package, use this to overrid This can also be used to override `manifest.name`; e.g. if your `manifest.name` is `helloWorld` (therefore `helloWorld.app` on Mac) but your Windows executable is named `nw.exe`. Then you'd set `execPath` to `nw.exe` ---- - -## Troubleshooting - -### Mac - -If you get an error on Mac about too many files being open, run `ulimit -n 10240` +## Contributing -### Windows +### External contributor -On Windows, there is no "unzip" command built in by default. As a result, this project uses a third party "unzip.exe" in order to extract the downloaded update. On the NWJS site, in the "How to package and distribute your apps" file, one of the recommended methods of distribution is using EnigmaVirtualBox to package the app, nw.exe, and required DLLs into a single EXE file. This method works great for distribution, but unfortunately breaks node-webkit-updater, because it wraps the required unzip.exe file inside of the created EnigmaVirtualBox EXE. As a result, *it is not possible to use EnigmaVirtualBox to distribute your app if you plan on using node-webkit-updater*. Try using InnoSetup instead. +- Use Node.js standard libraries whenever possible. +- Prefer to use syncronous APIs over modern APIs which have been introduced in later versions. -## Contributing +### Maintainer -See [CONTRIBUTING.md](CONTRIBUTING.md) +- npm trusted publishing is used for releases +- a package is released when a maintainer creates a release note for a specific version diff --git a/package-lock.json b/package-lock.json index 7606aa8..3ee9c24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,11 +15,13 @@ "semver": "^7.6.2" }, "devDependencies": { + "@types/chrome": "^0.1.42", "@types/del": "^3.0.1", "@types/ncp": "^2.0.8", "@types/node": "^25.6.2", "@types/nw.js": "^0.92.0", "@types/semver": "^7.7.1", + "@types/yauzl-promise": "^4.0.1", "express": "^5.2.1", "get-port": "^7.2.0", "nw-builder": "^4.17.10", @@ -565,6 +567,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chrome": { + "version": "0.1.42", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.1.42.tgz", + "integrity": "sha512-tdT2roFqGecZZDjA9fUEAINb2STxSPifHMDvY6EfRjNRCjdrs/0FwKt5RCIA9MKMd1arAYZZL3nwEkp6ZLZu2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, "node_modules/@types/del": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/del/-/del-3.0.1.tgz", @@ -575,6 +588,23 @@ "@types/glob": "*" } }, + "node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/glob": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-8.1.0.tgz", @@ -586,6 +616,13 @@ "@types/node": "*" } }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/minimatch": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", @@ -630,6 +667,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/yauzl-promise": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/yauzl-promise/-/yauzl-promise-4.0.1.tgz", + "integrity": "sha512-qYEC3rJwqiJpdQ9b+bPNeuSY0c3JUM8vIuDy08qfuVN7xHm3ZDsHn2kGphUIB0ruEXrPGNXZ64nMUcu4fDjViQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.9.10", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", diff --git a/package.json b/package.json index 010a09b..f935022 100644 --- a/package.json +++ b/package.json @@ -53,11 +53,13 @@ "semver": "^7.6.2" }, "devDependencies": { + "@types/chrome": "^0.1.42", "@types/del": "^3.0.1", "@types/ncp": "^2.0.8", "@types/node": "^25.6.2", "@types/nw.js": "^0.92.0", "@types/semver": "^7.7.1", + "@types/yauzl-promise": "^4.0.1", "express": "^5.2.1", "get-port": "^7.2.0", "nw-builder": "^4.17.10", diff --git a/src/main.js b/src/main.js index 749691c..feae0d4 100644 --- a/src/main.js +++ b/src/main.js @@ -1,8 +1,11 @@ const fs = await import('node:fs'); const os = await import('node:os'); const path = await import('node:path'); +const process = await import('node:process'); const stream = await import('node:stream'); +import util from './util'; + function semverGt(v1, v2) { const [major1, minor1, patch1] = v1.replace(/^v/i, '').split('.').map(Number); const [major2, minor2, patch2] = v2.replace(/^v/i, '').split('.').map(Number); @@ -25,7 +28,7 @@ function semverGt(v1, v2) { /** * @typedef {object} Packages * @property {Platform} win - The Windows package - * @property {Platform} mac - The macOS package + * @property {Platform} osx - The macOS package * @property {Platform} linux32 - The Linux 32-bit package * @property {Platform} linux64 - The Linux 64-bit package */ @@ -160,6 +163,77 @@ class Updater { cb(err, null); }); } + + /** + * Returns executed application path. + * + * @returns {string} + */ + getAppPath() { + /** + * @type {Object.} + */ + let appPath = { + osx: path.join(process.cwd(), '../../..'), + win: path.dirname(process.execPath) + }; + appPath.linux32 = appPath.win; + appPath.linux64 = appPath.win; + return appPath[getHost()]; + } + + /** + * Returns current application executable. + * + * @returns {string} + */ + getAppExec() { + let execFolder = this.getAppPath(); + let exec = { + osx: '', + win: path.basename(process.execPath), + linux32: path.basename(process.execPath), + linux64: path.basename(process.execPath) + }; + return path.join(execFolder, exec[platform]); + } + + /** + * @private + * @param {Manifest} manifest + * @return {string} + */ + getExecPathRelativeToPackage(manifest) { + const execPath = manifest.packages[platform] && manifest.packages[platform].execPath; + + if (execPath) { + return execPath; + } + else { + const suffix = { + win: '.exe', + mac: '.app' + }; + return manifest.name + (suffix[platform] || ''); + } + }; + + /** + * Unpack the `filename` in temporary folder. + * + * @param {string} filename + * @param {function} cb - Callback arguments: error, unpacked directory + * @param {Manifest} manifest + */ + unpack(filename, cb, manifest) { + util.decompress(filename, this.options.temporaryDirectory) + .then(() => { + cb(null, path.join(this.options.temporaryDirectory, this.getExecPathRelativeToPackage(manifest))); + }) + .catch((err) => { + cb(err, null); + }); + } } export default Updater; diff --git a/src/updater.js b/src/updater.js index e345a30..0b9ef1a 100644 --- a/src/updater.js +++ b/src/updater.js @@ -156,6 +156,7 @@ class Updater { unpack(filename, cb, manifest) { pUnpack[platform](filename, cb, manifest, this.options.temporaryDirectory); } + /** * Runs installer * @param {string} appPath diff --git a/src/util.js b/src/util.js new file mode 100644 index 0000000..dfd1d36 --- /dev/null +++ b/src/util.js @@ -0,0 +1,107 @@ + +import fs from 'node:fs'; +import path from 'node:path'; +import stream from 'node:stream'; + +import * as tar from 'tar'; +import yauzl from 'yauzl-promise'; + +/** + * Decompresses a file at `filePath` to `cacheDir` directory. + * @async + * @function + * @param {string} filePath - file path to compressed binary + * @param {string} cacheDir - directory to decompress into + * @throws {Error} + * @returns {Promise} + */ +async function decompress(filePath, cacheDir) { + if (filePath.endsWith('.zip')) { + await unzip(filePath, cacheDir); + } else { + await tar.extract({ + file: filePath, + C: cacheDir + }); + } +} + +/** + * Get file mode from entry. Reference implementation is [here](https://github.com/fpsqdb/zip-lib/blob/ac447d269218d396e05cd7072d0e9cd82b5ec52c/src/unzip.ts#L380). + * @async + * @function + * @param {yauzl.Entry} entry - Yauzl entry + * @returns {number} - entry's file mode + */ +function modeFromEntry(entry) { + const attr = entry.externalFileAttributes >> 16 || 33188; + + return [448 /* S_IRWXU */, 56 /* S_IRWXG */, 7 /* S_IRWXO */] + .map(mask => attr & mask) + .reduce((a, b) => a + b, attr & 61440 /* S_IFMT */); +} + +/** + * Unzip `zippedFile` to `cacheDir`. + * @async + * @function + * @param {string} zippedFile - file path to .zip file + * @param {string} cacheDir - directory to unzip in + * @throws {Error} + * @returns {Promise} + */ +async function unzip(zippedFile, cacheDir) { + const zip = await yauzl.open(zippedFile); + let entry = await zip.readEntry(); + /* Array to hold symbolic link entries */ + const symlinks = []; + + while (entry !== null) { + let entryPathAbs = path.join(cacheDir, entry.filename); + /* Check if entry is a symbolic link */ + const isSymlink = ((modeFromEntry(entry) & 0o170000) === 0o120000); + + if (isSymlink) { + /* Store symlink entries to process later */ + symlinks.push(entry); + } else { + /* Handle regular files and directories */ + await fs.promises.mkdir(path.dirname(entryPathAbs), { recursive: true }); + /* Skip directories */ + if (!entry.filename.endsWith('/')) { + const readStream = await entry.openReadStream(); + const writeStream = fs.createWriteStream(entryPathAbs); + await stream.promises.pipeline(readStream, writeStream); + + /* Set file permissions after the file has been written */ + const mode = modeFromEntry(entry); + await fs.promises.chmod(entryPathAbs, mode); + } + } + + /* Read next entry */ + entry = await zip.readEntry(); + } + + /* Process symbolic links after all other files have been extracted */ + for (const symlinkEntry of symlinks) { + let entryPathAbs = path.join(cacheDir, symlinkEntry.filename); + const readStream = await symlinkEntry.openReadStream(); + /** @type {Buffer[]} */ + const chunks = []; + readStream.on('data', (chunk) => chunks.push(chunk)); + await new Promise(resolve => readStream.on('end', resolve)); + const linkTarget = Buffer.concat(chunks).toString('utf8').trim(); + + /* Check if the symlink or a file/directory already exists at the destination */ + if (fs.existsSync(entryPathAbs)) { + /* skip */ + } else { + /* Create symbolic link */ + await fs.promises.symlink(linkTarget, entryPathAbs); + } + } + await zip.close(); +} + +export default { decompress }; diff --git a/tsconfig.json b/tsconfig.json index 85f1271..802e020 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,12 +7,11 @@ "rootDir": ".", "outDir": "types", "strict": true, - "target": "ES2020", + "target": "esnext", "lib": [ - "ES2020" + "ESNext" ], "module": "ESNext", - // "moduleResolution": "node", "types": [ "node" ], diff --git a/types/src/main.d.ts b/types/src/main.d.ts index 140325a..5a33a31 100644 --- a/types/src/main.d.ts +++ b/types/src/main.d.ts @@ -17,7 +17,7 @@ export type Packages = { /** * - The macOS package */ - mac: Platform; + osx: Platform; /** * - The Linux 32-bit package */ @@ -83,4 +83,25 @@ declare class Updater { * @returns {void} */ download(cb: (error: Error | null, filepath: string | null) => void, newManifest: Manifest): void; + /** + * Returns executed application path. + * + * @returns {string} + */ + getAppPath(): string; + /** + * Returns current application executable. + * + * @returns {string} + */ + getAppExec(): string; + /** + * Unpack the `filename` in temporary folder. + * For Windows, [unzip](https://www.mkssoftware.com/docs/man1/unzip.1.asp) is used (which is [not signed](https://github.com/nwutils/updater/issues/68)). + * + * @param {string} filename + * @param {function} cb - Callback arguments: error, unpacked directory + * @param {object} manifest + */ + unpack(filename: string, cb: Function, manifest: object): void; }