From f96cf8f491bfcc5d210d617178f994083831721a Mon Sep 17 00:00:00 2001 From: wangxi Date: Wed, 9 Jan 2019 12:22:33 +0800 Subject: [PATCH] feat: Download app version package file from browser --- .eslintignore | 2 + lib/blob/Blob.js | 491 ++++++++++++++++++ package.json | 1 + src/index.js | 3 + src/pages/Dashboard/Versions/Detail/index.jsx | 7 +- src/stores/app/version.js | 5 +- src/utils/index.js | 20 + yarn.lock | 5 + 8 files changed, 529 insertions(+), 5 deletions(-) create mode 100644 lib/blob/Blob.js diff --git a/.eslintignore b/.eslintignore index 808ab9bd..f33c361f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -9,3 +9,5 @@ public/ coverage/ webpack*.js + +lib/blob diff --git a/lib/blob/Blob.js b/lib/blob/Blob.js new file mode 100644 index 00000000..3c82918f --- /dev/null +++ b/lib/blob/Blob.js @@ -0,0 +1,491 @@ +/* Blob.js + * A Blob, File, FileReader & URL implementation. + * 2018-08-09 + * + * By Eli Grey, http://eligrey.com + * By Jimmy Wärting, https://github.com/jimmywarting + * License: MIT + * See https://github.com/eligrey/Blob.js/blob/master/LICENSE.md + */ + +(function() { + const global = + typeof window === 'object' + ? window + : typeof self === 'object' + ? self + : this; + + const BlobBuilder = + global.BlobBuilder || + global.WebKitBlobBuilder || + global.MSBlobBuilder || + global.MozBlobBuilder; + + global.URL = + global.URL || + global.webkitURL || + function(href, a) { + a = document.createElement('a'); + a.href = href; + return a; + }; + + const origBlob = global.Blob; + const createObjectURL = URL.createObjectURL; + const revokeObjectURL = URL.revokeObjectURL; + const strTag = global.Symbol && global.Symbol.toStringTag; + let blobSupported = false; + let blobSupportsArrayBufferView = false; + const arrayBufferSupported = !!global.ArrayBuffer; + const blobBuilderSupported = + BlobBuilder && + BlobBuilder.prototype.append && + BlobBuilder.prototype.getBlob; + + try { + // Check if Blob constructor is supported + blobSupported = new Blob(['ä']).size === 2; + + // Check if Blob constructor supports ArrayBufferViews + // Fails in Safari 6, so we need to map to ArrayBuffers there. + blobSupportsArrayBufferView = new Blob([new Uint8Array([1, 2])]).size === 2; + } catch (e) {} + + /** + * Helper function that maps ArrayBufferViews to ArrayBuffers + * Used by BlobBuilder constructor and old browsers that didn't + * support it in the Blob constructor. + */ + function mapArrayBufferViews(ary) { + return ary.map(chunk => { + if (chunk.buffer instanceof ArrayBuffer) { + let buf = chunk.buffer; + + // if this is a subarray, make a copy so we only + // include the subarray region from the underlying buffer + if (chunk.byteLength !== buf.byteLength) { + const copy = new Uint8Array(chunk.byteLength); + copy.set(new Uint8Array(buf, chunk.byteOffset, chunk.byteLength)); + buf = copy.buffer; + } + + return buf; + } + + return chunk; + }); + } + + function BlobBuilderConstructor(ary, options) { + options = options || {}; + + const bb = new BlobBuilder(); + mapArrayBufferViews(ary).forEach(part => { + bb.append(part); + }); + + return options.type ? bb.getBlob(options.type) : bb.getBlob(); + } + + function BlobConstructor(ary, options) { + return new origBlob(mapArrayBufferViews(ary), options || {}); + } + + if (global.Blob) { + BlobBuilderConstructor.prototype = Blob.prototype; + BlobConstructor.prototype = Blob.prototype; + } + + function FakeBlobBuilder() { + function toUTF8Array(str) { + const utf8 = []; + for (let i = 0; i < str.length; i++) { + let charcode = str.charCodeAt(i); + if (charcode < 0x80) utf8.push(charcode); + else if (charcode < 0x800) { + utf8.push(0xc0 | (charcode >> 6), 0x80 | (charcode & 0x3f)); + } else if (charcode < 0xd800 || charcode >= 0xe000) { + utf8.push( + 0xe0 | (charcode >> 12), + 0x80 | ((charcode >> 6) & 0x3f), + 0x80 | (charcode & 0x3f) + ); + } + // surrogate pair + else { + i++; + // UTF-16 encodes 0x10000-0x10FFFF by + // subtracting 0x10000 and splitting the + // 20 bits of 0x0-0xFFFFF into two halves + charcode = + 0x10000 + + (((charcode & 0x3ff) << 10) | (str.charCodeAt(i) & 0x3ff)); + utf8.push( + 0xf0 | (charcode >> 18), + 0x80 | ((charcode >> 12) & 0x3f), + 0x80 | ((charcode >> 6) & 0x3f), + 0x80 | (charcode & 0x3f) + ); + } + } + return utf8; + } + function fromUtf8Array(array) { + let out, i, len, c; + let char2, char3; + + out = ''; + len = array.length; + i = 0; + while (i < len) { + c = array[i++]; + switch (c >> 4) { + case 0: + case 1: + case 2: + case 3: + case 4: + case 5: + case 6: + case 7: + // 0xxxxxxx + out += String.fromCharCode(c); + break; + case 12: + case 13: + // 110x xxxx 10xx xxxx + char2 = array[i++]; + out += String.fromCharCode(((c & 0x1f) << 6) | (char2 & 0x3f)); + break; + case 14: + // 1110 xxxx 10xx xxxx 10xx xxxx + char2 = array[i++]; + char3 = array[i++]; + out += String.fromCharCode( + ((c & 0x0f) << 12) | ((char2 & 0x3f) << 6) | ((char3 & 0x3f) << 0) + ); + break; + } + } + return out; + } + function isDataView(obj) { + return obj && DataView.prototype.isPrototypeOf(obj); + } + function bufferClone(buf) { + const view = new Array(buf.byteLength); + const array = new Uint8Array(buf); + let i = view.length; + while (i--) { + view[i] = array[i]; + } + return view; + } + function encodeByteArray(input) { + const byteToCharMap = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + + const output = []; + + for (let i = 0; i < input.length; i += 3) { + const byte1 = input[i]; + const haveByte2 = i + 1 < input.length; + const byte2 = haveByte2 ? input[i + 1] : 0; + const haveByte3 = i + 2 < input.length; + const byte3 = haveByte3 ? input[i + 2] : 0; + + const outByte1 = byte1 >> 2; + const outByte2 = ((byte1 & 0x03) << 4) | (byte2 >> 4); + let outByte3 = ((byte2 & 0x0f) << 2) | (byte3 >> 6); + let outByte4 = byte3 & 0x3f; + + if (!haveByte3) { + outByte4 = 64; + + if (!haveByte2) { + outByte3 = 64; + } + } + + output.push( + byteToCharMap[outByte1], + byteToCharMap[outByte2], + byteToCharMap[outByte3], + byteToCharMap[outByte4] + ); + } + + return output.join(''); + } + + const create = + Object.create || + function(a) { + function c() {} + c.prototype = a; + return new c(); + }; + + if (arrayBufferSupported) { + const viewClasses = [ + '[object Int8Array]', + '[object Uint8Array]', + '[object Uint8ClampedArray]', + '[object Int16Array]', + '[object Uint16Array]', + '[object Int32Array]', + '[object Uint32Array]', + '[object Float32Array]', + '[object Float64Array]' + ]; + + var isArrayBufferView = + ArrayBuffer.isView || + function(obj) { + return ( + obj && viewClasses.indexOf(Object.prototype.toString.call(obj)) > -1 + ); + }; + } + + /** ***************************************************** */ + /* Blob constructor */ + /** ***************************************************** */ + function Blob(chunks, opts) { + chunks = chunks || []; + for (let i = 0, len = chunks.length; i < len; i++) { + const chunk = chunks[i]; + if (chunk instanceof Blob) { + chunks[i] = chunk._buffer; + } else if (typeof chunk === 'string') { + chunks[i] = toUTF8Array(chunk); + } else if ( + arrayBufferSupported && + (ArrayBuffer.prototype.isPrototypeOf(chunk) || + isArrayBufferView(chunk)) + ) { + chunks[i] = bufferClone(chunk); + } else if (arrayBufferSupported && isDataView(chunk)) { + chunks[i] = bufferClone(chunk.buffer); + } else { + chunks[i] = toUTF8Array(String(chunk)); + } + } + + this._buffer = [].concat.apply([], chunks); + this.size = this._buffer.length; + this.type = opts ? opts.type || '' : ''; + } + + Blob.prototype.slice = function(start, end, type) { + const slice = this._buffer.slice(start || 0, end || this._buffer.length); + return new Blob([slice], { type }); + }; + + Blob.prototype.toString = function() { + return '[object Blob]'; + }; + + /** ***************************************************** */ + /* File constructor */ + /** ***************************************************** */ + function File(chunks, name, opts) { + opts = opts || {}; + const a = Blob.call(this, chunks, opts) || this; + a.name = name; + a.lastModifiedDate = opts.lastModified + ? new Date(opts.lastModified) + : new Date(); + a.lastModified = +a.lastModifiedDate; + + return a; + } + + File.prototype = create(Blob.prototype); + File.prototype.constructor = File; + + if (Object.setPrototypeOf) { + Object.setPrototypeOf(File, Blob); + } else { + try { + File.__proto__ = Blob; + } catch (e) {} + } + + File.prototype.toString = function() { + return '[object File]'; + }; + + /** ***************************************************** */ + /* FileReader constructor */ + /** ***************************************************** */ + function FileReader() { + if (!(this instanceof FileReader)) + throw new TypeError( + "Failed to construct 'FileReader': Please use the 'new' operator, this DOM object constructor cannot be called as a function." + ); + + const delegate = document.createDocumentFragment(); + this.addEventListener = delegate.addEventListener; + this.dispatchEvent = function(evt) { + const local = this[`on${evt.type}`]; + if (typeof local === 'function') local(evt); + delegate.dispatchEvent(evt); + }; + this.removeEventListener = delegate.removeEventListener; + } + + function _read(fr, blob, kind) { + if (!(blob instanceof Blob)) + throw new TypeError( + `Failed to execute '${kind}' on 'FileReader': parameter 1 is not of type 'Blob'.` + ); + + fr.result = ''; + + setTimeout(function() { + this.readyState = FileReader.LOADING; + fr.dispatchEvent(new Event('load')); + fr.dispatchEvent(new Event('loadend')); + }); + } + + FileReader.EMPTY = 0; + FileReader.LOADING = 1; + FileReader.DONE = 2; + FileReader.prototype.error = null; + FileReader.prototype.onabort = null; + FileReader.prototype.onerror = null; + FileReader.prototype.onload = null; + FileReader.prototype.onloadend = null; + FileReader.prototype.onloadstart = null; + FileReader.prototype.onprogress = null; + + FileReader.prototype.readAsDataURL = function(blob) { + _read(this, blob, 'readAsDataURL'); + this.result = `data:${blob.type};base64,${encodeByteArray(blob._buffer)}`; + }; + + FileReader.prototype.readAsText = function(blob) { + _read(this, blob, 'readAsText'); + this.result = fromUtf8Array(blob._buffer); + }; + + FileReader.prototype.readAsArrayBuffer = function(blob) { + _read(this, blob, 'readAsText'); + this.result = blob._buffer.slice(); + }; + + FileReader.prototype.abort = function() {}; + + /** ***************************************************** */ + /* URL */ + /** ***************************************************** */ + URL.createObjectURL = function(blob) { + return blob instanceof Blob + ? `data:${blob.type};base64,${encodeByteArray(blob._buffer)}` + : createObjectURL.call(URL, blob); + }; + + URL.revokeObjectURL = function(url) { + revokeObjectURL && revokeObjectURL.call(URL, url); + }; + + /** ***************************************************** */ + /* XHR */ + /** ***************************************************** */ + const _send = global.XMLHttpRequest && global.XMLHttpRequest.prototype.send; + if (_send) { + XMLHttpRequest.prototype.send = function(data) { + if (data instanceof Blob) { + this.setRequestHeader('Content-Type', data.type); + _send.call(this, fromUtf8Array(data._buffer)); + } else { + _send.call(this, data); + } + }; + } + + global.FileReader = FileReader; + global.File = File; + global.Blob = Blob; + } + + if (strTag) { + File.prototype[strTag] = 'File'; + Blob.prototype[strTag] = 'Blob'; + FileReader.prototype[strTag] = 'FileReader'; + } + + function fixFileAndXHR() { + const isIE = + !!global.ActiveXObject || + ('-ms-scroll-limit' in document.documentElement.style && + '-ms-ime-align' in document.documentElement.style); + + // Monkey patched + // IE don't set Content-Type header on XHR whose body is a typed Blob + // https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/6047383 + const _send = global.XMLHttpRequest && global.XMLHttpRequest.prototype.send; + if (isIE && _send) { + XMLHttpRequest.prototype.send = function(data) { + if (data instanceof Blob) { + this.setRequestHeader('Content-Type', data.type); + _send.call(this, data); + } else { + _send.call(this, data); + } + }; + } + + try { + new File([], ''); + } catch (e) { + try { + var klass = new Function( + 'class File extends Blob {' + + 'constructor(chunks, name, opts) {' + + 'opts = opts || {};' + + 'super(chunks, opts || {});' + + 'this.name = name;' + + 'this.lastModifiedDate = opts.lastModified ? new Date(opts.lastModified) : new Date;' + + 'this.lastModified = +this.lastModifiedDate;' + + '}};' + + 'return new File([], ""), File' + )(); + global.File = klass; + } catch (e) { + var klass = function(b, d, c) { + const blob = new Blob(b, c); + const t = + c && void 0 !== c.lastModified + ? new Date(c.lastModified) + : new Date(); + + blob.name = d; + blob.lastModifiedDate = t; + blob.lastModified = +t; + blob.toString = function() { + return '[object File]'; + }; + + if (strTag) blob[strTag] = 'File'; + + return blob; + }; + global.File = klass; + } + } + } + + if (blobSupported) { + fixFileAndXHR(); + global.Blob = blobSupportsArrayBufferView ? global.Blob : BlobConstructor; + } else if (blobBuilderSupported) { + fixFileAndXHR(); + global.Blob = BlobBuilderConstructor; + } else { + FakeBlobBuilder(); + } +})(); diff --git a/package.json b/package.json index 5232f498..31c20f59 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dayjs": "^1.7.4", "debug": "^3.1.0", "deepmerge": "^2.1.0", + "file-saver": "^2.0.0", "i18next": "^11.3.3", "i18next-browser-languagedetector": "^2.2.3", "js-base64": "^2.4.5", diff --git a/src/index.js b/src/index.js index a9657e30..f5bc033e 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,7 @@ +// polyfills import 'promise-polyfill/src/polyfill'; +// shim Blob construct +import '../lib/blob/Blob'; import React from 'react'; import ReactDOM from 'react-dom'; diff --git a/src/pages/Dashboard/Versions/Detail/index.jsx b/src/pages/Dashboard/Versions/Detail/index.jsx index e8ac5fd5..a611f7ea 100644 --- a/src/pages/Dashboard/Versions/Detail/index.jsx +++ b/src/pages/Dashboard/Versions/Detail/index.jsx @@ -390,20 +390,19 @@ export default class VersionDetail extends Component { const errorFiles = _.keys(uploadError); const isEdit = version.status === 'draft' || version.status === 'rejected'; + const pkgName = packageName || `${appDetail.name}-${version.name}`; return ( {!isShowUpload && (
-
- {packageName || `${appDetail.name} ${version.name}`} -
+
{pkgName}
{t('Upload time')}:  {formatTime(version.status_time, 'YYYY/MM/DD HH:mm:ss')} downloadPackage(version.version_id)} + onClick={() => downloadPackage(version.version_id, pkgName)} > {t('Download')} diff --git a/src/stores/app/version.js b/src/stores/app/version.js index 7aec865b..a5303d8e 100644 --- a/src/stores/app/version.js +++ b/src/stores/app/version.js @@ -1,6 +1,7 @@ import { observable, action } from 'mobx'; import _, { get, assign, assignIn } from 'lodash'; import { Base64 } from 'js-base64'; +import { downloadFileFromBase64 } from 'utils'; import { reviewStatus } from 'config/version'; import Store from '../Store'; @@ -462,11 +463,13 @@ export default class AppVersionStore extends Store { }; @action - downloadPackage = async versionId => { + downloadPackage = async (versionId, pkgName) => { const result = await this.request.get('app_version/package', { version_id: versionId }); this.uploadFile = result.package; + + downloadFileFromBase64(this.uploadFile, pkgName); }; @action diff --git a/src/utils/index.js b/src/utils/index.js index 1758187f..d0fca8c2 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -1,6 +1,7 @@ import _, { get, filter, set } from 'lodash'; import day from 'dayjs'; import { t } from 'i18next'; +import { saveAs } from 'file-saver'; const formatMap = { 'YYYY/MM/DD': 'YYYY年MM月DD日', @@ -250,3 +251,22 @@ export const makeArray = val => { } return val; }; + +export const downloadFileFromBase64 = (base64_str = '', filename) => { + // Convert the Base64 string back to text. + const byteString = atob(base64_str); + + // Convert that text into a byte array. + const ab = new ArrayBuffer(byteString.length); + const ia = new Uint8Array(ab); + for (let i = 0; i < byteString.length; i++) { + ia[i] = byteString.charCodeAt(i); + } + + const blob = new Blob([ia], { type: 'application/tar+gzip' }); + + saveAs(blob, `${filename}.tgz`); + + // Alternatively, you could redirect to the blob to open it in the browser. + // document.location.href = window.URL.createObjectURL(blob); +}; diff --git a/yarn.lock b/yarn.lock index 9b404921..44a61af3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3793,6 +3793,11 @@ file-loader@^1.1.6: loader-utils "^1.0.2" schema-utils "^0.4.5" +file-saver@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-2.0.0.tgz#74eef7748159503b60008a15af2f1930fb5df7ab" + integrity sha512-cYM1ic5DAkg25pHKgi5f10ziAM7RJU37gaH1XQlyNDrtUnzhC/dfoV9zf2OmF0RMKi42jG5B0JWBnPQqyj/G6g== + filename-regex@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26"