diff --git a/package.json b/package.json index adaf90ae00..f9ac2d0643 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "memoize-immutable": "^3.0.0", "mixedtuplemap": "^1.0.0", "namedtuplemap": "^1.0.0", + "pako": "~1.0.2", "photon-colors": "3.3.2", "prop-types": "^15.7.2", "query-string": "^6.3.0", diff --git a/src/actions/receive-profile.js b/src/actions/receive-profile.js index 2ff9afb854..21d6a74875 100644 --- a/src/actions/receive-profile.js +++ b/src/actions/receive-profile.js @@ -44,6 +44,10 @@ import { getProfileOrNull } from '../selectors/profile'; import { getView } from '../selectors/app'; import { setDataSource } from './profile-view'; +import { + convertInstrumentsProfile, + isInstrumentsProfile, +} from '../profile-logic/import/instruments'; import type { FunctionsUpdatePerThread, FuncToFuncMap, @@ -910,8 +914,13 @@ export function retrieveProfileFromFile( // extensions (eg .profile). So we can't rely on the mime type to // decide how to handle them. We'll try to parse them as a plain JSON // file. - const text = await fileReader(file).asText(); - const profile = await unserializeProfileOfArbitraryFormat(text); + let profile; + if (isInstrumentsProfile(file)) { + profile = await convertInstrumentsProfile(file); + } else { + const text = await fileReader(file).asText(); + profile = await unserializeProfileOfArbitraryFormat(text); + } if (profile === undefined) { throw new Error('Unable to parse the profile.'); } diff --git a/src/components/app/Home.js b/src/components/app/Home.js index 9283f60bf2..f9e2a71b04 100644 --- a/src/components/app/Home.js +++ b/src/components/app/Home.js @@ -267,7 +267,33 @@ class Home extends React.PureComponent { return; } - const { files } = event.dataTransfer; + const { files, items } = event.dataTransfer; + + if (items && items.length > 0) { + const [firstItem] = items; + let webkitEntry = null; + + if ('webkitGetAsEntry' in firstItem) { + // DataTransferItem.webkitGetAsEntry() is a non-standard function for now. + // I have checked in Firefox and Chrome. It's working fine in it. + // We will remove FlowFixMe once it becomes a standard function (issue #2217) + // $FlowFixMe webkitGetAsEntry is not present in DataTransferItem + webkitEntry = firstItem.webkitGetAsEntry(); + } else if ('getAsEntry' in firstItem) { + // $FlowFixMe getAsEntry is not present in DataTransferItem (issue #2230) + webkitEntry = firstItem.getAsEntry(); + } + + if ( + webkitEntry !== null && + webkitEntry.isDirectory && + webkitEntry.name.endsWith('.trace') + ) { + this.props.retrieveProfileFromFile(webkitEntry); + return; + } + } + if (files.length > 0) { this.props.retrieveProfileFromFile(files[0]); } diff --git a/src/profile-logic/import/instruments/BinReader.js b/src/profile-logic/import/instruments/BinReader.js new file mode 100644 index 0000000000..cfddcf472e --- /dev/null +++ b/src/profile-logic/import/instruments/BinReader.js @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @flow + +// This class is inspired from here: https://github.com/jlfwong/speedscope/blob/master/src/import/instruments.ts#L200 +export class BinReader { + view: DataView; + bytePos: number; + + constructor(buffer: ArrayBuffer) { + this.view = new DataView(buffer); + this.bytePos = 0; + } + seek(pos: number) { + this.bytePos = pos; + } + skip(byteCount: number) { + this.bytePos += byteCount; + } + hasMore() { + return this.bytePos < this.view.byteLength; + } + bytesLeft() { + return this.view.byteLength - this.bytePos; + } + readUint8() { + this.bytePos++; + if (this.bytePos > this.view.byteLength) { + return 0; + } + return this.view.getUint8(this.bytePos - 1); + } + + readUint32() { + this.bytePos += 4; + if (this.bytePos > this.view.byteLength) { + return 0; + } + return this.view.getUint32(this.bytePos - 4, true); + } + readUint48() { + this.bytePos += 6; + if (this.bytePos > this.view.byteLength) { + return 0; + } + + // Note: we intentionally use Math.pow here rather than bit shifts + // because JavaScript doesn't have true 64 bit integers. + return ( + this.view.getUint32(this.bytePos - 6, true) + + this.view.getUint16(this.bytePos - 2, true) * Math.pow(2, 32) + ); + } + readUint64() { + this.bytePos += 8; + if (this.bytePos > this.view.byteLength) { + return 0; + } + return ( + this.view.getUint32(this.bytePos - 8, true) + + this.view.getUint32(this.bytePos - 4, true) * Math.pow(2, 32) + ); + } +} diff --git a/src/profile-logic/import/instruments/BinaryPlistParser.js b/src/profile-logic/import/instruments/BinaryPlistParser.js new file mode 100644 index 0000000000..474b48b3b8 --- /dev/null +++ b/src/profile-logic/import/instruments/BinaryPlistParser.js @@ -0,0 +1,220 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @flow + +export class UID { + index: number; + + constructor(index: number) { + this.index = index; + } +} + +// This class is inspired from here:https://github.com/jlfwong/speedscope/blob/master/src/import/instruments.ts#L828 +export class BinaryPlistParser { + view: DataView; + referenceSize: number; + offsetTable: Array; + + constructor(view: DataView) { + this.view = view; + this.referenceSize = 0; + this.offsetTable = []; + } + + parseRoot(): any { + const trailer = this.view.byteLength - 32; + const offsetSize = this.view.getUint8(trailer + 6); + this.referenceSize = this.view.getUint8(trailer + 7); + + // Just use the last 32-bits of these 64-bit big-endian values + const objectCount = this.view.getUint32(trailer + 12); + const rootIndex = this.view.getUint32(trailer + 20); + let tableOffset = this.view.getUint32(trailer + 28); + + // Parse all offsets before starting to parse objects + for (let i = 0; i < objectCount; i++) { + this.offsetTable.push(this.parseInteger(tableOffset, offsetSize)); + tableOffset += offsetSize; + } + + return this.parseObject(this.offsetTable[rootIndex]); + } + + parseLengthAndOffset(offset: number, extra: number) { + if (extra !== 0x0f) return { length: extra, offset: 0 }; + const marker = this.view.getUint8(offset++); + if ((marker & 0xf0) !== 0x10) { + throw new Error('Unexpected non-integer length at offset ' + offset); + } + const size = 1 << (marker & 0x0f); + return { length: this.parseInteger(offset, size), offset: size + 1 }; + } + + parseSingleton(offset: number, extra: number): any { + if (extra === 0) { + return null; + } + if (extra === 8) { + return false; + } + if (extra === 9) { + return true; + } + throw new Error('Unexpected extra value ' + extra + ' at offset ' + offset); + } + + parseInteger(offset: number, size: number): number { + if (size === 1) { + return this.view.getUint8(offset); + } + if (size === 2) { + return this.view.getUint16(offset, false); + } + if (size === 4) { + return this.view.getUint32(offset, false); + } + + if (size === 8) { + return ( + Math.pow(2, 32 * 1) * this.view.getUint32(offset + 0, false) + + Math.pow(2, 32 * 0) * this.view.getUint32(offset + 4, false) + ); + } + + if (size === 16) { + return ( + Math.pow(2, 32 * 3) * this.view.getUint32(offset + 0, false) + + Math.pow(2, 32 * 2) * this.view.getUint32(offset + 4, false) + + Math.pow(2, 32 * 1) * this.view.getUint32(offset + 8, false) + + Math.pow(2, 32 * 0) * this.view.getUint32(offset + 12, false) + ); + } + + throw new Error( + 'Unexpected integer of size ' + size + ' at offset ' + offset + ); + } + + parseFloat(offset: number, size: number): number { + if (size === 4) { + return this.view.getFloat32(offset, false); + } + if (size === 8) { + return this.view.getFloat64(offset, false); + } + throw new Error( + 'Unexpected float of size ' + size + ' at offset ' + offset + ); + } + + parseDate(offset: number, size: number): Date { + if (size !== 8) { + throw new Error( + 'Unexpected date of size ' + size + ' at offset ' + offset + ); + } + const seconds = this.view.getFloat64(offset, false); + return new Date(978307200000 + seconds * 1000); // Starts from January 1st, 2001 + } + + parseData(offset: number, extra: number): Uint8Array { + const both = this.parseLengthAndOffset(offset, extra); + + return new Uint8Array(this.view.buffer, offset + both.offset, both.length); + } + + parseStringASCII(offset: number, extra: number): string { + const both = this.parseLengthAndOffset(offset, extra); + let text = ''; + offset += both.offset; + for (let i = 0; i < both.length; i++) { + text += String.fromCharCode(this.view.getUint8(offset++)); + } + return text; + } + + parseStringUTF16(offset: number, extra: number): string { + const both = this.parseLengthAndOffset(offset, extra); + let text = ''; + offset += both.offset; + for (let i = 0; i < both.length; i++) { + text += String.fromCharCode(this.view.getUint16(offset, false)); + offset += 2; + } + return text; + } + + parseUID(offset: number, size: number): UID { + return new UID(this.parseInteger(offset, size)); + } + + parseArray(offset: number, extra: number): any[] { + const both = this.parseLengthAndOffset(offset, extra); + const array: any[] = []; + const size = this.referenceSize; + offset += both.offset; + for (let i = 0; i < both.length; i++) { + array.push( + this.parseObject(this.offsetTable[this.parseInteger(offset, size)]) + ); + offset += size; + } + return array; + } + + parseDictionary(offset: number, extra: number): Object { + const both = this.parseLengthAndOffset(offset, extra); + const dictionary = Object.create(null); + const size = this.referenceSize; + let nextKey = offset + both.offset; + let nextValue = nextKey + both.length * size; + for (let i = 0; i < both.length; i++) { + const key = this.parseObject( + this.offsetTable[this.parseInteger(nextKey, size)] + ); + const value = this.parseObject( + this.offsetTable[this.parseInteger(nextValue, size)] + ); + if (typeof key !== 'string') { + throw new Error('Unexpected non-string key at offset ' + nextKey); + } + dictionary[key] = value; + nextKey += size; + nextValue += size; + } + return dictionary; + } + + parseObject(offset: number): any { + const marker = this.view.getUint8(offset++); + const extra = marker & 0x0f; + switch (marker >> 4) { + case 0x0: + return this.parseSingleton(offset, extra); + case 0x1: + return this.parseInteger(offset, 1 << extra); + case 0x2: + return this.parseFloat(offset, 1 << extra); + case 0x3: + return this.parseDate(offset, 1 << extra); + case 0x4: + return this.parseData(offset, extra); + case 0x5: + return this.parseStringASCII(offset, extra); + case 0x6: + return this.parseStringUTF16(offset, extra); + case 0x8: + return this.parseUID(offset, extra + 1); + case 0xa: + return this.parseArray(offset, extra); + case 0xd: + return this.parseDictionary(offset, extra); + default: + throw new Error( + 'Unexpected marker ' + marker + ' at offset ' + --offset + ); + } + } +} diff --git a/src/profile-logic/import/instruments/MaybeCompressedReader.js b/src/profile-logic/import/instruments/MaybeCompressedReader.js new file mode 100644 index 0000000000..462f94bbc3 --- /dev/null +++ b/src/profile-logic/import/instruments/MaybeCompressedReader.js @@ -0,0 +1,76 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @flow +import { inflate } from 'pako'; + +// This class is inspired from here: https://github.com/jlfwong/speedscope/blob/master/src/import/utils.ts#L27 +class MaybeCompressedDataReader { + uncompressedData: Promise; + namePromise: Promise; + + constructor( + namePromise: Promise, + maybeCompressedDataPromise: Promise + ) { + this.uncompressedData = maybeCompressedDataPromise.then( + (fileData: ArrayBuffer) => { + try { + const result = inflate(new Uint8Array(fileData)).buffer; + return result; + } catch (e) { + return fileData; + } + } + ); + } + + async name(): Promise { + return this.namePromise; + } + + async readAsArrayBuffer(): Promise { + return this.uncompressedData; + } + + async readAsText(): Promise { + const buffer = await this.readAsArrayBuffer(); + let ret: string = ''; + + if (typeof TextDecoder !== 'undefined') { + const decoder = new TextDecoder(); + return decoder.decode(buffer); + } + // JavaScript strings are UTF-16 encoded, but we're reading data + // from disk that we're going to assume is UTF-8 encoded. + const array = new Uint8Array(buffer); + for (let i = 0; i < array.length; i++) { + ret += String.fromCharCode(array[i]); + } + return ret; + } + + static fromFile(file: File): MaybeCompressedDataReader { + const maybeCompressedDataPromise: Promise = new Promise( + resolve => { + const reader = new FileReader(); + reader.addEventListener('loadend', () => { + if (!(reader.result instanceof ArrayBuffer)) { + throw new Error( + 'Expected reader.result to be an instance of ArrayBuffer' + ); + } + resolve(reader.result); + }); + reader.readAsArrayBuffer(file); + } + ); + + return new MaybeCompressedDataReader( + Promise.resolve(file.name), + maybeCompressedDataPromise + ); + } +} + +export default MaybeCompressedDataReader; diff --git a/src/profile-logic/import/instruments/index.js b/src/profile-logic/import/instruments/index.js new file mode 100644 index 0000000000..93770c801a --- /dev/null +++ b/src/profile-logic/import/instruments/index.js @@ -0,0 +1,926 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @flow + +// This file is divided into 2 main parts: +// 1) Understand the Instruments profile's file structure and extract the relevant data +// 2) Fill all the data-structures within profile data-structure to support visualizing Instruments profiles into Firefox Profiler +// For understanding the structure and extracting the important data from Instruments profiles, +// I followed this great article: http://jamie-wong.com/post/reverse-engineering-instruments-file-format/ + +import { + getEmptyProfile, + getEmptyThread, +} from '../../../profile-logic/data-structures'; +import { BinaryPlistParser, UID } from './BinaryPlistParser'; +import { BinReader } from './BinReader'; +import MaybeCompressedReader from './MaybeCompressedReader'; +import { ensureExists } from '../../../utils/flow'; + +//types +import type { + Profile, + FuncTable, + FrameTable, + StackTable, +} from '../../../types/profile'; +import type { UniqueStringArray } from '../../../../src/utils/unique-string-array'; + +type InstrumentsSample = {| + +timestamp: number, + +threadID: number, + +backtraceID: number, +|}; + +type TraceDirectoryTree = {| + +name: string, + +files: Map, + +subdirectories: Map, +|}; + +type InstrumentsFrameInfo = {| + +key: string | number, + +name: string, + +file: string | null, + +line?: number, + +col?: number, +|}; + +type FormTemplateRunData = {| + +number: number, + +addressToFrameMap: Map, +|}; + +function readAsArrayBuffer(file: File): Promise { + return MaybeCompressedReader.fromFile(file).readAsArrayBuffer(); +} + +function readAsText(file: File): Promise { + return MaybeCompressedReader.fromFile(file).readAsText(); +} + +function parseBinaryPlist(bytes: Uint8Array) { + return new BinaryPlistParser( + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + ).parseRoot(); +} + +function decodeUTF8(bytes: Uint8Array): string { + const textDecoder = new TextDecoder(); + if (bytes[bytes.length - 1] === 0) { + // Remove a single trailing null byte if present. + return textDecoder.decode(bytes.subarray(0, -1)); + } + return textDecoder.decode(bytes); +} + +function followUID( + objects: any[], + value: UID +): { $classes: Array, $classname: string } { + return value instanceof UID ? objects[value.index] : value; +} + +// This function constructs an object from given interpreter class and property list +// I have taken inspiration for this function from here: +// https://github.com/jlfwong/speedscope/blob/9edd5ce7ed6aaf9290d57e85f125c648a3b66d1f/import/instruments.ts#L648 +// Apple BinaryPlist data format is very unstructured. +// For example, objects (first parameter in patternMatchObjectiveC function) +// So it's hard to define a type for this kind of arrays or objects. That's why I had to put any types for these kinds of instances. +function patternMatchObjectiveC( + objects: any[], + value: any, + interpretClass: ($classname: string, obj: any) => any +): any { + if (isDictionary(value) && value.$class) { + const name = followUID(objects, value.$class).$classname; + switch (name) { + case 'NSDecimalNumberPlaceholder': { + const length: number = value['NS.length']; + const exponent: number = value['NS.exponent']; + const byteOrder: number = value['NS.mantissa.bo']; + const negative: boolean = value['NS.negative']; + const mantissa = new Uint16Array( + new Uint8Array(value['NS.mantissa']).buffer + ); + let decimal = 0; + + for (let i = 0; i < length; i++) { + let digit = mantissa[i]; + + if (byteOrder !== 1) { + digit = ((digit & 0xff00) >> 8) | ((digit & 0x00ff) << 8); + } + + decimal += digit * Math.pow(65536, i); + } + + decimal *= Math.pow(10, exponent); + return negative ? -decimal : decimal; + } + + // Replace NSData with a Uint8Array + case 'NSData': + case 'NSMutableData': + return value['NS.bytes'] || value['NS.data']; + + // Replace NSString with a string + case 'NSString': + case 'NSMutableString': + return value['NS.bytes'] ? decodeUTF8(value['NS.bytes']) : ''; + + // Replace NSArray with an Array + case 'NSArray': + case 'NSMutableArray': { + if ('NS.objects' in value) { + return value['NS.objects']; + } + const array: number[] = []; + while (true) { + const object = 'NS.object.' + array.length; + if (!(object in value)) { + break; + } + array.push(value[object]); + } + return array; + } + case '_NSKeyedCoderOldStyleArray': { + const count = value['NS.count']; + + // const size = value['NS.size'] + // Types are encoded as single printable characters. + // See: https://github.com/apple/swift-corelibs-foundation/blob/76995e8d3d8c10f3f3ec344dace43426ab941d0e/Foundation/NSObjCRuntime.swift#L19 + // const type = String.fromCharCode(value['NS.type']) + + const array: any[] = []; + for (let i = 0; i < count; i++) { + const element = value['$' + i]; + array.push(element); + } + return array; + } + + case 'NSDictionary': + case 'NSMutableDictionary': { + const map = new Map(); + if ('NS.keys' in value && 'NS.objects' in value) { + for (let i = 0; i < value['NS.keys'].length; i++) { + map.set(value['NS.keys'][i], value['NS.objects'][i]); + } + } else { + while (true) { + const key = 'NS.key.' + map.size; + const object = 'NS.object.' + map.size; + if (!(key in value) || !(object in value)) { + break; + } + map.set(value[key], value[object]); + } + } + return map; + } + default: { + const converted = interpretClass(name, value); + if (converted !== value) { + return converted; + } + } + } + } + return value; +} + +function isDictionary(value: any): boolean { + return ( + value !== null && + typeof value === 'object' && + Object.getPrototypeOf(value) === null + ); +} + +// This function populates data fields with given interpretClass(decided by various datatypes +// from readInstrumentsArchive function) +function expandKeyedArchive( + root: { + $archiver: string, + $version: number, + $objects: Array, + $top: any, + }, + interpretClass: ($classname: string, obj: any) => any +): any { + // Sanity checks + if ( + root.$version !== 100000 || + root.$archiver !== 'NSKeyedArchiver' || + !isDictionary(root.$top) || + !Array.isArray(root.$objects) + ) { + throw new Error('Invalid keyed archive'); + } + + // Substitute NSNull + if (root.$objects[0] === '$null') { + root.$objects[0] = null; + } + + // Pattern-match Objective-C constructs + for (let i = 0; i < root.$objects.length; i++) { + root.$objects[i] = patternMatchObjectiveC( + root.$objects, + root.$objects[i], + interpretClass + ); + } + + const visit = (object: any): any => { + if (object instanceof UID) { + return root.$objects[object.index]; + } else if (Array.isArray(object)) { + for (let i = 0; i < object.length; i++) { + object[i] = visit(object[i]); + } + } else if (isDictionary(object)) { + for (const key in object) { + object[key] = visit(object[key]); + } + } else if (object instanceof Map) { + const clone = new Map(object); + object.clear(); + for (const [k, v] of clone.entries()) { + object.set(visit(k), visit(v)); + } + } + return object; + }; + for (let i = 0; i < root.$objects.length; i++) { + visit(root.$objects[i]); + } + return visit(root.$top); +} + +// This function creates an archived data for given buffer by interpreting various Instruments specific data types +function readInstrumentsArchive(buffer: ArrayBuffer) { + const byteArray = new Uint8Array(buffer); + const parsedPlist = parseBinaryPlist(byteArray); + + const data = expandKeyedArchive(parsedPlist, ($classname, object) => { + switch ($classname) { + case 'NSTextStorage': + case 'NSParagraphStyle': + case 'NSFont': + // Stuff that's irrelevant for constructing a flamegraph + return null; + + case 'PFTSymbolData': { + const ret = Object.create(null); + ret.symbolName = object.$0; + ret.sourcePath = object.$1; + ret.addressToLine = new Map(); + for (let i = 3; ; i += 2) { + const address = object['$' + i]; + const line = object['$' + (i + 1)]; + // eslint-disable-next-line eqeqeq + if (address == null || line == null) { + break; + } + ret.addressToLine.set(address, line); + } + return ret; + } + + case 'PFTOwnerData': { + const ret = Object.create(null); + ret.ownerName = object.$0; + ret.ownerPath = object.$1; + return ret; + } + + case 'PFTPersistentSymbols': { + const ret = Object.create(null); + const symbolCount = object.$4; + + ret.threadNames = object.$3; + ret.symbols = []; + for (let i = 1; i < symbolCount; i++) { + ret.symbols.push(object['$' + (4 + i)]); + } + return ret; + } + + case 'XRRunListData': { + const ret = Object.create(null); + ret.runNumbers = object.$0; + ret.runData = object.$1; + return ret; + } + + case 'XRIntKeyedDictionary': { + const ret = new Map(); + const size = object.$0; + for (let i = 0; i < size; i++) { + const key = object['$' + (1 + 2 * i)]; + const value = object['$' + (1 + (2 * i + 1))]; + ret.set(key, value); + } + return ret; + } + + case 'XRCore': { + const ret = Object.create(null); + ret.number = object.$0; + ret.name = object.$1; + return ret; + } + default: + } + return object; + }); + return data; +} + +// This function adds a padding ahead of given string to make its length equal to a given width value +function zeroPad(s: string, width: number) { + return s.padStart(width, '0'); +} + +function setIfAbsent(map: Map, k: K, fallback: (k: K) => V): V | K { + if (!map.has(k)) { + map.set(k, fallback(k)); + return k; + } + return map.get(k); +} + +// This function extracts arrays of backtraceIDs which contains information about backtrace in recursive manner +async function getIntegerArrays(core: TraceDirectoryTree): Promise { + const uniquing = ensureExists(core.subdirectories.get('uniquing')); + const arrayUniquer = ensureExists( + uniquing.subdirectories.get('arrayUniquer') + ); + const integeruniquerindex = ensureExists( + arrayUniquer.files.get('integeruniquer.index') + ); + const integeruniquerdata = ensureExists( + arrayUniquer.files.get('integeruniquer.data') + ); + + // integeruniquer.index is a binary file containing an array of [byte offset, MB offset] pairs + // that indicate where array data starts in the .data file + + // integeruniquer.data is a binary file containing an array of arrays of 64 bit integer. + // The schema is a 32 byte header followed by a stream of arrays. + // Each array consists of a 4 byte size N followed by N 8 byte little endian integers + + // This table contains the memory addresses of stack frames + + const indexreader = new BinReader( + await readAsArrayBuffer(integeruniquerindex) + ); + const datareader = new BinReader(await readAsArrayBuffer(integeruniquerdata)); + + indexreader.seek(32); + + const arrays: number[][] = []; + + while (indexreader.hasMore()) { + const byteOffset = + indexreader.readUint32() + indexreader.readUint32() * (1024 * 1024); + + if (byteOffset === 0) { + // The first entry in the index table seems to just indicate the offset of + // the header into the data file + continue; + } + + datareader.seek(byteOffset); + + let length = datareader.readUint32(); + const array: number[] = []; + + while (length--) { + array.push(datareader.readUint64()); + } + arrays.push(array); + } + + return arrays; +} + +// This function extracts samples from given core file +async function getRawSampleList( + core: TraceDirectoryTree +): Promise { + const stores = ensureExists(core.subdirectories.get('stores')); + for (const storedir of stores.subdirectories.values()) { + const schemaFile = storedir.files.get('schema.xml'); + if (!schemaFile) { + continue; + } + const schema = await readAsText(schemaFile); + if (!/name="time-profile"/.exec(schema)) { + continue; + } + const bulkstore = new BinReader( + await readAsArrayBuffer(ensureExists(storedir.files.get('bulkstore'))) + ); + // Ignore the first 3 words + bulkstore.readUint32(); + bulkstore.readUint32(); + bulkstore.readUint32(); + const headerSize = bulkstore.readUint32(); + const bytesPerEntry = bulkstore.readUint32(); + + bulkstore.seek(headerSize); + + const samples: InstrumentsSample[] = []; + while (true) { + // Schema as of Instruments 8.3.3 is a 6 byte timestamp, followed by a bunch + // of stuff we don't care about, followed by a 4 byte backtrace ID + const timestamp = bulkstore.readUint48(); + if (timestamp === 0) { + break; + } + + const threadID = bulkstore.readUint32(); + + bulkstore.skip(bytesPerEntry - 6 - 4 - 4); + const backtraceID = bulkstore.readUint32(); + samples.push({ timestamp, threadID, backtraceID }); + } + return samples; + } + throw new Error('Could not find sample list'); +} + +// This function extracts core directory file which contains information about samples +// path: tree.subdirectories.corespace.subdirectories.run${runNumber}.core +function getCoreDirForRun( + tree: TraceDirectoryTree, + selectedRun: number +): TraceDirectoryTree { + const corespace = ensureExists(tree.subdirectories.get('corespace')); + const corespaceRunDir = ensureExists( + corespace.subdirectories.get(`run${selectedRun}`) + ); + return ensureExists(corespaceRunDir.subdirectories.get('core')); +} + +// This function mainly extracts two entities: samples and arrays +// Here arrays contains all the information about stack trace at a given timestamp. +// Each samples has a field named 'backtraceID' which is an index into arrays +// Iterating recursively into arrays by given backtraceID it extracts a backtraceStack for each sample +async function getSamples(args: { + tree: TraceDirectoryTree, + addressToFrameMap: Map, + runNumber: number, +}): Promise<{ + samples: Array, + backtraceIDtoStack: Map>, +}> { + const { tree, addressToFrameMap, runNumber } = args; + const core = getCoreDirForRun(tree, runNumber); + const samples = await getRawSampleList(core); + const arrays = await getIntegerArrays(core); + const backtraceIDtoStack = new Map>(); + + function appendRecursive(k: number, stack: Array) { + const frame = addressToFrameMap.get(k); + if (frame) { + stack.push(k); + return; + } + + const arrayIndex = k & 0xffffffff; + if (arrayIndex in arrays) { + for (const addr of arrays[arrayIndex]) { + appendRecursive(addr, stack); + } + return; + } + + // Fallback: We are not able to find the address, so we will just + // display the address instead of frame information + const rawAddressFrame: InstrumentsFrameInfo = { + key: k, + name: `0x${zeroPad(k.toString(16), 16)}`, + file: null, + }; + addressToFrameMap.set(k, rawAddressFrame); + stack.push(k); + } + + for (const sample of samples) { + setIfAbsent(backtraceIDtoStack, sample.backtraceID, id => { + const stack: Array = []; + appendRecursive(id, stack); + stack.reverse(); + return stack; + }); + } + + return Promise.resolve({ samples, backtraceIDtoStack }); +} + +// This function reads the 'form.template' file which contains all the important information about +// addressToFrameMap and Instruments' version +async function readFormTemplateFile( + tree +): Promise<{ + version: number, + instrumentType: string, + selectedRunNumber: number, + runs: Array, +}> { + const formTemplate = tree.files.get('form.template'); + const archive = + typeof formTemplate !== 'undefined' + ? readInstrumentsArchive(await readAsArrayBuffer(formTemplate)) + : {}; + + const version = archive['com.apple.xray.owner.template.version']; + let selectedRunNumber = 1; + if ('com.apple.xray.owner.template' in archive) { + selectedRunNumber = archive['com.apple.xray.owner.template'].get( + '_selectedRunNumber' + ); + } + let instrumentType = archive.$1; + if ('stubInfoByUUID' in archive) { + instrumentType = Array.from(archive.stubInfoByUUID.keys())[0]; + } + const allRunData = archive['com.apple.xray.run.data']; + + const runs: FormTemplateRunData[] = []; + for (const runNumber of allRunData.runNumbers) { + const runData: Map = ensureExists( + allRunData.runData.get(runNumber) + ); + const symbolsByPid = ensureExists(runData.get('symbolsByPid')); + + const addressToFrameMap = new Map(); + for (const symbols of symbolsByPid.values()) { + for (const symbol of symbols.symbols) { + if (!symbol) { + continue; + } + const { sourcePath, symbolName, addressToLine } = symbol; + for (const address of addressToLine.keys()) { + setIfAbsent(addressToFrameMap, address, () => { + const name = symbolName || `0x${zeroPad(address.toString(16), 16)}`; + const frame: InstrumentsFrameInfo = { + key: `${sourcePath}:${name}`, + name: name, + file: sourcePath || null, + }; + return frame; + }); + } + } + + runs.push({ + number: runNumber, + addressToFrameMap, + }); + } + } + + return { + version, + instrumentType, + selectedRunNumber, + runs, + }; +} + +// This function returns a directory tree where each node of tree +// is a object consist of name, files and subdirectories fields +async function extractDirectoryTree(entry: { + name: string, +}): Promise { + const node = { + name: entry.name, + files: new Map(), + subdirectories: new Map(), + }; + + const children = await new Promise((resolve, reject) => { + // FileSystemDirectoryEntry.createReader() is a non-standard function for now. + // I have checked in Firefox and Chrome. It's working fine in it. + // We will remove FlowFixMe once it becomes a standard function (issue #2218) + // $FlowFixMe createReader is not present in entry + entry.createReader().readEntries((entries: Array) => { + resolve(entries); + }, reject); + }); + + for (const child of children) { + if (child.isDirectory) { + const subtree = await extractDirectoryTree(child); + node.subdirectories.set(subtree.name, subtree); + } else { + const file = await new Promise((resolve, reject) => { + child.file(resolve, reject); + }); + node.files.set(file.name, file); + } + } + + return node; +} + +// This function checks weather the given profile is of Instruments type or not by checking the extension as a 'trace' +export function isInstrumentsProfile(file: mixed): boolean { + let fileName = ''; + let isDirectory = false; + if (file && typeof file === 'object') { + if (typeof file.name === 'string') { + fileName = file.name; + } + if (typeof file.isDirectory === 'boolean') { + isDirectory = file.isDirectory; + } + } + + const fileMetaData = fileName.split('.'); + if ( + fileMetaData.length === 1 || + (fileMetaData[0] === '' && fileMetaData.length === 2) + ) { + return false; + } + + return fileMetaData.pop() === 'trace' && isDirectory; +} + +// This function return an index of function from funcTable if it already exists otherwise +// it creates a new entry into funcTable and returns that index +function getOrCreateFunc( + funcTable: FuncTable, + funcKeyToIndex: Map, + stringTable: UniqueStringArray, + name: string, + fileName: string, + funcKey: string +): number { + let indexToFunc = -1; + + if (funcKeyToIndex.has(funcKey)) { + indexToFunc = funcKeyToIndex.get(funcKey); + } else { + funcKeyToIndex.set(funcKey, funcTable.length); + funcTable.name.push(stringTable.indexForString(name)); + funcTable.fileName.push(stringTable.indexForString(fileName)); + funcTable.isJS.push(false); + funcTable.resource.push(-1); + funcTable.lineNumber.push(null); + funcTable.columnNumber.push(null); + indexToFunc = funcTable.length; + funcTable.length++; + } + + if (typeof indexToFunc === 'number') return indexToFunc; + + throw new Error(`Error in finding the indexToFunc`); +} + +// This function creates a new frame inside frameTable +function createFrame( + frameTable: FrameTable, + stringTable: UniqueStringArray, + frameKeyToIndex: Map, + indexToFunc: number, + frameAddress: string +): void { + frameTable.func.push(indexToFunc); + frameTable.category.push(1); // TODO: Make the function to get the index of 'Other' category + frameTable.address.push(stringTable.indexForString(`${frameAddress}`)); + frameTable.implementation.push(null); + frameTable.line.push(null); + frameTable.column.push(null); + frameKeyToIndex.set(frameAddress, frameTable.length); + frameTable.length++; +} + +// This function creates a new stack inside stackTable +function createStack( + stackTable: StackTable, + stringTable: UniqueStringArray, + stackKeyToIndex: Map, + stackKey: string, + parentIndex: number | null, + frame: number +): void { + stackTable.prefix.push(parentIndex); + stackTable.frame.push(frame); + stackKeyToIndex.set(stackKey, stackTable.length); + stackTable.category.push(1); + stackTable.length++; +} + +// This function returns a processed thread with all the tables filled( funcTable, frameTable, stackTable, stringTable and samples) +// addressToFrame map here is a map between frameAddress and details for that frame. +// Each sample is a tuple made up of (timestamp, threadID, backtraceID, backtraceStack) +function getProcessedThread( + threadId: number, + samples: Array, + addressToFrameMap: Map, + backtraceIDtoStack: Map> +) { + const thread = getEmptyThread(); + const { + funcTable, + frameTable, + stackTable, + stringTable, + samples: samplesTable, + } = thread; + + thread.name = `Thread ${threadId}`; + + const funcKeyToIndex = new Map(); + const frameKeyToIndex = new Map(); + const stackKeyToIndex = new Map(); + + // We don't have (root) function in our data. + // So, we have to explicitly add it in the funcTable, frameTable and then stackTable + // We have defined the address of (root) frame as $00000000. + // $ sign here is intentionally, because we might get a frame whose address is '00000000' + + const rootFuncName = '(root)'; + const rootFrameAddress = '$00000000'; + const rootFuncFileName = ''; + const rootFuncKey = '(root)'; + + const indexOfRootFunc = getOrCreateFunc( + funcTable, + funcKeyToIndex, + stringTable, + rootFuncName, + rootFuncFileName, + rootFuncKey + ); + + createFrame( + frameTable, + stringTable, + frameKeyToIndex, + indexOfRootFunc, + rootFrameAddress + ); + + const rootStackKey = 'rootStack'; + const rootFrameIndex = frameKeyToIndex.get(rootFrameAddress); + + if (typeof rootFrameIndex === 'number') { + createStack( + stackTable, + stringTable, + stackKeyToIndex, + rootStackKey, + null, + rootFrameIndex + ); + } + + for (const frameData of addressToFrameMap) { + const frameMetaData = frameData[1]; + const frameAddress = frameData[0]; + + const indexToFunc = getOrCreateFunc( + funcTable, + funcKeyToIndex, + stringTable, + frameMetaData.name, + frameMetaData.file || '', + frameMetaData.key + '' + ); + + createFrame( + frameTable, + stringTable, + frameKeyToIndex, + indexToFunc, + frameAddress + '' + ); + } + + for (const sample of samples) { + const stackTrace = backtraceIDtoStack.get(sample.backtraceID); + let parentIndex = stackKeyToIndex.get(rootStackKey); + if (stackTrace) { + for (let index = 0; index < stackTrace.length; index++) { + const frameAddress = stackTrace[index]; + const keyOfStackKeyToIndexMap = + typeof parentIndex === 'number' + ? '$' + parentIndex + '$' + frameAddress + : '$' + frameAddress; + + if (!stackKeyToIndex.has(keyOfStackKeyToIndexMap)) { + const frameIndex = frameKeyToIndex.get(frameAddress + ''); + + if ( + typeof frameIndex === 'number' && + typeof parentIndex === 'number' + ) { + createStack( + stackTable, + stringTable, + stackKeyToIndex, + keyOfStackKeyToIndexMap, + parentIndex, + frameIndex + ); + } + } + + parentIndex = stackKeyToIndex.get(keyOfStackKeyToIndexMap); + + if (index === stackTrace.length - 1) { + const stackForSample = stackKeyToIndex.get(keyOfStackKeyToIndexMap); + if (stackForSample) { + samplesTable.stack.push(stackForSample); + } else { + samplesTable.stack.push(null); + } + samplesTable.time.push(sample.timestamp / 1000000); + samplesTable.responsiveness.push(null); + samplesTable.length++; + } + } + } + } + + return thread; +} + +// This function creates a thread for each group of samples(by threadID) +function getProcessedProfile( + addressToFrameMap: Map, + samples: Array, + backtraceIDtoStack: Map> +): Profile { + const threadIDToSamples = new Map(); + const profile = getEmptyProfile(); + for (const sample of samples) { + const samplesArray = threadIDToSamples.get(sample.threadID); + if (samplesArray) { + samplesArray.push(sample); + } else { + threadIDToSamples.set(sample.threadID, [sample]); + } + } + + for (const threadID of threadIDToSamples.keys()) { + const samples = threadIDToSamples.get(threadID); + let processedThread = getEmptyThread(); + if (samples) { + processedThread = getProcessedThread( + threadID, + samples, + addressToFrameMap, + backtraceIDtoStack + ); + } + profile.threads.push(processedThread); + } + + profile.meta.platform = 'Macintosh'; + + return profile; +} + +// This is the root function of getting processed Instruments profile. +// Here entry is an instance of FileSystemDirectoryEntry +export async function convertInstrumentsProfile(entry: { + name: string, +}): Promise { + const tree = await extractDirectoryTree(entry); + const { runs, instrumentType } = await readFormTemplateFile(tree); + + if (instrumentType !== 'com.apple.xray.instrument-type.coresampler2') { + throw new Error( + `The only supported instrument from .trace import is "com.apple.xray.instrument-type.coresampler2". Got ${instrumentType}` + ); + } + + // For now, we are just processing first run + // In future, we will add support for processing every runs (issue #2215) + const { addressToFrameMap, number } = runs[0]; + + const { samples, backtraceIDtoStack } = await getSamples({ + tree, + addressToFrameMap, + runNumber: number, + }); + + const processedProfile = getProcessedProfile( + addressToFrameMap, + samples, + backtraceIDtoStack + ); + + return processedProfile; +} diff --git a/src/test/fixtures/upgrades/simple-time-profile-10_0_0.trace.zip b/src/test/fixtures/upgrades/simple-time-profile-10_0_0.trace.zip new file mode 100644 index 0000000000..6d0c1eb3a1 Binary files /dev/null and b/src/test/fixtures/upgrades/simple-time-profile-10_0_0.trace.zip differ diff --git a/src/test/fixtures/upgrades/simple-time-profile-10_1_0.trace.zip b/src/test/fixtures/upgrades/simple-time-profile-10_1_0.trace.zip new file mode 100644 index 0000000000..ee0062b472 Binary files /dev/null and b/src/test/fixtures/upgrades/simple-time-profile-10_1_0.trace.zip differ diff --git a/src/test/fixtures/upgrades/simple-time-profile-9_3_1.trace.zip b/src/test/fixtures/upgrades/simple-time-profile-9_3_1.trace.zip new file mode 100644 index 0000000000..28b0b2e828 Binary files /dev/null and b/src/test/fixtures/upgrades/simple-time-profile-9_3_1.trace.zip differ diff --git a/src/test/unit/instruments.test.js b/src/test/unit/instruments.test.js new file mode 100644 index 0000000000..796e52b11e --- /dev/null +++ b/src/test/unit/instruments.test.js @@ -0,0 +1,111 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// @flow +import JSZip from 'jszip'; +import fs from 'fs'; +import path from 'path'; + +import { convertInstrumentsProfile } from '../../profile-logic/import/instruments'; + +// This class is a mocked version of native FileSystemEntry class +// Reference: https://developer.mozilla.org/en-US/docs/Web/API/FileSystemEntry +class MockFileSystemEntry { + //standard properties + isFile: boolean; + isDirectory: boolean; + name: string; + fullPath: string; + + //profiler-specific properties + _zip: typeof JSZip; + _zipDir: typeof JSZip; + _zipFile: JSZip.JSZipObject | null; + + constructor(zip: typeof JSZip, fullPath: string) { + this.fullPath = fullPath; + this._zipFile = zip.file(fullPath); + this.isFile = !!this._zipFile; + this._zip = zip; + + if (this.isFile) { + this._zipDir = null; + this.isDirectory = false; + } else { + this._zipDir = zip.folder(fullPath); + this.isDirectory = true; + } + + this.name = path.basename(this.fullPath); + } + + file(cb: (file: File) => void, errCb: (error: Error) => void) { + if (!this._zipFile) { + return errCb(new Error('Failed to extract file')); + } + + this._zipFile.async('blob').then(blob => { + blob.name = this.name; + cb(blob); + }, errCb); + + return undefined; + } + + // In real FileSystemEntry class, createReader function would be present only when the zipDir is true, + // I've kept it here regardless the value of zipDir for simplicity + createReader() { + return { + readEntries: ( + cb: (entries: []) => void, + errCb: (error: Error) => void + ) => { + if (!this._zipDir) { + return errCb(new Error('Failed to read folder entries')); + } + const ret = []; + this._zipDir.forEach((relativePath: string, file: { name: string }) => { + const relativePathLength = relativePath.endsWith('/') ? 2 : 1; + + if (relativePath.split('/').length === relativePathLength) { + ret.push(new MockFileSystemEntry(this._zip, file.name)); + } + }); + return cb(ret); + }, + }; + } +} + +describe('convertInstrumentsProfile function', () => { + async function importFromTrace(tracePath: string, fileName: string) { + const data = fs.readFileSync(tracePath); + const zip = await JSZip.loadAsync(data); + const root = new MockFileSystemEntry(zip, fileName); + const profile = await convertInstrumentsProfile(root); + + return profile; + } + + test('Can import Instruments 9.3.1 profile', async () => { + await importFromTrace( + 'src/test/fixtures/upgrades/simple-time-profile-9_3_1.trace.zip', + 'simple-time-profile.trace' + ); + }); + + test('Can import Instruments 10.0.0 profile', async () => { + await importFromTrace( + 'src/test/fixtures/upgrades/simple-time-profile-10_0_0.trace.zip', + 'simple-time-profile.trace' + ); + }); + + test('Can import Instruments 10.1.0 profile', async () => { + await importFromTrace( + 'src/test/fixtures/upgrades/simple-time-profile-10_1_0.trace.zip', + 'simple-time-profile.trace' + ); + }); +}); diff --git a/src/types/libdef/npm-custom/pako_vx.x.x.js b/src/types/libdef/npm-custom/pako_vx.x.x.js new file mode 100644 index 0000000000..bf25bfe08c --- /dev/null +++ b/src/types/libdef/npm-custom/pako_vx.x.x.js @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @flow + +declare module 'pako' { + declare type pako$InflateOptions = {| + +windowBits?: number, + +raw?: boolean, + +chunkSize?: number, + +dictionary?: string | ArrayBuffer | Uint8Array, + |}; + declare function inflate( + buffer: Uint8Array | number[] | string, + options?: pako$InflateOptions + ): Uint8Array; + declare function inflate( + buffer: Uint8Array | number[] | string, + options: {| ...pako$InflateOptions, +to: 'string' |} + ): string; + declare module.exports: { inflate: typeof inflate }; +} diff --git a/src/types/libdef/npm/jszip_vx.x.x.js b/src/types/libdef/npm/jszip_vx.x.x.js new file mode 100644 index 0000000000..5e8f18efdd --- /dev/null +++ b/src/types/libdef/npm/jszip_vx.x.x.js @@ -0,0 +1,298 @@ +// flow-typed signature: 0a6309979753ba5de93aa92991e7d57f +// flow-typed version: <>/jszip_v^3.1.5/flow_v0.96.0 + +/** + * This is an autogenerated libdef stub for: + * + * 'jszip' + * + * Fill this stub out by replacing all the `any` types. + * + * Once filled out, we encourage you to share your work with the + * community by sending a pull request to: + * https://github.com/flowtype/flow-typed + */ + +declare module 'jszip' { + declare module.exports: any; +} + +/** + * We include stubs for each file inside this npm package in case you need to + * require those files directly. Feel free to delete any files that aren't + * needed. + */ +declare module 'jszip/dist/jszip' { + declare module.exports: any; +} + +declare module 'jszip/dist/jszip.min' { + declare module.exports: any; +} + +declare module 'jszip/lib/base64' { + declare module.exports: any; +} + +declare module 'jszip/lib/compressedObject' { + declare module.exports: any; +} + +declare module 'jszip/lib/compressions' { + declare module.exports: any; +} + +declare module 'jszip/lib/crc32' { + declare module.exports: any; +} + +declare module 'jszip/lib/defaults' { + declare module.exports: any; +} + +declare module 'jszip/lib/external' { + declare module.exports: any; +} + +declare module 'jszip/lib/flate' { + declare module.exports: any; +} + +declare module 'jszip/lib/generate/index' { + declare module.exports: any; +} + +declare module 'jszip/lib/generate/ZipFileWorker' { + declare module.exports: any; +} + +declare module 'jszip/lib/index' { + declare module.exports: any; +} + +declare module 'jszip/lib/license_header' { + declare module.exports: any; +} + +declare module 'jszip/lib/load' { + declare module.exports: any; +} + +declare module 'jszip/lib/nodejs/NodejsStreamInputAdapter' { + declare module.exports: any; +} + +declare module 'jszip/lib/nodejs/NodejsStreamOutputAdapter' { + declare module.exports: any; +} + +declare module 'jszip/lib/nodejsUtils' { + declare module.exports: any; +} + +declare module 'jszip/lib/object' { + declare module.exports: any; +} + +declare module 'jszip/lib/readable-stream-browser' { + declare module.exports: any; +} + +declare module 'jszip/lib/reader/ArrayReader' { + declare module.exports: any; +} + +declare module 'jszip/lib/reader/DataReader' { + declare module.exports: any; +} + +declare module 'jszip/lib/reader/NodeBufferReader' { + declare module.exports: any; +} + +declare module 'jszip/lib/reader/readerFor' { + declare module.exports: any; +} + +declare module 'jszip/lib/reader/StringReader' { + declare module.exports: any; +} + +declare module 'jszip/lib/reader/Uint8ArrayReader' { + declare module.exports: any; +} + +declare module 'jszip/lib/signature' { + declare module.exports: any; +} + +declare module 'jszip/lib/stream/ConvertWorker' { + declare module.exports: any; +} + +declare module 'jszip/lib/stream/Crc32Probe' { + declare module.exports: any; +} + +declare module 'jszip/lib/stream/DataLengthProbe' { + declare module.exports: any; +} + +declare module 'jszip/lib/stream/DataWorker' { + declare module.exports: any; +} + +declare module 'jszip/lib/stream/GenericWorker' { + declare module.exports: any; +} + +declare module 'jszip/lib/stream/StreamHelper' { + declare module.exports: any; +} + +declare module 'jszip/lib/support' { + declare module.exports: any; +} + +declare module 'jszip/lib/utf8' { + declare module.exports: any; +} + +declare module 'jszip/lib/utils' { + declare module.exports: any; +} + +declare module 'jszip/lib/zipEntries' { + declare module.exports: any; +} + +declare module 'jszip/lib/zipEntry' { + declare module.exports: any; +} + +declare module 'jszip/lib/zipObject' { + declare module.exports: any; +} + +declare module 'jszip/vendor/FileSaver' { + declare module.exports: any; +} + +// Filename aliases +declare module 'jszip/dist/jszip.js' { + declare module.exports: $Exports<'jszip/dist/jszip'>; +} +declare module 'jszip/dist/jszip.min.js' { + declare module.exports: $Exports<'jszip/dist/jszip.min'>; +} +declare module 'jszip/lib/base64.js' { + declare module.exports: $Exports<'jszip/lib/base64'>; +} +declare module 'jszip/lib/compressedObject.js' { + declare module.exports: $Exports<'jszip/lib/compressedObject'>; +} +declare module 'jszip/lib/compressions.js' { + declare module.exports: $Exports<'jszip/lib/compressions'>; +} +declare module 'jszip/lib/crc32.js' { + declare module.exports: $Exports<'jszip/lib/crc32'>; +} +declare module 'jszip/lib/defaults.js' { + declare module.exports: $Exports<'jszip/lib/defaults'>; +} +declare module 'jszip/lib/external.js' { + declare module.exports: $Exports<'jszip/lib/external'>; +} +declare module 'jszip/lib/flate.js' { + declare module.exports: $Exports<'jszip/lib/flate'>; +} +declare module 'jszip/lib/generate/index.js' { + declare module.exports: $Exports<'jszip/lib/generate/index'>; +} +declare module 'jszip/lib/generate/ZipFileWorker.js' { + declare module.exports: $Exports<'jszip/lib/generate/ZipFileWorker'>; +} +declare module 'jszip/lib/index.js' { + declare module.exports: $Exports<'jszip/lib/index'>; +} +declare module 'jszip/lib/license_header.js' { + declare module.exports: $Exports<'jszip/lib/license_header'>; +} +declare module 'jszip/lib/load.js' { + declare module.exports: $Exports<'jszip/lib/load'>; +} +declare module 'jszip/lib/nodejs/NodejsStreamInputAdapter.js' { + declare module.exports: $Exports<'jszip/lib/nodejs/NodejsStreamInputAdapter'>; +} +declare module 'jszip/lib/nodejs/NodejsStreamOutputAdapter.js' { + declare module.exports: $Exports<'jszip/lib/nodejs/NodejsStreamOutputAdapter'>; +} +declare module 'jszip/lib/nodejsUtils.js' { + declare module.exports: $Exports<'jszip/lib/nodejsUtils'>; +} +declare module 'jszip/lib/object.js' { + declare module.exports: $Exports<'jszip/lib/object'>; +} +declare module 'jszip/lib/readable-stream-browser.js' { + declare module.exports: $Exports<'jszip/lib/readable-stream-browser'>; +} +declare module 'jszip/lib/reader/ArrayReader.js' { + declare module.exports: $Exports<'jszip/lib/reader/ArrayReader'>; +} +declare module 'jszip/lib/reader/DataReader.js' { + declare module.exports: $Exports<'jszip/lib/reader/DataReader'>; +} +declare module 'jszip/lib/reader/NodeBufferReader.js' { + declare module.exports: $Exports<'jszip/lib/reader/NodeBufferReader'>; +} +declare module 'jszip/lib/reader/readerFor.js' { + declare module.exports: $Exports<'jszip/lib/reader/readerFor'>; +} +declare module 'jszip/lib/reader/StringReader.js' { + declare module.exports: $Exports<'jszip/lib/reader/StringReader'>; +} +declare module 'jszip/lib/reader/Uint8ArrayReader.js' { + declare module.exports: $Exports<'jszip/lib/reader/Uint8ArrayReader'>; +} +declare module 'jszip/lib/signature.js' { + declare module.exports: $Exports<'jszip/lib/signature'>; +} +declare module 'jszip/lib/stream/ConvertWorker.js' { + declare module.exports: $Exports<'jszip/lib/stream/ConvertWorker'>; +} +declare module 'jszip/lib/stream/Crc32Probe.js' { + declare module.exports: $Exports<'jszip/lib/stream/Crc32Probe'>; +} +declare module 'jszip/lib/stream/DataLengthProbe.js' { + declare module.exports: $Exports<'jszip/lib/stream/DataLengthProbe'>; +} +declare module 'jszip/lib/stream/DataWorker.js' { + declare module.exports: $Exports<'jszip/lib/stream/DataWorker'>; +} +declare module 'jszip/lib/stream/GenericWorker.js' { + declare module.exports: $Exports<'jszip/lib/stream/GenericWorker'>; +} +declare module 'jszip/lib/stream/StreamHelper.js' { + declare module.exports: $Exports<'jszip/lib/stream/StreamHelper'>; +} +declare module 'jszip/lib/support.js' { + declare module.exports: $Exports<'jszip/lib/support'>; +} +declare module 'jszip/lib/utf8.js' { + declare module.exports: $Exports<'jszip/lib/utf8'>; +} +declare module 'jszip/lib/utils.js' { + declare module.exports: $Exports<'jszip/lib/utils'>; +} +declare module 'jszip/lib/zipEntries.js' { + declare module.exports: $Exports<'jszip/lib/zipEntries'>; +} +declare module 'jszip/lib/zipEntry.js' { + declare module.exports: $Exports<'jszip/lib/zipEntry'>; +} +declare module 'jszip/lib/zipObject.js' { + declare module.exports: $Exports<'jszip/lib/zipObject'>; +} +declare module 'jszip/vendor/FileSaver.js' { + declare module.exports: $Exports<'jszip/vendor/FileSaver'>; +}