import { Arch, asArray, copyOrLinkFile, deepAssign, InvalidConfigurationError, log, walk } from "builder-util"
import { Nullish } from "builder-util-runtime"
import { emptyDir, readdir, readFile, writeFile } from "fs-extra"
import * as path from "path"
import { AppXOptions } from "../"
import { getSignVendorPath, isOldWin6 } from "../codeSign/windowsSignToolManager"
import { Target } from "../core"
import { getTemplatePath } from "../util/pathManager"
import { VmManager } from "../vm/vm"
import { WinPackager } from "../winPackager"
import { createStageDir } from "./targetUtil"
const APPX_ASSETS_DIR_NAME = "appx"
const vendorAssetsForDefaultAssets: Record<string, string> = {
"StoreLogo.png": "SampleAppx.50x50.png",
"Square150x150Logo.png": "SampleAppx.150x150.png",
"Square44x44Logo.png": "SampleAppx.44x44.png",
"Wide310x150Logo.png": "SampleAppx.310x150.png",
}
const restrictedApplicationIdValues = [
"CON",
"PRN",
"AUX",
"NUL",
"COM1",
"COM2",
"COM3",
"COM4",
"COM5",
"COM6",
"COM7",
"COM8",
"COM9",
"LPT1",
"LPT2",
"LPT3",
"LPT4",
"LPT5",
"LPT6",
"LPT7",
"LPT8",
"LPT9",
]
const DEFAULT_RESOURCE_LANG = "en-US"
export default class AppXTarget extends Target {
readonly options: AppXOptions = deepAssign({}, this.packager.platformSpecificBuildOptions, this.packager.config.appx)
isAsyncSupported = false
constructor(
private readonly packager: WinPackager,
readonly outDir: string
) {
super("appx")
if (process.platform !== "darwin" && (process.platform !== "win32" || isOldWin6())) {
throw new Error("AppX is supported only on Windows 10 or Windows Server 2012 R2 (version number 6.3+)")
}
}
async build(appOutDir: string, arch: Arch): Promise<any> {
const packager = this.packager
const artifactName = packager.expandArtifactBeautyNamePattern(this.options, "appx", arch)
const artifactPath = path.join(this.outDir, artifactName)
await packager.info.emitArtifactBuildStarted({
targetPresentableName: "AppX",
file: artifactPath,
arch,
})
const vendorPath = await getSignVendorPath()
const vm = await packager.vm.value
const stageDir = await createStageDir(this, packager, arch)
const mappingFile = stageDir.getTempFile("mapping.txt")
const makeAppXArgs = ["pack", "/o" , "/f", vm.toVmFile(mappingFile), "/p", vm.toVmFile(artifactPath)]
if (packager.compression === "store") {
makeAppXArgs.push("/nc")
}
const mappingList: Array<Array<string>> = []
mappingList.push(
await Promise.all(
(await walk(appOutDir)).map(file => {
let appxPath = file.substring(appOutDir.length + 1)
if (path.sep !== "\\") {
appxPath = appxPath.replace(/\//g, "\\")
}
return `"${vm.toVmFile(file)}" "app\\${appxPath}"`
})
)
)
const userAssetDir = await this.packager.getResource(undefined, APPX_ASSETS_DIR_NAME)
const assetInfo = await AppXTarget.computeUserAssets(vm, vendorPath, userAssetDir)
const userAssets = assetInfo.userAssets
const manifestFile = stageDir.getTempFile("AppxManifest.xml")
await this.writeManifest(manifestFile, arch, await this.computePublisherName(), userAssets)
await packager.info.emitAppxManifestCreated(manifestFile)
mappingList.push(assetInfo.mappings)
mappingList.push([`"${vm.toVmFile(manifestFile)}" "AppxManifest.xml"`])
const signToolArch = arch === Arch.arm64 ? "x64" : Arch[arch]
if (isScaledAssetsProvided(userAssets)) {
const outFile = vm.toVmFile(stageDir.getTempFile("resources.pri"))
const makePriPath = vm.toVmFile(path.join(vendorPath, "windows-10", signToolArch, "makepri.exe"))
const assetRoot = stageDir.getTempFile("appx/assets")
await emptyDir(assetRoot)
await Promise.all(assetInfo.allAssets.map(it => copyOrLinkFile(it, path.join(assetRoot, path.basename(it)))))
await vm.exec(makePriPath, [
"new",
"/Overwrite",
"/Manifest",
vm.toVmFile(manifestFile),
"/ProjectRoot",
vm.toVmFile(path.dirname(assetRoot)),
"/ConfigXml",
vm.toVmFile(path.join(getTemplatePath("appx"), "priconfig.xml")),
"/OutputFile",
outFile,
])
for (const resourceFile of (await readdir(stageDir.dir)).filter(it => it.startsWith("resources.")).sort()) {
mappingList.push([`"${vm.toVmFile(stageDir.getTempFile(resourceFile))}" "${resourceFile}"`])
}
makeAppXArgs.push("/l")
}
let mapping = "[Files]"
for (const list of mappingList) {
mapping += "\r\n" + list.join("\r\n")
}
await writeFile(mappingFile, mapping)
packager.debugLogger.add("appx.mapping", mapping)
if (this.options.makeappxArgs != null) {
makeAppXArgs.push(...this.options.makeappxArgs)
}
this.buildQueueManager.add(async () => {
await vm.exec(vm.toVmFile(path.join(vendorPath, "windows-10", signToolArch, "makeappx.exe")), makeAppXArgs)
await packager.sign(artifactPath)
await stageDir.cleanup()
await packager.info.emitArtifactBuildCompleted({
file: artifactPath,
packager,
arch,
safeArtifactName: packager.computeSafeArtifactName(artifactName, "appx"),
target: this,
isWriteUpdateInfo: this.options.electronUpdaterAware,
})
})
}
private static async computeUserAssets(vm: VmManager, vendorPath: string, userAssetDir: string | null) {
const mappings: Array<string> = []
let userAssets: Array<string>
const allAssets: Array<string> = []
if (userAssetDir == null) {
userAssets = []
} else {
userAssets = (await readdir(userAssetDir)).filter(it => !it.startsWith(".") && !it.endsWith(".db") && it.includes("."))
for (const name of userAssets) {
mappings.push(`"${vm.toVmFile(userAssetDir)}${vm.pathSep}${name}" "assets\\${name}"`)
allAssets.push(path.join(userAssetDir, name))
}
}
for (const defaultAsset of Object.keys(vendorAssetsForDefaultAssets)) {
if (userAssets.length === 0 || !isDefaultAssetIncluded(userAssets, defaultAsset)) {
const file = path.join(vendorPath, "appxAssets", vendorAssetsForDefaultAssets[defaultAsset])
mappings.push(`"${vm.toVmFile(file)}" "assets\\${defaultAsset}"`)
allAssets.push(file)
}
}
return { userAssets, mappings, allAssets }
}
private async computePublisherName() {
const signtoolManager = await this.packager.signingManager.value
return signtoolManager.computePublisherName(this, this.options.publisher)
}
private async writeManifest(outFile: string, arch: Arch, publisher: string, userAssets: Array<string>) {
const appInfo = this.packager.appInfo
const options = this.options
const executable = `app\\${appInfo.productFilename}.exe`
const displayName = options.displayName || appInfo.productName
const extensions = await this.getExtensions(executable, displayName)
const archSpecificMinVersion = arch === Arch.arm64 ? "10.0.16299.0" : "10.0.14316.0"
const customManifestPath = await this.packager.getResource(this.options.customManifestPath)
if (customManifestPath) {
log.info({ manifestPath: log.filePath(customManifestPath) }, "custom appx manifest found")
}
const manifestFileContent = await readFile(customManifestPath || path.join(getTemplatePath("appx"), "appxmanifest.xml"), "utf8")
const manifest = manifestFileContent.replace(/\${([a-zA-Z0-9]+)}/g, (match, p1): string => {
switch (p1) {
case "publisher":
return publisher
case "publisherDisplayName": {
const name = options.publisherDisplayName || appInfo.companyName
if (name == null) {
throw new InvalidConfigurationError(`Please specify "author" in the application package.json — it is required because "appx.publisherDisplayName" is not set.`)
}
return name
}
case "version":
return appInfo.getVersionInWeirdWindowsForm(options.setBuildNumber === true)
case "applicationId": {
const validCharactersRegex = /^([A-Za-z][A-Za-z0-9]*)(\.[A-Za-z][A-Za-z0-9]*)*$/
const identitynumber = parseInt(options.identityName as string, 10) || NaN
let result: string
if (options.applicationId) {
result = options.applicationId
} else if (!isNaN(identitynumber) && options.identityName !== null && options.identityName !== undefined) {
if (options.identityName[0] === "0") {
log.warn(`Remove the 0${identitynumber}`)
result = options.identityName.replace("0" + identitynumber.toString(), "")
} else {
log.warn(`Remove the ${identitynumber}`)
result = options.identityName.replace(identitynumber.toString(), "")
}
} else {
result = options.identityName || appInfo.name
}
if (result.length < 1 || result.length > 64) {
const message = `Appx Application.Id with a value between 1 and 64 characters in length`
throw new InvalidConfigurationError(message)
} else if (!validCharactersRegex.test(result)) {
const message = `AppX Application.Id can not be consists of alpha-numeric and period"`
throw new InvalidConfigurationError(message)
} else if (restrictedApplicationIdValues.includes(result.toUpperCase())) {
const message = `AppX identityName.Id can not include restricted values: ${JSON.stringify(restrictedApplicationIdValues)}`
throw new InvalidConfigurationError(message)
} else if (result == null && options.applicationId == null) {
const message = `Please set appx.applicationId (or correct appx.identityName or name)`
throw new InvalidConfigurationError(message)
}
return result
}
case "identityName": {
const result = options.identityName || appInfo.name
const validCharactersRegex = /^[a-zA-Z0-9.-]+$/
if (result.length < 3 || result.length > 50) {
const message = `Appx identityName.Id with a value between 3 and 50 characters in length`
throw new InvalidConfigurationError(message)
} else if (!validCharactersRegex.test(result)) {
const message = `AppX identityName.Id cat be consists of alpha-numeric, period, and dash characters"`
throw new InvalidConfigurationError(message)
} else if (restrictedApplicationIdValues.includes(result.toUpperCase())) {
const message = `AppX identityName.Id can not be some values`
throw new InvalidConfigurationError(message)
} else if (result == null && options.identityName == null) {
const message = `Please set appx.identityName or name`
throw new InvalidConfigurationError(message)
}
return result
}
case "executable":
return executable
case "displayName":
return displayName
case "description":
return appInfo.description || appInfo.productName
case "backgroundColor":
return options.backgroundColor || "#464646"
case "logo":
return "assets\\StoreLogo.png"
case "square150x150Logo":
return "assets\\Square150x150Logo.png"
case "square44x44Logo":
return "assets\\Square44x44Logo.png"
case "lockScreen":
return lockScreenTag(userAssets)
case "defaultTile":
return defaultTileTag(userAssets, options.showNameOnTiles || false)
case "splashScreen":
return splashScreenTag(userAssets)
case "arch":
return arch === Arch.ia32 ? "x86" : arch === Arch.arm64 ? "arm64" : "x64"
case "resourceLanguages":
return resourceLanguageTag(asArray(options.languages))
case "extensions":
return extensions
case "minVersion":
return options.minVersion || archSpecificMinVersion
case "maxVersionTested":
return options.maxVersionTested || options.minVersion || archSpecificMinVersion
default:
throw new Error(`Macro ${p1} is not defined`)
}
})
await writeFile(outFile, manifest)
}
private async getExtensions(executable: string, displayName: string): Promise<string> {
const uriSchemes = asArray(this.packager.config.protocols).concat(asArray(this.packager.platformSpecificBuildOptions.protocols))
const fileAssociations = asArray(this.packager.config.fileAssociations).concat(asArray(this.packager.platformSpecificBuildOptions.fileAssociations))
let isAddAutoLaunchExtension = this.options.addAutoLaunchExtension
if (isAddAutoLaunchExtension === undefined) {
const deps = this.packager.info.metadata.dependencies
isAddAutoLaunchExtension = deps != null && deps["electron-winstore-auto-launch"] != null
}
if (!isAddAutoLaunchExtension && uriSchemes.length === 0 && fileAssociations.length === 0 && this.options.customExtensionsPath === undefined) {
return ""
}
let extensions = "<Extensions>"
if (isAddAutoLaunchExtension) {
extensions += `
<desktop:Extension Category="windows.startupTask" Executable="${executable}" EntryPoint="Windows.FullTrustApplication">
<desktop:StartupTask TaskId="SlackStartup" Enabled="true" DisplayName="${displayName}" />
</desktop:Extension>`
}
for (const protocol of uriSchemes) {
for (const scheme of asArray(protocol.schemes)) {
extensions += `
<uap:Extension Category="windows.protocol">
<uap:Protocol Name="${scheme}">
<uap:DisplayName>${protocol.name}</uap:DisplayName>
</uap:Protocol>
</uap:Extension>`
}
}
for (const fileAssociation of fileAssociations) {
for (const ext of asArray(fileAssociation.ext)) {
extensions += `
<uap:Extension Category="windows.fileTypeAssociation">
<uap:FileTypeAssociation Name="${ext}">
<uap:SupportedFileTypes>
<uap:FileType>.${ext}</uap:FileType>
</uap:SupportedFileTypes>
</uap:FileTypeAssociation>
</uap:Extension>`
}
}
if (this.options.customExtensionsPath !== undefined) {
const extensionsPath = path.resolve(this.packager.info.appDir, this.options.customExtensionsPath)
extensions += await readFile(extensionsPath, "utf8")
}
extensions += "</Extensions>"
return extensions
}
}
function resourceLanguageTag(userLanguages: Array<string> | Nullish): string {
if (userLanguages == null || userLanguages.length === 0) {
userLanguages = [DEFAULT_RESOURCE_LANG]
}
return userLanguages.map(it => `<Resource Language="${it.replace(/_/g, "-")}" />`).join("\n")
}
function lockScreenTag(userAssets: Array<string>): string {
if (isDefaultAssetIncluded(userAssets, "BadgeLogo.png")) {
return '<uap:LockScreen Notification="badgeAndTileText" BadgeLogo="assets\\BadgeLogo.png" />'
} else {
return ""
}
}
function defaultTileTag(userAssets: Array<string>, showNameOnTiles: boolean): string {
const defaultTiles: Array<string> = ["<uap:DefaultTile", 'Wide310x150Logo="assets\\Wide310x150Logo.png"']
if (isDefaultAssetIncluded(userAssets, "LargeTile.png")) {
defaultTiles.push('Square310x310Logo="assets\\LargeTile.png"')
}
if (isDefaultAssetIncluded(userAssets, "SmallTile.png")) {
defaultTiles.push('Square71x71Logo="assets\\SmallTile.png"')
}
if (showNameOnTiles) {
defaultTiles.push(">")
defaultTiles.push("<uap:ShowNameOnTiles>")
defaultTiles.push("<uap:ShowOn", 'Tile="wide310x150Logo"', "/>")
defaultTiles.push("<uap:ShowOn", 'Tile="square150x150Logo"', "/>")
defaultTiles.push("</uap:ShowNameOnTiles>")
defaultTiles.push("</uap:DefaultTile>")
} else {
defaultTiles.push("/>")
}
return defaultTiles.join(" ")
}
function splashScreenTag(userAssets: Array<string>): string {
if (isDefaultAssetIncluded(userAssets, "SplashScreen.png")) {
return '<uap:SplashScreen Image="assets\\SplashScreen.png" />'
} else {
return ""
}
}
function isDefaultAssetIncluded(userAssets: Array<string>, defaultAsset: string) {
const defaultAssetName = defaultAsset.substring(0, defaultAsset.indexOf("."))
return userAssets.some(it => it.includes(defaultAssetName))
}
function isScaledAssetsProvided(userAssets: Array<string>) {
return userAssets.some(it => it.includes(".scale-") || it.includes(".targetsize-"))
}