-
{
return accumulator;
}, []);
+/**
+ * @summary Extract entry by path
+ * @function
+ * @private
+ *
+ * @param {String} archive - archive
+ * @param {String} filePath - entry file path
+ * @param {Object} options - options
+ * @param {Object} options.hooks - archive hooks
+ * @param {Object[]} options.entries - archive entries
+ * @param {*} [options.default] - entry default value
+ * @fulfil {*} contents
+ * @returns {Promise}
+ *
+ * extractEntryByPath('my/archive.zip', '_info/logo.svg', {
+ * hooks: { ... },
+ * entries: [ ... ],
+ * default: ''
+ * }).then((contents) => {
+ * console.log(contents);
+ * });
+ */
+const extractEntryByPath = (archive, filePath, options) => {
+ const fileEntry = _.find(options.entries, (entry) => {
+ return _.chain(entry.name)
+ .split('/')
+ .tail()
+ .join('/')
+ .value() === filePath;
+ });
+
+ if (!fileEntry) {
+ return Bluebird.resolve(options.default);
+ }
+
+ return options.hooks.extractFile(archive, options.entries, fileEntry.name)
+ .then(rindle.extract);
+};
+
+/**
+ * @summary Extract archive metadata
+ * @function
+ * @private
+ *
+ * @param {String} archive - archive
+ * @param {String} basePath - metadata base path
+ * @param {Object} options - options
+ * @param {Object[]} options.entries - archive entries
+ * @param {Object} options.hooks - archive hooks
+ * @fulfil {Object} - metadata
+ * @returns {Promise}
+ *
+ * extractArchiveMetadata('my/archive.zip', '.meta', {
+ * hooks: { ... },
+ * entries: [ ... ]
+ * }).then((metadata) => {
+ * console.log(metadata);
+ * });
+ */
+const extractArchiveMetadata = (archive, basePath, options) => {
+ return Bluebird.props({
+ logo: extractEntryByPath(archive, `${basePath}/logo.svg`, options),
+ instructions: extractEntryByPath(archive, `${basePath}/instructions.markdown`, options),
+ bmap: extractEntryByPath(archive, `${basePath}/image.bmap`, options),
+ manifest: _.attempt(() => {
+ return extractEntryByPath(archive, `${basePath}/manifest.json`, {
+ hooks: options.hooks,
+ entries: options.entries,
+ default: '{}'
+ }).then((manifest) => {
+ try {
+ return JSON.parse(manifest);
+ } catch (parseError) {
+ const error = new Error('Invalid archive manifest.json');
+ error.description = 'The archive manifest.json file is not valid JSON.';
+ throw error;
+ }
+ });
+ })
+ }).then((results) => {
+ return {
+ name: results.manifest.name,
+ version: results.manifest.version,
+ url: results.manifest.url,
+ supportUrl: results.manifest.supportUrl,
+ releaseNotesUrl: results.manifest.releaseNotesUrl,
+ checksumType: results.manifest.checksumType,
+ checksum: results.manifest.checksum,
+ bytesToZeroOutFromTheBeginning: results.manifest.bytesToZeroOutFromTheBeginning,
+ recommendedDriveSize: results.manifest.recommendedDriveSize,
+ logo: results.logo,
+ bmap: results.bmap,
+ instructions: results.instructions
+ };
+ });
+};
+
/**
* @summary Extract image from archive
* @function
@@ -75,18 +182,25 @@ exports.extractImage = (archive, hooks) => {
const imageEntry = _.first(imageEntries);
- return hooks.extractFile(archive, entries, imageEntry.name).then((imageStream) => {
- return {
- stream: imageStream,
- transform: new PassThroughStream(),
- size: {
- original: imageEntry.size,
- final: {
- estimation: false,
- value: imageEntry.size
- }
+ return Bluebird.props({
+ imageStream: hooks.extractFile(archive, entries, imageEntry.name),
+ metadata: extractArchiveMetadata(archive, ARCHIVE_METADATA_BASE_PATH, {
+ entries,
+ hooks
+ })
+ }).then((results) => {
+ results.metadata.stream = results.imageStream;
+ results.metadata.transform = new PassThroughStream();
+
+ results.metadata.size = {
+ original: imageEntry.size,
+ final: {
+ estimation: false,
+ value: imageEntry.size
}
};
+
+ return results.metadata;
});
});
};
diff --git a/lib/image-stream/index.js b/lib/image-stream/index.js
index ff04b2c8..da881cfa 100644
--- a/lib/image-stream/index.js
+++ b/lib/image-stream/index.js
@@ -99,7 +99,10 @@ exports.getFromFilePath = (file) => {
* const imageStream = require('./lib/image-stream');
*
* imageStream.getImageMetadata('path/to/rpi.img.xz').then((metadata) => {
- * console.log(`The image original size is: ${metada.size.original}`);
+ * console.log(`The image display name is: ${metada.name}`);
+ * console.log(`The image url is: ${metada.url}`);
+ * console.log(`The image support url is: ${metada.supportUrl}`);
+ * console.log(`The image logo is: ${metada.logo}`);
* });
*/
exports.getImageMetadata = (file) => {
diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json
index 9d15fd0a..ef6ac9e8 100644
--- a/npm-shrinkwrap.json
+++ b/npm-shrinkwrap.json
@@ -512,16 +512,16 @@
"from": "isarray@0.0.1",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
},
- "isexe": {
- "version": "1.1.2",
- "from": "isexe@>=1.1.1 <2.0.0",
- "resolved": "https://registry.npmjs.org/isexe/-/isexe-1.1.2.tgz"
- },
"isstream": {
"version": "0.1.2",
"from": "isstream@>=0.1.1 <0.2.0",
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz"
},
+ "isexe": {
+ "version": "1.1.2",
+ "from": "isexe@>=1.1.1 <2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-1.1.2.tgz"
+ },
"js-message": {
"version": "1.0.5",
"from": "js-message@>=1.0.5",
@@ -1451,6 +1451,23 @@
"from": "restore-cursor@>=1.0.1 <2.0.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-1.0.1.tgz"
},
+ "rindle": {
+ "version": "1.3.0",
+ "from": "rindle@>=1.3.0 <2.0.0",
+ "resolved": "https://registry.npmjs.org/rindle/-/rindle-1.3.0.tgz",
+ "dependencies": {
+ "bluebird": {
+ "version": "2.11.0",
+ "from": "bluebird@>=2.10.2 <3.0.0",
+ "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz"
+ },
+ "lodash": {
+ "version": "3.10.1",
+ "from": "lodash@>=3.10.1 <4.0.0",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz"
+ }
+ }
+ },
"run-async": {
"version": "0.1.0",
"from": "run-async@>=0.1.0 <0.2.0",
@@ -1575,6 +1592,11 @@
"from": "string-template@>=0.2.1 <0.3.0",
"resolved": "https://registry.npmjs.org/string-template/-/string-template-0.2.1.tgz"
},
+ "string-to-stream": {
+ "version": "1.1.0",
+ "from": "string-to-stream@>=1.0.1 <2.0.0",
+ "resolved": "https://registry.npmjs.org/string-to-stream/-/string-to-stream-1.1.0.tgz"
+ },
"string-width": {
"version": "1.0.1",
"from": "string-width@>=1.0.1 <2.0.0",
diff --git a/package.json b/package.json
index dba5e4e8..034bd711 100644
--- a/package.json
+++ b/package.json
@@ -90,6 +90,7 @@
"redux-localstorage": "^0.4.1",
"resin-cli-form": "^1.4.1",
"resin-cli-visuals": "^1.2.8",
+ "rindle": "^1.3.0",
"rx": "^4.1.0",
"semver": "^5.1.0",
"sudo-prompt": "^6.1.0",
@@ -113,7 +114,6 @@
"jsonfile": "^2.3.1",
"mochainon": "^1.0.0",
"node-sass": "^3.8.0",
- "rindle": "^1.3.0",
"tmp": "0.0.31",
"versionist": "^2.1.0"
},
diff --git a/tests/gui/models/selection-state.spec.js b/tests/gui/models/selection-state.spec.js
index a3c10d20..4a35113c 100644
--- a/tests/gui/models/selection-state.spec.js
+++ b/tests/gui/models/selection-state.spec.js
@@ -47,6 +47,26 @@ describe('Browser: SelectionState', function() {
m.chai.expect(SelectionStateModel.getImageSize()).to.be.undefined;
});
+ it('getImageUrl() should return undefined', function() {
+ m.chai.expect(SelectionStateModel.getImageUrl()).to.be.undefined;
+ });
+
+ it('getImageName() should return undefined', function() {
+ m.chai.expect(SelectionStateModel.getImageName()).to.be.undefined;
+ });
+
+ it('getImageLogo() should return undefined', function() {
+ m.chai.expect(SelectionStateModel.getImageLogo()).to.be.undefined;
+ });
+
+ it('getImageSupportUrl() should return undefined', function() {
+ m.chai.expect(SelectionStateModel.getImageSupportUrl()).to.be.undefined;
+ });
+
+ it('getImageRecommendedDriveSize() should return undefined', function() {
+ m.chai.expect(SelectionStateModel.getImageRecommendedDriveSize()).to.be.undefined;
+ });
+
it('hasDrive() should return false', function() {
const hasDrive = SelectionStateModel.hasDrive();
m.chai.expect(hasDrive).to.be.false;
@@ -199,7 +219,12 @@ describe('Browser: SelectionState', function() {
beforeEach(function() {
this.image = {
path: 'foo.img',
- size: 999999999
+ size: 999999999,
+ recommendedDriveSize: 1000000000,
+ url: 'https://www.raspbian.org',
+ supportUrl: 'https://www.raspbian.org/forums/',
+ name: 'Raspbian',
+ logo: ''
};
SelectionStateModel.setImage(this.image);
@@ -250,6 +275,51 @@ describe('Browser: SelectionState', function() {
});
+ describe('.getImageUrl()', function() {
+
+ it('should return the image url', function() {
+ const imageUrl = SelectionStateModel.getImageUrl();
+ m.chai.expect(imageUrl).to.equal('https://www.raspbian.org');
+ });
+
+ });
+
+ describe('.getImageName()', function() {
+
+ it('should return the image name', function() {
+ const imageName = SelectionStateModel.getImageName();
+ m.chai.expect(imageName).to.equal('Raspbian');
+ });
+
+ });
+
+ describe('.getImageLogo()', function() {
+
+ it('should return the image logo', function() {
+ const imageLogo = SelectionStateModel.getImageLogo();
+ m.chai.expect(imageLogo).to.equal('');
+ });
+
+ });
+
+ describe('.getImageSupportUrl()', function() {
+
+ it('should return the image support url', function() {
+ const imageSupportUrl = SelectionStateModel.getImageSupportUrl();
+ m.chai.expect(imageSupportUrl).to.equal('https://www.raspbian.org/forums/');
+ });
+
+ });
+
+ describe('.getImageRecommendedDriveSize()', function() {
+
+ it('should return the image recommended drive size', function() {
+ const imageRecommendedDriveSize = SelectionStateModel.getImageRecommendedDriveSize();
+ m.chai.expect(imageRecommendedDriveSize).to.equal(1000000000);
+ });
+
+ });
+
describe('.hasImage()', function() {
it('should return true', function() {
@@ -340,6 +410,36 @@ describe('Browser: SelectionState', function() {
}).to.throw('Invalid image size: 999999999');
});
+ it('should throw if url is defined but its not a string', function() {
+ m.chai.expect(function() {
+ SelectionStateModel.setImage({
+ path: 'foo.img',
+ size: 999999999,
+ url: 1234
+ });
+ }).to.throw('Invalid image url: 1234');
+ });
+
+ it('should throw if name is defined but its not a string', function() {
+ m.chai.expect(function() {
+ SelectionStateModel.setImage({
+ path: 'foo.img',
+ size: 999999999,
+ name: 1234
+ });
+ }).to.throw('Invalid image name: 1234');
+ });
+
+ it('should throw if logo is defined but its not a string', function() {
+ m.chai.expect(function() {
+ SelectionStateModel.setImage({
+ path: 'foo.img',
+ size: 999999999,
+ logo: 1234
+ });
+ }).to.throw('Invalid image logo: 1234');
+ });
+
it('should de-select a previously selected not-large-enough drive', function() {
DrivesModel.setDrives([
{
@@ -362,6 +462,29 @@ describe('Browser: SelectionState', function() {
SelectionStateModel.removeImage();
});
+ it('should de-select a previously selected not-recommended drive', function() {
+ DrivesModel.setDrives([
+ {
+ device: '/dev/disk1',
+ name: 'USB Drive',
+ size: 1200000000,
+ protected: false
+ }
+ ]);
+
+ SelectionStateModel.setDrive('/dev/disk1');
+ m.chai.expect(SelectionStateModel.hasDrive()).to.be.true;
+
+ SelectionStateModel.setImage({
+ path: 'foo.img',
+ size: 999999999,
+ recommendedDriveSize: 1500000000
+ });
+
+ m.chai.expect(SelectionStateModel.hasDrive()).to.be.false;
+ SelectionStateModel.removeImage();
+ });
+
});
});
diff --git a/tests/image-stream/data/metadata/zip/rpi-invalid-manifest.zip b/tests/image-stream/data/metadata/zip/rpi-invalid-manifest.zip
new file mode 100644
index 00000000..21a0bbc8
Binary files /dev/null and b/tests/image-stream/data/metadata/zip/rpi-invalid-manifest.zip differ
diff --git a/tests/image-stream/data/metadata/zip/rpi-with-bmap.zip b/tests/image-stream/data/metadata/zip/rpi-with-bmap.zip
new file mode 100644
index 00000000..14cc086b
Binary files /dev/null and b/tests/image-stream/data/metadata/zip/rpi-with-bmap.zip differ
diff --git a/tests/image-stream/data/metadata/zip/rpi-with-instructions.zip b/tests/image-stream/data/metadata/zip/rpi-with-instructions.zip
new file mode 100644
index 00000000..19c75692
Binary files /dev/null and b/tests/image-stream/data/metadata/zip/rpi-with-instructions.zip differ
diff --git a/tests/image-stream/data/metadata/zip/rpi-with-logo.zip b/tests/image-stream/data/metadata/zip/rpi-with-logo.zip
new file mode 100644
index 00000000..5b0158f6
Binary files /dev/null and b/tests/image-stream/data/metadata/zip/rpi-with-logo.zip differ
diff --git a/tests/image-stream/data/metadata/zip/rpi-with-manifest.zip b/tests/image-stream/data/metadata/zip/rpi-with-manifest.zip
new file mode 100644
index 00000000..15289b19
Binary files /dev/null and b/tests/image-stream/data/metadata/zip/rpi-with-manifest.zip differ
diff --git a/tests/image-stream/metadata/zip.spec.js b/tests/image-stream/metadata/zip.spec.js
new file mode 100644
index 00000000..2a07ae0c
--- /dev/null
+++ b/tests/image-stream/metadata/zip.spec.js
@@ -0,0 +1,171 @@
+/*
+ * Copyright 2016 resin.io
+ *
+ * Licensed 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.
+ */
+
+'use strict';
+
+const m = require('mochainon');
+const path = require('path');
+const DATA_PATH = path.join(__dirname, '..', 'data');
+const IMAGES_PATH = path.join(DATA_PATH, 'images');
+const ZIP_PATH = path.join(DATA_PATH, 'metadata', 'zip');
+const tester = require('../tester');
+const imageStream = require('../../../lib/image-stream/index');
+
+const testMetadataProperty = (archivePath, propertyName, expectedValue) => {
+ return imageStream.getFromFilePath(archivePath).then((image) => {
+ m.chai.expect(image[propertyName]).to.deep.equal(expectedValue);
+
+ return imageStream.getImageMetadata(archivePath).then((metadata) => {
+ m.chai.expect(metadata[propertyName]).to.deep.equal(expectedValue);
+ });
+ });
+};
+
+describe('ImageStream: Metadata ZIP', function() {
+
+ this.timeout(10000);
+
+ describe('given an archive with an invalid `manifest.json`', function() {
+
+ tester.expectError(
+ path.join(ZIP_PATH, 'rpi-invalid-manifest.zip'),
+ 'Invalid archive manifest.json');
+
+ describe('.getImageMetadata()', function() {
+
+ it('should be rejected with an error', function(done) {
+ const image = path.join(ZIP_PATH, 'rpi-invalid-manifest.zip');
+
+ imageStream.getImageMetadata(image).catch((error) => {
+ m.chai.expect(error).to.be.an.instanceof(Error);
+ m.chai.expect(error.message).to.equal('Invalid archive manifest.json');
+ done();
+ });
+ });
+
+ });
+
+ });
+
+ describe('given an archive with a `manifest.json`', function() {
+
+ const archive = path.join(ZIP_PATH, 'rpi-with-manifest.zip');
+
+ tester.extractFromFilePath(
+ archive,
+ path.join(IMAGES_PATH, 'raspberrypi.img'));
+
+ it('should read the manifest name property', function(done) {
+ testMetadataProperty(archive, 'name', 'Raspberry Pi').asCallback(done);
+ });
+
+ it('should read the manifest version property', function(done) {
+ testMetadataProperty(archive, 'version', '1.0.0').asCallback(done);
+ });
+
+ it('should read the manifest url property', function(done) {
+ testMetadataProperty(archive, 'url', 'https://www.raspberrypi.org').asCallback(done);
+ });
+
+ it('should read the manifest supportUrl property', function(done) {
+ const expectedValue = 'https://www.raspberrypi.org/forums/';
+ testMetadataProperty(archive, 'supportUrl', expectedValue).asCallback(done);
+ });
+
+ it('should read the manifest releaseNotesUrl property', function(done) {
+ const expectedValue = 'http://downloads.raspberrypi.org/raspbian/release_notes.txt';
+ testMetadataProperty(archive, 'releaseNotesUrl', expectedValue).asCallback(done);
+ });
+
+ it('should read the manifest checksumType property', function(done) {
+ testMetadataProperty(archive, 'checksumType', 'md5').asCallback(done);
+ });
+
+ it('should read the manifest checksum property', function(done) {
+ testMetadataProperty(archive, 'checksum', 'add060b285d512f56c175b76b7ef1bee').asCallback(done);
+ });
+
+ it('should read the manifest bytesToZeroOutFromTheBeginning property', function(done) {
+ testMetadataProperty(archive, 'bytesToZeroOutFromTheBeginning', 512).asCallback(done);
+ });
+
+ it('should read the manifest recommendedDriveSize property', function(done) {
+ testMetadataProperty(archive, 'recommendedDriveSize', 4294967296).asCallback(done);
+ });
+
+ });
+
+ describe('given an archive with a `logo.svg`', function() {
+
+ const archive = path.join(ZIP_PATH, 'rpi-with-logo.zip');
+
+ const logo = [
+ '',
+ ''
+ ].join('\n');
+
+ it('should read the logo contents', function(done) {
+ testMetadataProperty(archive, 'logo', logo).asCallback(done);
+ });
+
+ });
+
+ describe('given an archive with a bmap file', function() {
+
+ const archive = path.join(ZIP_PATH, 'rpi-with-bmap.zip');
+
+ const bmap = [
+ '',
+ '',
+ ' 36864 ',
+ ' 4096 ',
+ ' 9 ',
+ ' 4 ',
+ ' d90f372215cbbef8801caca7b1dd7e587b2142cc ',
+ ' ',
+ ' 0-1 ',
+ ' 7-8 ',
+ ' ',
+ '',
+ ''
+ ].join('\n');
+
+ it('should read the bmap contents', function(done) {
+ testMetadataProperty(archive, 'bmap', bmap).asCallback(done);
+ });
+
+ });
+
+ describe('given an archive with instructions', function() {
+
+ const archive = path.join(ZIP_PATH, 'rpi-with-instructions.zip');
+
+ const instructions = [
+ '# Raspberry Pi Next Steps',
+ '',
+ 'Lorem ipsum dolor sit amet.',
+ ''
+ ].join('\n');
+
+ it('should read the instruction contents', function(done) {
+ testMetadataProperty(archive, 'instructions', instructions).asCallback(done);
+ });
+
+ });
+
+});