import { CreateOptions, createPackageWithOptions } from "@electron/asar"
import { AsyncTaskManager, log } from "builder-util"
import { CancellationToken } from "builder-util-runtime"
import { FileCopier, Filter, Link, MAX_FILE_REQUESTS } from "builder-util/out/fs"
import * as fs from "fs-extra"
import { mkdir, readlink, symlink } from "fs-extra"
import { platform } from "os"
import * as path from "path"
import * as tempFile from "temp-file"
import { AsarOptions } from "../options/PlatformSpecificBuildOptions"
import { PlatformPackager } from "../platformPackager"
import { ResolvedFileSet, getDestinationPath } from "../util/appFileCopier"
import { detectUnpackedDirs } from "./unpackDetector"
export class AsarPackager {
private readonly outFile: string
private rootForAppFilesWithoutAsar!: string
private readonly fileCopier = new FileCopier()
private readonly tmpDir: tempFile.TmpDir
private readonly cancellationToken: CancellationToken
constructor(
readonly packager: PlatformPackager<any>,
private readonly config: {
defaultDestination: string
resourcePath: string
options: AsarOptions
unpackPattern: Filter | undefined
}
) {
this.outFile = path.join(config.resourcePath, `app.asar`)
this.tmpDir = packager.info.tempDirManager
this.cancellationToken = packager.info.cancellationToken
}
async pack(fileSets: Array<ResolvedFileSet>) {
this.rootForAppFilesWithoutAsar = await this.tmpDir.getTempDir({ prefix: "asar-app" })
const orderedFileSets = [
...fileSets.slice(1),
fileSets[0],
].map(orderFileSet)
const { unpackedPaths, copiedFiles } = await this.detectAndCopy(orderedFileSets)
const unpackGlob = unpackedPaths.length > 1 ? `{${unpackedPaths.join(",")}}` : unpackedPaths.pop()
await this.executeElectronAsar(copiedFiles, unpackGlob)
}
private async executeElectronAsar(copiedFiles: string[], unpackGlob: string | undefined) {
let ordering = this.config.options.ordering || undefined
if (!ordering) {
const filesSorted = copiedFiles.map(file => file.substring(this.rootForAppFilesWithoutAsar.length))
ordering = await this.tmpDir.getTempFile({ prefix: "asar-ordering", suffix: ".txt" })
await fs.writeFile(ordering, filesSorted.join("\n"))
}
const options: CreateOptions = {
unpack: unpackGlob,
unpackDir: unpackGlob,
ordering,
dot: true,
}
const consoleLogger = console.log
console.log = (...args) => {
if (args[0] === "Ordering file has 100% coverage.") {
return
}
log.info({ args }, "logging @electron/asar")
}
await createPackageWithOptions(this.rootForAppFilesWithoutAsar, this.outFile, options)
console.log = consoleLogger
}
private async detectAndCopy(fileSets: ResolvedFileSet[]) {
const taskManager = new AsyncTaskManager(this.cancellationToken)
const unpackedPaths = new Set<string>()
const copiedFiles = new Set<string>()
const createdSourceDirs = new Set<string>()
const links: Array<Link> = []
const symlinkType = platform() === "win32" ? "junction" : "file"
const matchUnpacker = (file: string, dest: string, stat: fs.Stats, tmpUnpackedPaths: Set<string>) => {
if (this.config.unpackPattern?.(file, stat)) {
log.debug({ file }, "unpacking")
tmpUnpackedPaths.add(dest)
return
}
}
const writeFileOrProcessSymlink = async (options: {
file: string
destination: string
stat: fs.Stats
fileSet: ResolvedFileSet
transformedData: string | Buffer | undefined
}) => {
const { transformedData, file, destination, stat, fileSet } = options
if (!stat.isFile() && !stat.isSymbolicLink()) {
return
}
copiedFiles.add(destination)
const dir = path.dirname(destination)
if (!createdSourceDirs.has(dir)) {
await mkdir(dir, { recursive: true })
createdSourceDirs.add(dir)
}
if (transformedData != null) {
return fs.writeFile(destination, transformedData, { mode: stat.mode })
}
const realPathFile = await fs.realpath(file)
const realPathRelative = path.relative(fileSet.src, realPathFile)
const isOutsidePackage = realPathRelative.startsWith("..")
if (isOutsidePackage) {
log.error({ source: log.filePath(file), realPathFile: log.filePath(realPathFile) }, `unable to copy, file is symlinked outside the package`)
throw new Error(`Cannot copy file (${path.basename(file)}) symlinked to file (${path.basename(realPathFile)}) outside the package as that violates asar security integrity`)
}
if (file === realPathFile) {
return this.fileCopier.copy(file, destination, stat)
}
let link = await readlink(file)
if (path.isAbsolute(link)) {
link = path.relative(path.dirname(file), link)
}
links.push({ file: destination, link })
}
for (const fileSet of fileSets) {
if (this.config.options.smartUnpack !== false) {
detectUnpackedDirs(fileSet, unpackedPaths)
}
const tmpUnpackedPaths = new Set<string>()
for (let i = 0; i < fileSet.files.length; i++) {
const file = fileSet.files[i]
const transformedData = fileSet.transformedFiles?.get(i)
const stat = fileSet.metadata.get(file)!
const relative = path.relative(this.config.defaultDestination, getDestinationPath(file, fileSet))
const destination = path.resolve(this.rootForAppFilesWithoutAsar, relative)
matchUnpacker(file, destination, stat, tmpUnpackedPaths)
taskManager.addTask(writeFileOrProcessSymlink({ transformedData, file, destination, stat, fileSet }))
if (taskManager.tasks.length > MAX_FILE_REQUESTS) {
await taskManager.awaitTasks()
}
}
if (tmpUnpackedPaths.size === fileSet.files.length) {
const relative = path.relative(this.config.defaultDestination, fileSet.destination)
unpackedPaths.add(relative)
} else {
for (const it of tmpUnpackedPaths) {
unpackedPaths.add(it)
}
}
}
await taskManager.awaitTasks()
for (const it of links) {
taskManager.addTask(symlink(it.link, it.file, symlinkType))
if (taskManager.tasks.length > MAX_FILE_REQUESTS) {
await taskManager.awaitTasks()
}
}
await taskManager.awaitTasks()
return {
unpackedPaths: Array.from(unpackedPaths),
copiedFiles: Array.from(copiedFiles),
}
}
}
function orderFileSet(fileSet: ResolvedFileSet): ResolvedFileSet {
const sortedFileEntries = Array.from(fileSet.files.entries())
sortedFileEntries.sort(([, a], [, b]) => {
if (a === b) {
return 0
}
const isAAddon = a.endsWith(".node")
const isBAddon = b.endsWith(".node")
if (isAAddon && !isBAddon) {
return 1
}
if (isBAddon && !isAAddon) {
return -1
}
return a < b ? -1 : 1
})
let transformedFiles: Map<number, string | Buffer> | undefined
if (fileSet.transformedFiles) {
transformedFiles = new Map()
const indexMap = new Map<number, number>()
for (const [newIndex, [oldIndex]] of sortedFileEntries.entries()) {
indexMap.set(oldIndex, newIndex)
}
for (const [oldIndex, value] of fileSet.transformedFiles) {
const newIndex = indexMap.get(oldIndex)
if (newIndex === undefined) {
const file = fileSet.files[oldIndex]
throw new Error(`Internal error: ${file} was lost while ordering asar`)
}
transformedFiles.set(newIndex, value)
}
}
const { src, destination, metadata } = fileSet
return {
src,
destination,
metadata,
files: sortedFileEntries.map(([, file]) => file),
transformedFiles,
}
}