diff --git a/.travis.yml b/.travis.yml index 549aeb5..1954c70 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,7 +9,6 @@ matrix: - os: osx - os: linux node_js: - - "8" - "10" - "12" - "14" diff --git a/appveyor.yml b/appveyor.yml index 4238b84..feb669c 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -3,7 +3,6 @@ cache: - node_modules environment: matrix: - - nodejs_version: 8 - nodejs_version: 10 - nodejs_version: 12 - nodejs_version: 14 diff --git a/package-lock.json b/package-lock.json index aad0b32..48c1c94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -519,6 +519,11 @@ "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", "dev": true }, + "at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==" + }, "aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", @@ -1263,6 +1268,17 @@ "integrity": "sha512-33X7H/wdfO99GdRLLgkjUrD4geAFdq/Uv0kl3HD4da6HDixd2GUg8Mw7dahLCV9r/EARkmtYBB6Tch4EEokFTQ==", "dev": true }, + "fs-extra": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.0.1.tgz", + "integrity": "sha512-h2iAoN838FqAFJY2/qVpzFXy+EBxfVE220PalAqQLDVsFOHLJrZvut5puAbCdNv6WJk+B8ihI+k0c7JK5erwqQ==", + "requires": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^1.0.0" + } + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -1352,8 +1368,7 @@ "graceful-fs": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", - "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", - "dev": true + "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==" }, "growl": { "version": "1.10.5", @@ -1862,6 +1877,15 @@ "minimist": "^1.2.5" } }, + "jsonfile": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.0.1.tgz", + "integrity": "sha512-jR2b5v7d2vIOust+w3wtFKZIfpC2pnRmFAhAC/BuweZFQR8qZzxH1OyrQ10HmdVYiXWkYUqPVsz91cG7EL2FBg==", + "requires": { + "graceful-fs": "^4.1.6", + "universalify": "^1.0.0" + } + }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", @@ -3092,6 +3116,11 @@ "is-typedarray": "^1.0.0" } }, + "universalify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-1.0.0.tgz", + "integrity": "sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug==" + }, "uri-js": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", diff --git a/package.json b/package.json index 72e4ab8..a7a43d3 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,9 @@ "ubuntu-touch" ], "homepage": "https://github.com/ubports/promise-android-tools#readme", - "dependencies": {}, + "dependencies": { + "fs-extra": "^9.0.1" + }, "devDependencies": { "chai": "^4.1.2", "chai-as-promised": "^7.1.1", diff --git a/src/adb.js b/src/adb.js index 1f3ee5f..c32d660 100644 --- a/src/adb.js +++ b/src/adb.js @@ -17,7 +17,7 @@ * along with this program. If not, see . */ -const fs = require("fs"); +const fs = require("fs-extra"); const path = require("path"); const exec = require("child_process").exec; const events = require("events"); @@ -32,6 +32,7 @@ const DEFAULT_EXEC = (args, callback) => { callback ); }; + const DEFAULT_LOG = console.log; const DEFAULT_PORT = 5037; @@ -172,6 +173,7 @@ class Adb { } var fileSize = fs.statSync(file)["size"]; var lastSize = 0; + // FIXME use stream and parse stdout instead of polling with stat var progressInterval = setInterval(() => { _this .shell([ @@ -259,10 +261,25 @@ class Adb { ]); } + // Return the status of the device (bootloader, recovery, device) + getState() { + return this.execCommand(["get-state"]).then(stdout => stdout.trim()); + } + ////////////////////////////////////////////////////////////////////////////// // Convenience functions ////////////////////////////////////////////////////////////////////////////// + // Reboot to a state (system, recovery, bootloader) + ensureState(state) { + return this.getState().then(currentState => + currentState === state || + (currentState === "device" && state === "system") + ? Promise.resolve() + : this.reboot(state).then(() => this.waitForDevice()) + ); + } + // Push an array of files and report progress // { src, dest } pushArray(files = [], progress = () => {}, interval) { @@ -497,6 +514,207 @@ class Adb { ); }); } + + // size of a file or directory + getFileSize(file) { + return this.shell("du -shk " + file) + .then(size => { + if (isNaN(parseFloat(size))) + throw new Error(`Cannot parse size from ${size}`); + else return parseFloat(size); + }) + .catch(e => { + throw new Error(`Unable to get size: ${e}`); + }); + } + + // available size of a partition + getAvailablePartitionSize(partition) { + return this.shell("df -k -P " + partition) + .then(stdout => stdout.split(/[ ,]+/)) + .then(arr => parseInt(arr[arr.length - 3])) + .then(size => { + if (isNaN(size)) throw new Error(`Cannot parse size from ${size}`); + else return size; + }) + .catch(e => { + throw new Error(`Unable to get size: ${e}`); + }); + } + + // total size of a partition + getTotalPartitionSize(partition) { + return this.shell("df -k -P " + partition) + .then(stdout => stdout.split(/[ ,]+/)) + .then(arr => parseInt(arr[arr.length - 5])) + .then(size => { + if (isNaN(size)) throw new Error(`Cannot parse size from ${size}`); + else return size; + }) + .catch(e => { + throw new Error(`Unable to get size: ${e}`); + }); + } + + // Backup "srcfile" from the device to local tar "destfile" + createBackupTar(srcfile, destfile, progress) { + return Promise.all([ + this.ensureState("recovery") + .then(() => this.shell("mkfifo /backup.pipe")) + .then(() => this.getFileSize(srcfile)), + fs.ensureFile(destfile) + ]) + .then(([fileSize]) => { + progress(0); + // FIXME with gzip compression (the -z flag on tar), the progress estimate is way off. It's still beneficial to enable it, because it saves a lot of space. + const progressInterval = setInterval(() => { + const { size } = fs.statSync(destfile); + progress((size / 1024 / fileSize) * 100); + }, 1000); + + // FIXME replace shell pipe to dd with node stream + return Promise.all([ + this.execCommand([ + "exec-out 'tar -cpz " + + "--exclude=*/var/cache " + + "--exclude=*/var/log " + + "--exclude=*/.cache/upstart " + + "--exclude=*/.cache/*.qmlc " + + "--exclude=*/.cache/*/qmlcache " + + "--exclude=*/.cache/*/qml_cache", + srcfile, + " 2>/backup.pipe' | dd of=" + destfile + ]), + this.shell("cat /backup.pipe") + ]) + .then(() => { + clearInterval(progressInterval); + progress(100); + }) + .catch(e => { + clearInterval(progressInterval); + throw new Error(e); + }); + }) + .then(() => this.shell("rm /backup.pipe")) + .catch(e => { + throw new Error(`Backup failed: ${e}`); + }); + } + + // Restore tar "srcfile" + restoreBackupTar(srcfile) { + return this.ensureState("recovery") + .then(() => this.shell("mkfifo /restore.pipe")) + .then(() => + Promise.all([ + this.push(srcfile, "/restore.pipe"), + this.shell(["'cd /; cat /restore.pipe | tar -xvz'"]) + ]) + ) + .then(() => this.shell(["rm", "/restore.pipe"])) + .catch(e => { + throw new Error(`Restore failed: ${e}`); + }); + } + + listUbuntuBackups(backupBaseDir) { + return fs + .readdir(backupBaseDir) + .then(backups => + Promise.all( + backups.map(backup => + fs + .readFile(path.join(backupBaseDir, backup, "metadata.json")) + .then(metadataBuffer => ({ + ...JSON.parse(metadataBuffer.toString()), + dir: path.join(backupBaseDir, backup) + })) + .catch(() => null) + ) + ).then(r => r.filter(r => r)) + ) + .catch(() => []); + } + + async createUbuntuTouchBackup( + backupBaseDir, + comment, + dataPartition = "/data", + progress = () => {} + ) { + const time = new Date(); + const dir = path.join(backupBaseDir, time.toISOString()); + return this.ensureState("recovery") + .then(() => fs.ensureDir(dir)) + .then(() => + Promise.all([ + this.shell(["stat", "/data/user-data"]), + this.shell(["stat", "/data/syste-mdata"]) + ]).catch(() => this.shell(["mount", dataPartition, "/data"])) + ) + .then(() => + this.createBackupTar( + "/data/system-data", + path.join(dir, "system.tar.gz"), + p => progress(p * 0.5) + ) + ) + .then(() => + this.createBackupTar( + "/data/user-data", + path.join(dir, "user.tar.gz"), + p => progress(50 + p * 0.5) + ) + ) + .then(async () => { + const metadata = { + codename: await this.getDeviceName(), + serialno: await this.getSerialno(), + size: + (await this.getFileSize("/data/user-data")) + + (await this.getFileSize("/data/system-data")), + time, + comment: + comment || `Ubuntu Touch backup created on ${time.toISOString()}`, + restorations: [] + }; + return fs + .writeJSON(path.join(dir, "metadata.json"), metadata) + .then(() => ({ ...metadata, dir })) + .catch(e => { + throw new Error(`Failed to restore: ${e}`); + }); + }); + } + + async restoreUbuntuTouchBackup(dir, progress = () => {}) { + progress(0); // FIXME report actual push progress + let metadata = JSON.parse( + await fs.readFile(path.join(dir, "metadata.json")) + ); + return this.ensureState("recovery") + .then(async () => { + metadata.restorations = metadata.restorations || []; + metadata.restorations.push({ + codename: await this.getDeviceName(), + serialno: await this.getSerialno(), + time: new Date().toISOString() + }); + }) + .then(() => progress(10)) + .then(() => this.restoreBackupTar(path.join(dir, "system.tar.gz"))) + .then(() => progress(50)) + .then(() => this.restoreBackupTar(path.join(dir, "user.tar.gz"))) + .then(() => progress(90)) + .then(() => fs.writeJSON(path.join(dir, "metadata.json"), metadata)) + .then(() => this.reboot("system")) + .then(() => progress(100)) + .then(() => ({ ...metadata, dir })) + .catch(e => { + throw new Error(`Failed to restore: ${e}`); + }); + } } module.exports = Adb; diff --git a/tests/unit-tests/test_adb.js b/tests/unit-tests/test_adb.js index cb70ffc..9f2f716 100644 --- a/tests/unit-tests/test_adb.js +++ b/tests/unit-tests/test_adb.js @@ -379,6 +379,38 @@ describe("Adb module", function() { }); }); }); + it("should reject on failure in stdout", function(done) { + const execFake = sinon.fake((args, callback) => { + callback(null, "failed", null); + }); + const logSpy = sinon.spy(); + const adb = new Adb({ exec: execFake, log: logSpy }); + adb.reboot("bootloader").catch(() => { + expect(execFake).to.have.been.calledWith([ + "-P", + 5037, + "reboot", + "bootloader" + ]); + done(); + }); + }); + it("should reject on error", function(done) { + const execFake = sinon.fake((args, callback) => { + callback(666, "everything exploded", "what!?"); + }); + const logSpy = sinon.spy(); + const adb = new Adb({ exec: execFake, log: logSpy }); + adb.reboot("bootloader").catch(() => { + expect(execFake).to.have.been.calledWith([ + "-P", + 5037, + "reboot", + "bootloader" + ]); + done(); + }); + }); it("should reject on invalid state", function() { const execFake = sinon.spy(); const logSpy = sinon.spy(); @@ -388,14 +420,6 @@ describe("Adb module", function() { ); }); }); - describe("backup()", function() { - it("should create backup"); - it("should reject if backup failed"); - }); - describe("restore()", function() { - it("should restore backup"); - it("should reject if backup failed"); - }); describe("forward()", function() { it("should create forward connection"); it("should not rebind forward connection"); @@ -444,12 +468,6 @@ describe("Adb module", function() { it("should reject if no host specified"); it("should reject on error"); }); - describe("getState()", function() { - it("should resolve offline"); - it("should resolve bootloader"); - it("should resolve device"); - it("should reject on error"); - }); describe("ppp()", function() { it("should run PPP over USB"); it("should reject on error"); @@ -519,9 +537,55 @@ describe("Adb module", function() { it("should reject if package inaccessible"); it("should reject on error"); }); + describe("getState()", function() { + it("should resolve state", function() { + const execFake = sinon.fake((args, callback) => { + callback(null, "recovery", null); + }); + const logSpy = sinon.spy(); + const adb = new Adb({ exec: execFake, log: logSpy }); + return adb.getState().then(() => { + expect(execFake).to.have.been.calledWith(["-P", 5037, "get-state"]); + }); + }); + }); }); describe("convenience functions", function() { + describe("ensureState()", function() { + it("should resolve if already in requested state", function() { + const execFake = sinon.fake((args, callback) => { + callback(null, "recovery", null); + }); + const logSpy = sinon.spy(); + const adb = new Adb({ exec: execFake, log: logSpy }); + return adb.ensureState("recovery").then(() => { + expect(execFake).to.have.been.calledWith(["-P", 5037, "get-state"]); + }); + }); + it("should properly handle device state", function() { + const execFake = sinon.fake((args, callback) => { + callback(null, "device", null); + }); + const logSpy = sinon.spy(); + const adb = new Adb({ exec: execFake, log: logSpy }); + return adb.ensureState("system").then(() => { + expect(execFake).to.have.been.calledWith(["-P", 5037, "get-state"]); + }); + }); + it("should reboot to correct state", function() { + const execFake = sinon.fake((args, callback) => { + callback(null, "recovery", null); + }); + const logSpy = sinon.spy(); + const adb = new Adb({ exec: execFake, log: logSpy }); + sinon.stub(adb, "reboot").resolves(); + sinon.stub(adb, "waitForDevice").resolves(); + return adb.ensureState("system").then(() => { + expect(execFake).to.have.been.calledWith(["-P", 5037, "get-state"]); + }); + }); + }); describe("pushArray()", function() { it("should resolve on empty array", function() { const execFake = sinon.spy(); @@ -1041,5 +1105,114 @@ describe("Adb module", function() { }); }); }); + describe("getFileSize()", function() { + it("should resolve file size", function() { + const execFake = sinon.fake((args, callback) => { + callback(null, "1337", null); + }); + const logSpy = sinon.spy(); + const adb = new Adb({ exec: execFake, log: logSpy }); + return adb.getFileSize("/wtf").then(size => { + expect(size).to.eql(1337); + expect(execFake).to.have.been.calledWith([ + "-P", + 5037, + "shell", + "du -shk /wtf" + ]); + }); + }); + it("should reject on invalid response file size", function(done) { + const execFake = sinon.fake((args, callback) => { + callback(null, "invalid response :)", null); + }); + const logSpy = sinon.spy(); + const adb = new Adb({ exec: execFake, log: logSpy }); + adb.getFileSize().catch(() => { + expect(execFake).to.have.been.calledOnce; + done(); + }); + }); + }); + describe("getAvailablePartitionSize()", function() { + it("should resolve available partition size", function() { + const execFake = sinon.fake((args, callback) => { + callback(null, "a\n/wtf 1337 a b", null); + }); + const logSpy = sinon.spy(); + const adb = new Adb({ exec: execFake, log: logSpy }); + return adb.getAvailablePartitionSize("/wtf").then(size => { + expect(size).to.eql(1337); + expect(execFake).to.have.been.calledWith([ + "-P", + 5037, + "shell", + "df -k -P /wtf" + ]); + }); + }); + it("should reject on invalid response", function(done) { + const execFake = sinon.fake((args, callback) => { + callback(null, "invalid response :)", null); + }); + const logSpy = sinon.spy(); + const adb = new Adb({ exec: execFake, log: logSpy }); + adb.getAvailablePartitionSize("/wtf").catch(() => { + expect(execFake).to.have.been.calledOnce; + done(); + }); + }); + it("should reject on error", function(done) { + const execFake = sinon.fake((args, callback) => { + callback(69, "invalid response :)", null); + }); + const logSpy = sinon.spy(); + const adb = new Adb({ exec: execFake, log: logSpy }); + adb.getAvailablePartitionSize().catch(() => { + expect(execFake).to.have.been.calledOnce; + done(); + }); + }); + }); + describe("getTotalPartitionSize()", function() { + it("should resolve available partition size", function() { + const execFake = sinon.fake((args, callback) => { + callback(null, "a\n/wtf 1337 a b c d", null); + }); + const logSpy = sinon.spy(); + const adb = new Adb({ exec: execFake, log: logSpy }); + return adb.getTotalPartitionSize("/wtf").then(size => { + expect(size).to.eql(1337); + expect(execFake).to.have.been.calledWith([ + "-P", + 5037, + "shell", + "df -k -P /wtf" + ]); + }); + }); + it("should reject on invalid response", function(done) { + const execFake = sinon.fake((args, callback) => { + callback(null, "invalid response :)", null); + }); + const logSpy = sinon.spy(); + const adb = new Adb({ exec: execFake, log: logSpy }); + adb.getTotalPartitionSize("/wtf").catch(() => { + expect(execFake).to.have.been.calledOnce; + done(); + }); + }); + it("should reject on error", function(done) { + const execFake = sinon.fake((args, callback) => { + callback(69, "invalid response :)", null); + }); + const logSpy = sinon.spy(); + const adb = new Adb({ exec: execFake, log: logSpy }); + adb.getTotalPartitionSize().catch(() => { + expect(execFake).to.have.been.calledOnce; + done(); + }); + }); + }); }); });