Add MsixContent and MsixBundleContent and handle those types in ZipContent as well

This commit is contained in:
acroos 2018-08-08 15:18:57 +02:00
Родитель ff0cccc1c6
Коммит 2d0760dc36
8 изменённых файлов: 398 добавлений и 14 удалений

Просмотреть файл

@ -3,8 +3,10 @@ import { ExtractError } from "./extractError";
import { IpaContent } from "./ipaContent";
import { ApkContent } from "./apkContent";
import { AppxContent } from "./appxContent";
import { MsixContent } from "./msixContent";
import { ZipContent } from "./zipContent";
import { AppxBundleContent } from "./appxBundleContent";
import { MsixBundleContent } from "./msixBundleContent";
import * as fse from 'fs-extra';
import * as path from 'path';
@ -16,7 +18,7 @@ import { WorkingFolder } from "./workingFolder";
export class Extract {
/**
* Extract metadata and icons from iOS, Android and UWP packages.
* @param filePath the path to the file to extract. The type of the file is determine based on the extension (IPA, APK, APPX, APPXBUNDLE, ZIP).
* @param filePath the path to the file to extract. The type of the file is determine based on the extension (IPA, APK, APPX, APPXBUNDLE, MSIX, MSIXBUNDLE, ZIP).
* @param workingFolder The content of the packages will be extracted to this folder. After extraction this folder will hold the icons and other none temporarily files. If no folder is supplied the machine's temp folder (using tmp NPM) is used.
*/
public static async run(filePath: string, workingFolder?: string): Promise<IPackageMetadata> {
@ -31,6 +33,9 @@ export class Extract {
case Constants.APPX: appPackage = new AppxContent(); break;
case Constants.APPXBUNDLE: appPackage = new AppxBundleContent(); break;
case Constants.APPXUPLOAD: appPackage = new ZipContent(); break;
case Constants.MSIX: appPackage = new MsixContent(); break;
case Constants.MSIXBUNDLE: appPackage = new MsixBundleContent(); break;
case Constants.MSIXUPLOAD: appPackage = new ZipContent(); break;
case Constants.ZIP: appPackage = new ZipContent(); break;
case Constants.DMG: throw new ExtractError(`${file.ext} is currently unsupported`);
default:

123
src/msixBundleContent.ts Normal file
Просмотреть файл

@ -0,0 +1,123 @@
import { ContentBase } from "./contentBase";
import { Constants } from "./constants";
import { MsixContent } from "./msixContent";
import { ExtractError } from "./extractError";
declare var require: any;
import * as path from 'path';
import * as fse from 'fs-extra';
import { OperatingSystem } from "./types";
export class MsixBundleContent extends ContentBase {
msixName: string; // this holds the name of the subPackage
iconMsix: string; // this holds the name of the zip file within the msix package with our icons
public get supportedFiles(): string[] {
return Constants.MSIX_FILES;
}
public async read(tempDir: string, fileList: any): Promise<void> {
this.operatingSystem = OperatingSystem.Windows;
const manifestPath = this.findFile(fileList, Constants.APPX_BUNDLE_MANIFEST);
if (!manifestPath) {
throw new ExtractError("no XML manifest found");
}
let fullPath = path.resolve(path.join(tempDir, manifestPath));
const exists = await fse.pathExists(fullPath);
if (!exists) {
throw new ExtractError(`plist wasn't saved on unzip || '${fullPath}' is incorrect`);
}
const manifestData = await this.parseXML(tempDir, manifestPath);
if (!manifestData) {
throw new ExtractError("manifest XML couldn't be parsed");
}
this.msixName = this.getMsixNameFromManifest(manifestData, fileList);
if (!this.msixName) {
throw new ExtractError(`cannot find the msix name in the manifest.`);
}
const msix = await this.readMsix(fileList, tempDir);
if (!msix) {
throw new ExtractError(`cannot find the msix '${this.msixName}' in the package.`);
}
this.mapManifest(manifestData, fileList, msix);
this.parseLanguages(fileList);
if(this.iconMsix) {
await this.parseIcon(tempDir, fileList);
}
}
private parseLanguages(fileList: string[]) {
// file examples:
// Calculator2.WindowsPhone_2016.1003.2147.0_language-fr.msix
// VLC_WinRT.WindowsPhone_1.8.4.0_language-en.msix
let languageList: any = [];
for (const file of fileList) {
if (file.includes("language")) {
let fileBase = path.basename(file, ".msix");
languageList.push(fileBase.substring(fileBase.indexOf("language-") + "language-".length));
}
}
this.languages = languageList;
}
public async parseIcon(tempDir: string, fileList: any): Promise<void> {
if (path.extname(this.iconMsix) === ".png") {
await this.readIcon(tempDir, this.iconMsix);
return;
}
fileList = await this.selectiveUnzip(tempDir, path.join(tempDir, this.iconMsix), [".png"]);
for(let fileName of fileList) {
if (path.extname(fileName).toLowerCase() === ".png" && !fileName.toLowerCase().includes("wide")) {
if (await this.readIcon(tempDir, fileName)) {
return;
}
}
}
}
private getMsixNameFromManifest(manifestData: any, fileList: string[]): string {
if (manifestData.Bundle.Packages[0].Package) {
for (const packageItem of manifestData.Bundle.Packages[0].Package) {
if(packageItem.$.Type === "application") {
return packageItem.$.FileName;
}
}
}
return null;
}
private mapManifest(manifestData: any, fileList: string[], msixContent: MsixContent) {
this.deviceFamily = Constants.WINDOWS;
this.uniqueIdentifier = msixContent.uniqueIdentifier;
this.buildVersion = msixContent.buildVersion;
this.version = "";
this.minimumOsVersion = msixContent.minimumOsVersion;
let iconScale = 0;
// find possible languages and icon scales
if (manifestData.Bundle.Packages[0].Package) {
for (const packageItem of manifestData.Bundle.Packages[0].Package) {
for (const resources of packageItem.Resources) {
for (const resourceItem of resources.Resource) {
if(resourceItem.$.Scale && (resourceItem.$.Scale > iconScale)) {
iconScale = resourceItem.$.Scale;
}
}
}
}
}
// look for actual icon zips in the existing files
for (const file of fileList) {
if (!this.iconMsix && file.includes("scale")) {
this.iconMsix = file;
}
if (iconScale > 0 && file.includes(iconScale.toLocaleString())) {
this.iconMsix = file;
break;
}
}
}
private async readMsix(fileList: string[], tempDir: string) : Promise<MsixContent> {
let subPackage = new MsixContent();
let unzipPath = this.findFile(fileList, this.msixName);
unzipPath = path.join(tempDir, unzipPath);
fileList = await subPackage.selectiveUnzip(tempDir, unzipPath, subPackage.supportedFiles);
await subPackage.read(tempDir, fileList);
return subPackage;
}
}

100
src/msixContent.ts Normal file
Просмотреть файл

@ -0,0 +1,100 @@
import { ContentBase } from "./contentBase";
import { Constants } from "./constants";
import { ExtractError } from "./extractError";
import * as fse from 'fs-extra';
import * as path from 'path';
import { OperatingSystem } from "./types";
export class MsixContent extends ContentBase {
public get supportedFiles(): string[] {
return Constants.MSIX_FILES;
}
public async read(tempDir: string, fileList: string[]): Promise<void> {
this.operatingSystem = OperatingSystem.Windows;
const manifestData = await this.parseManifest(tempDir, fileList);
if (!manifestData) {
throw new ExtractError("manifest XML couldn't be parsed");
}
await this.mapManifest(tempDir, manifestData);
await this.parseIcon(tempDir, fileList);
}
private async parseManifest(tempDir: string, fileList: any): Promise<any> {
let manifestPath = this.findFile(fileList, Constants.APPX_MANIFEST);
if (!manifestPath) {
throw new ExtractError("cannot find the xml manifest");
}
const exists = await fse.pathExists(path.resolve(path.join(tempDir, manifestPath)));
if (!exists) {
throw new ExtractError('manifest in filelist, but not on disk');
}
const manifestData = await this.parseXML(tempDir, manifestPath);
return manifestData;
}
private async mapManifest(tempDir: string, manifestData: any) {
if (!manifestData || !manifestData.Package) {
throw new ExtractError("empty manifest");
}
this.deviceFamily = Constants.WINDOWS;
if (manifestData.Package.Properties && manifestData.Package.Properties[0]) {
this.displayName = manifestData.Package.Properties[0].DisplayName ? manifestData.Package.Properties[0].DisplayName[0] : null;
this.iconFullPath = manifestData.Package.Properties[0].Logo ? manifestData.Package.Properties[0].Logo[0] : null;
}
if (manifestData.Package.Identity && manifestData.Package.Identity[0]) {
this.uniqueIdentifier = (manifestData.Package.Identity[0].$ && manifestData.Package.Identity[0].$.Name) ? manifestData.Package.Identity[0].$.Name : null;
this.buildVersion = (manifestData.Package.Identity[0].$ && manifestData.Package.Identity[0].$.Version) ? manifestData.Package.Identity[0].$.Version : null;
this.version = "";
}
if (manifestData.Package.Prerequisites && manifestData.Package.Prerequisites[0]) {
this.minimumOsVersion = manifestData.Package.Prerequisites[0].OSMinVersion ? manifestData.Package.Prerequisites[0].OSMinVersion[0] : null;
} else if (manifestData.Package.Dependencies && manifestData.Package.Dependencies[0]) {
this.minimumOsVersion = manifestData.Package.Dependencies[0].TargetDeviceFamily && manifestData.Package.Dependencies[0].TargetDeviceFamily[0].$.MinVersion ? manifestData.Package.Dependencies[0].TargetDeviceFamily[0].$.MinVersion : null;
}
if (manifestData.Package.Applications && manifestData.Package.Applications[0] && manifestData.Package.Applications[0].Application && manifestData.Package.Applications[0].Application[0] && manifestData.Package.Applications[0].Application[0].$) {
this.executableName = manifestData.Package.Applications[0].Application[0].$.Executable ? manifestData.Package.Applications[0].Application[0].$.Executable : null;
}
this.languages = [];
if (manifestData.Package.Resources && manifestData.Package.Resources[0] && manifestData.Package.Resources[0].Resource) {
for (const resource of manifestData.Package.Resources[0].Resource) {
if (resource.$.Language) {
this.languages.push(resource.$.Language.toLowerCase());
}
}
}
}
private async parseIcon(tempDir: string, fileList: string[]) {
if (this.iconFullPath) {
// normalize wasn't working with icon path for sone reason, had to use replace
this.iconFullPath = path.normalize(this.iconFullPath.replace("\\", "/"));
let success = await this.readIcon(tempDir, this.iconFullPath);
// return if you find the icon as listed in the manifest. Ex: "StoreLogo.png"
if (success) {
return;
}
// otherwise the icon name might also include scale, Ex: "StoreLogo.scale-240.png"
// so look for icons that include the manifest icon name as a subset
const basename = path.basename(this.iconFullPath, '.png').toLowerCase();
this.iconFullPath = null;
let max = 0;
for (let icon of fileList) {
// look through potential manifest icons for the one with the best scale
if (icon.toLowerCase().includes(basename)) {
const curr = icon.match(/[0-9]+/);
if (!curr) {
break;
}
const int = parseInt(curr[0], 10);
if (int && int >= max) {
max = int;
this.iconFullPath = icon;
}
}
}
}
// if there is still no icon name after looking for a scaled icon, return
if (!this.iconFullPath) {
return;
}
await this.readIcon(tempDir, this.iconFullPath);
}
}

Просмотреть файл

@ -1,6 +1,8 @@
import { ContentBase } from "./contentBase";
import { AppxContent } from "./appxContent";
import { AppxBundleContent } from "./appxBundleContent";
import { MsixContent } from "./msixContent";
import { MsixBundleContent } from "./msixBundleContent";
import { Constants } from "./constants";
import { ExtractError } from "./extractError";
@ -8,10 +10,10 @@ declare var require: any;
import * as path from 'path';
import { OperatingSystem } from "./types";
// this class expects the temp directory to have the contents of a .zip or .appxupload,
// which would both contain the app itself (.appx/.appxbundle) and other metadata inside
// this class expects the temp directory to have the contents of a .zip, .appxupload, or .msixupload,
// which would both contain the app itself (.appx/.appxbundle/.msix/.msixbundle) and other metadata inside
export class ZipContent extends ContentBase {
subPackage: ContentBase; //AppxContent | AppxBundleContent;
subPackage: ContentBase; //AppxContent | AppxBundleContent | MsixContent | MsixBundleContent;
packageRelativePath: string;
packageType: string;
public get supportedFiles(): string[] {
@ -24,7 +26,7 @@ export class ZipContent extends ContentBase {
throw new ExtractError("couldn't find actual app package");
}
this.packageType = path.extname(this.packageRelativePath).toLowerCase();
this.subPackage = this.packageType === ".appx" ? new AppxContent() : new AppxBundleContent();
this.subPackage = this.subPackageFromType(this.packageType);
const unzipPath = path.join(tempDir, this.packageRelativePath);
fileList = await this.subPackage.selectiveUnzip(tempDir, unzipPath, this.subPackage.supportedFiles);
await this.subPackage.read(tempDir, fileList);
@ -32,8 +34,8 @@ export class ZipContent extends ContentBase {
}
/**
* Zip packages are getting package metadata from the inner appxbundle or appx.
* This method will take the output from the inner appxbudle/appx packages and
* Zip packages are getting package metadata from the inner appx, msix, appxbundle, or msixbundle.
* This method will take the output from the inner appx/msix/appxbundle/msixbundle packages and
* save it in this class.
*/
private updateFromSubPackage(subPackage: ContentBase) {
@ -56,7 +58,7 @@ export class ZipContent extends ContentBase {
private packageSearch(fileList: string[]) : string {
// the directory depth of the packages changes depending on which type of package was unzipped.
// since you can't know before going in, searching by level is required since there can be other
// files with .appx or .appxbundle which could throw off the logic, (for example languages)
// files with .appx, .msix, .appxbundle, or .msixbundle which could throw off the logic, (for example languages)
let halt = false;
for (let depth = 1; depth <= 2; depth++) {
for (const file of fileList) {
@ -67,19 +69,30 @@ export class ZipContent extends ContentBase {
// and you can't find the app package at the same depth then something is wrong
halt = true;
}
if(path.extname(file).toLowerCase() === ".appx") {
let fileExtension = path.extname(file).toLowerCase();
let allowedExtensions = [".appx", ".appxbundle", ".msix", ".msixbundle"];
let notAllowedExtensions = [".appxupload", ".msixupload"];
if (allowedExtensions.indexOf(fileExtension) != -1) {
return file;
} else if (path.extname(file).toLowerCase() === ".appxbundle") {
return file;
} else if (path.extname(file).toLowerCase() === ".appxupload") {
} else if (notAllowedExtensions.indexOf(fileExtension) != -1) {
// not sure if people zip these but we shall see
throw new ExtractError("zip includes .appxUpload");
throw new ExtractError(`zip includes ${fileExtension}`);
}
}
}
if(halt) {
throw new ExtractError("Expected .appx or .appxbundle to be at the same folder as Add-AppDevPackage.ps1. Add-AppDevPackage.ps1 was found but no .appx or .appxbundle in the same folder.");
throw new ExtractError("Expected .appx, .msix, .appxbundle, or .msixbundle to be at the same folder as Add-AppDevPackage.ps1. Add-AppDevPackage.ps1 was found but no .appx, .msix, .appxbundle, or .msixbundle in the same folder.");
}
}
}
private subPackageFromType(packageType: string) : ContentBase {
switch(packageType) {
case ".appx": return new AppxContent();
case ".appxbundle": return new AppxBundleContent();
case ".msix": return new MsixContent();
case ".msixbundle": return new MsixBundleContent();
}
throw new ExtractError(`Subpackage has unrecognized type: ${packageType}. Expecting .appx, .appxbundle, .msix, or .msixbundle`)
}
}

Двоичные данные
test/assets/UwpApp_1/test.msixbundle Executable file

Двоичный файл не отображается.

Двоичные данные
test/assets/UwpApp_1/test_x86.msix Executable file

Двоичный файл не отображается.

114
test/msixContentTest.ts Normal file
Просмотреть файл

@ -0,0 +1,114 @@
import * as should from 'should';
import * as path from 'path';
var copydir = require('copy-dir');
var shortid = require('shortid');
import { ExtractError } from "../src/extractError";
import { MsixContent } from "../src/msixContent";
import { WorkingFolder } from '../src/workingFolder';
describe("MsixContent", () => {
describe("#read", () => {
context('when unzipped Msix has no manifest', () => {
it("should throw error", async () => {
const subject = new MsixContent();
return subject.read("test/assets/bike-payload", []).should.be.rejectedWith(ExtractError);
});
});
context('when path to manifest is incorrect or non-existent', () => {
it("should throw error", async () => {
const subject = new MsixContent();
return subject.read("test/assets/bike-payload", ["assets/AppxManifest.xml"]).should.be.rejectedWith(ExtractError);
});
});
context('somehow invalid manifest', () => {
it("should throw error", async () => {
const subject = new MsixContent();
return subject.read("test/assets/bike-payload", ["fake-assets/AppxManifest.xml"]).should.be.rejectedWith(ExtractError);
});
});
context("normal manifest collection", () => {
const unzipPath = `test/temp/${shortid.generate()}/bike-payload`;
beforeEach(() => {
copydir.sync("test/assets/bike-payload", unzipPath);
});
it("should extract params", async () => {
const subject = new MsixContent();
const manifestPath = "AppxManifest.xml";
await subject.read(unzipPath, [manifestPath]);
should(subject.displayName).eql("Sunset Bike Racer");
should(subject.executableName).eql("Sunset Racer.exe");
should(subject.languages).eql(["en", "de", "fr", "pt", "es"]);
should(subject.minimumOsVersion).eql("6.3.1");
should(subject.buildVersion).eql("26.1.0.40");
should(subject.version).eql("");
should(subject.uniqueIdentifier).eql("7659327F2E2D.SunsetBikeRacer");
});
});
context("existing icon", () => {
const subject = new MsixContent();
const manifestPath = "AppxManifest.xml";
const iconPath = "Assets/StoreLogo.scale-240.png";
const unzipPath = path.join(__dirname, `temp/${shortid.generate()}/bike-payload`);
beforeEach(async () => {
subject['workingFolder'] = await WorkingFolder.create();
copydir.sync("test/assets/bike-payload", unzipPath);
});
it("should extract icon and icon name", async () => {
await subject.read(unzipPath, [manifestPath, iconPath]);
should(subject.iconName).eql("StoreLogo.scale-240.png");
should(subject.iconFullPath).eql("Assets/StoreLogo.scale-240.png");
should(subject.icon).not.eql(undefined);
});
it("should extract icon and not interfere with other collection", async () => {
await subject.read(unzipPath, [manifestPath, iconPath]);
should(subject.displayName).eql("Sunset Bike Racer");
should(subject.executableName).eql("Sunset Racer.exe");
should(subject.languages).eql(["en", "de", "fr", "pt", "es"]);
should(subject.minimumOsVersion).eql("6.3.1");
should(subject.buildVersion).eql("26.1.0.40");
should(subject.version).eql("");
should(subject.uniqueIdentifier).eql("7659327F2E2D.SunsetBikeRacer");
});
});
context("non-existent icon", () => {
const unzipPath = `test/temp/${shortid.generate()}/bike-payload`;
beforeEach(() => {
copydir.sync("test/assets/bike-payload", unzipPath);
});
it("shouldn't extract icon", async () => {
const subject = new MsixContent();
const manifestPath = "AppxManifest.xml";
await subject.read(unzipPath, [manifestPath]);
should(subject.displayName).eql("Sunset Bike Racer");
should(subject.executableName).eql("Sunset Racer.exe");
should(subject.iconName).eql(undefined);
should(subject.iconFullPath).eql(null);
should(subject.icon).eql(undefined);
should(subject.languages).eql(["en", "de", "fr", "pt", "es"]);
should(subject.minimumOsVersion).eql("6.3.1");
should(subject.buildVersion).eql("26.1.0.40");
should(subject.version).eql("");
should(subject.uniqueIdentifier).eql("7659327F2E2D.SunsetBikeRacer");
});
});
context("icon in manifest but without real or scaled versions", () => {
const unzipPath = `test/temp/${shortid.generate()}/bike-payload`;
beforeEach(() => {
copydir.sync("test/assets/bike-payload", unzipPath);
});
it("should continue without icon", async () => {
const subject = new MsixContent();
const manifestPath = "AppxManifest.xml";
await subject.read(unzipPath, [manifestPath]);
should(subject.iconName).eql(undefined);
should(subject.icon).eql(undefined);
should(subject.languages).eql(["en", "de", "fr", "pt", "es"]);
should(subject.minimumOsVersion).eql("6.3.1");
should(subject.buildVersion).eql("26.1.0.40");
should(subject.version).eql("");
should(subject.uniqueIdentifier).eql("7659327F2E2D.SunsetBikeRacer");
});
});
});
});

Просмотреть файл

@ -53,5 +53,34 @@ describe("ZipContent", () => {
await subject.read(unzipPath, [packagePath, extraPath]);
});
});
context('zip contains msix subpackage', () => {
const unzipPath = `test/temp/${shortid.generate()}/UwpApp_1`;
beforeEach(() => {
copydir.sync("test/assets/UwpApp_1", unzipPath);
});
it("should extract", async () => {
const subject = new ZipContent();
await subject.read(unzipPath, ["test_x86.msix", "Dependencies/Microsoft.VCLibs.x64.14.00.appx"]);
should(subject.subPackage.displayName).eql("TestApp");
should(subject.subPackage.executableName).eql("TestApp.exe");
should(subject.subPackage.languages).eql(["en-us"]);
should(subject.subPackage.minimumOsVersion).eql("10.0.17135.0");
should(subject.subPackage.buildVersion).eql("1.0.0.0");
should(subject.subPackage.version).eql("");
should(subject.subPackage.uniqueIdentifier).eql("72dd8124-f57b-4118-8b47-9900db69752c");
});
});
context('zip contains msixbundle subpackage', () => {
const unzipPath = `test/temp/${shortid.generate()}/UwpApp_1`;
beforeEach(() => {
copydir.sync("test/assets/UwpApp_1", unzipPath);
});
it("should extract", async () => {
const subject = new ZipContent();
const packagePath = "test.msixbundle";
const extraPath = "Dependencies/Microsoft.VCLibs.x64.14.00.appx";
await subject.read(unzipPath, [packagePath, extraPath]);
});
});
});
});