diff --git a/package.json b/package.json index 2b2dde9..052e8b0 100644 --- a/package.json +++ b/package.json @@ -40,5 +40,15 @@ "asar": "^3.0.3", "dir-compare": "^2.4.0", "fs-extra": "^9.0.1" + }, + "husky": { + "hooks": { + "pre-commit": "lint-staged" + } + }, + "lint-staged": { + "*.ts": [ + "prettier --write" + ] } } diff --git a/src/asar-utils.ts b/src/asar-utils.ts new file mode 100644 index 0000000..725dfa9 --- /dev/null +++ b/src/asar-utils.ts @@ -0,0 +1,15 @@ +import * as fs from 'fs-extra'; +import * as path from 'path'; + +export enum AsarMode { + NO_ASAR, + HAS_ASAR, +} + +export const detectAsarMode = async (appPath: string) => { + const asarPath = path.resolve(appPath, 'Contents', 'Resources', 'app.asar'); + + if (!(await fs.pathExists(asarPath))) return AsarMode.NO_ASAR; + + return AsarMode.HAS_ASAR; +}; diff --git a/src/file-utils.ts b/src/file-utils.ts new file mode 100644 index 0000000..6b37ad8 --- /dev/null +++ b/src/file-utils.ts @@ -0,0 +1,61 @@ +import { spawn } from '@malept/cross-spawn-promise'; +import * as fs from 'fs-extra'; +import * as path from 'path'; + +const MACHO_PREFIX = 'Mach-O '; + +export enum AppFileType { + MACHO, + PLAIN, + SNAPSHOT, + APP_CODE, +} + +export type AppFile = { + relativePath: string; + type: AppFileType; +}; + +/** + * + * @param appPath Path to the application + */ +export const getAllAppFiles = async (appPath: string): Promise => { + const files: AppFile[] = []; + + const visited = new Set(); + const traverse = async (p: string) => { + p = await fs.realpath(p); + if (visited.has(p)) return; + visited.add(p); + + const info = await fs.stat(p); + if (info.isSymbolicLink()) return; + if (info.isFile()) { + let fileType = AppFileType.PLAIN; + + const fileOutput = await spawn('file', ['--brief', '--no-pad', p]); + if (p.includes('app.asar')) { + fileType = AppFileType.APP_CODE; + } else if (fileOutput.startsWith(MACHO_PREFIX)) { + fileType = AppFileType.MACHO; + } else if (p.endsWith('.bin')) { + fileType = AppFileType.SNAPSHOT; + } + + files.push({ + relativePath: path.relative(appPath, p), + type: fileType, + }); + } + + if (info.isDirectory()) { + for (const child of await fs.readdir(p)) { + await traverse(path.resolve(p, child)); + } + } + }; + await traverse(appPath); + + return files; +}; diff --git a/src/index.ts b/src/index.ts index b8fb994..b614f1f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,12 @@ import { spawn } from '@malept/cross-spawn-promise'; import * as asar from 'asar'; -import * as crypto from 'crypto'; import * as fs from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; import * as dircompare from 'dir-compare'; - -const MACHO_PREFIX = 'Mach-O '; +import { AppFile, AppFileType, getAllAppFiles } from './file-utils'; +import { AsarMode, detectAsarMode } from './asar-utils'; +import { sha } from './sha'; type MakeUniversalOpts = { /** @@ -29,76 +29,8 @@ type MakeUniversalOpts = { force: boolean; }; -enum AsarMode { - NO_ASAR, - HAS_ASAR, -} - -export const detectAsarMode = async (appPath: string) => { - const asarPath = path.resolve(appPath, 'Contents', 'Resources', 'app.asar'); - - if (!(await fs.pathExists(asarPath))) return AsarMode.NO_ASAR; - - return AsarMode.HAS_ASAR; -}; - -enum AppFileType { - MACHO, - PLAIN, - SNAPSHOT, - APP_CODE, -} - -type AppFile = { - relativePath: string; - type: AppFileType; -} - -const getAllFiles = async (appPath: string): Promise => { - const files: AppFile[] = []; - - const visited = new Set(); - const traverse = async (p: string) => { - p = await fs.realpath(p); - if (visited.has(p)) return; - visited.add(p); - - const info = await fs.stat(p); - if (info.isSymbolicLink()) return; - if (info.isFile()) { - let fileType = AppFileType.PLAIN; - - const fileOutput = await spawn('file', ['--brief', '--no-pad', p]); - if (p.includes('app.asar')) { - fileType = AppFileType.APP_CODE; - } else if (fileOutput.startsWith(MACHO_PREFIX)) { - fileType = AppFileType.MACHO; - } else if (p.endsWith('.bin')) { - fileType = AppFileType.SNAPSHOT; - } - - files.push({ - relativePath: path.relative(appPath, p), - type: fileType, - }); - } - - if (info.isDirectory()) { - for (const child of await fs.readdir(p)) { - await traverse(path.resolve(p, child)); - } - } - }; - await traverse(appPath); - - return files; -}; - -const dupedFiles = (files: AppFile[]) => files.filter(f => f.type !== AppFileType.SNAPSHOT && f.type !== AppFileType.APP_CODE); - -const sha = async (filePath: string) => { - return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); -} +const dupedFiles = (files: AppFile[]) => + files.filter((f) => f.type !== AppFileType.SNAPSHOT && f.type !== AppFileType.APP_CODE); export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise => { if (process.platform !== 'darwin') @@ -136,14 +68,16 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = const uniqueToX64: string[] = []; const uniqueToArm64: string[] = []; - const x64Files = await getAllFiles(await fs.realpath(tmpApp)); - const arm64Files = await getAllFiles(opts.arm64AppPath); + const x64Files = await getAllAppFiles(await fs.realpath(tmpApp)); + const arm64Files = await getAllAppFiles(opts.arm64AppPath); for (const file of dupedFiles(x64Files)) { - if (!arm64Files.some(f => f.relativePath === file.relativePath)) uniqueToX64.push(file.relativePath); + if (!arm64Files.some((f) => f.relativePath === file.relativePath)) + uniqueToX64.push(file.relativePath); } for (const file of dupedFiles(arm64Files)) { - if (!x64Files.some(f => f.relativePath === file.relativePath)) uniqueToArm64.push(file.relativePath); + if (!x64Files.some((f) => f.relativePath === file.relativePath)) + uniqueToArm64.push(file.relativePath); } if (uniqueToX64.length !== 0 || uniqueToArm64.length !== 0) { console.error({ @@ -155,16 +89,18 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = ); } - for (const file of x64Files.filter(f => f.type === AppFileType.PLAIN)) { + for (const file of x64Files.filter((f) => f.type === AppFileType.PLAIN)) { const x64Sha = await sha(path.resolve(opts.x64AppPath, file.relativePath)); const arm64Sha = await sha(path.resolve(opts.arm64AppPath, file.relativePath)); if (x64Sha !== arm64Sha) { console.error(`${x64Sha} !== ${arm64Sha}`); - throw new Error(`Expected all non-binary files to have identical SHAs when creating a universal build but "${file.relativePath}" did not`); + throw new Error( + `Expected all non-binary files to have identical SHAs when creating a universal build but "${file.relativePath}" did not`, + ); } } - for (const machOFile of x64Files.filter(f => f.type === AppFileType.MACHO)) { + for (const machOFile of x64Files.filter((f) => f.type === AppFileType.MACHO)) { await spawn('lipo', [ await fs.realpath(path.resolve(tmpApp, machOFile.relativePath)), await fs.realpath(path.resolve(opts.arm64AppPath, machOFile.relativePath)), @@ -175,51 +111,104 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = } if (x64AsarMode === AsarMode.NO_ASAR) { - const comparison = dircompare.compareSync(path.resolve(tmpApp, 'Contents', 'Resources', 'app'), path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app'), { compareSize: true, compareContent: true }); + const comparison = dircompare.compareSync( + path.resolve(tmpApp, 'Contents', 'Resources', 'app'), + path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app'), + { compareSize: true, compareContent: true }, + ); if (!comparison.same) { - await fs.move(path.resolve(tmpApp, 'Contents', 'Resources', 'app'), path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64')); - await fs.copy(path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app'), path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64')); + await fs.move( + path.resolve(tmpApp, 'Contents', 'Resources', 'app'), + path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64'), + ); + await fs.copy( + path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app'), + path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64'), + ); const entryAsar = path.resolve(tmpDir, 'entry-asar'); await fs.mkdir(entryAsar); - await fs.copy(path.resolve(__dirname, '..', '..', 'entry-asar', 'no-asar.js'), path.resolve(entryAsar, 'index.js')); - let pj = await fs.readJson(path.resolve(opts.x64AppPath, 'Contents', 'Resources', 'app', 'package.json')); + await fs.copy( + path.resolve(__dirname, '..', '..', 'entry-asar', 'no-asar.js'), + path.resolve(entryAsar, 'index.js'), + ); + let pj = await fs.readJson( + path.resolve(opts.x64AppPath, 'Contents', 'Resources', 'app', 'package.json'), + ); pj.main = 'index.js'; await fs.writeJson(path.resolve(entryAsar, 'package.json'), pj); - await asar.createPackage(entryAsar, path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar')); + await asar.createPackage( + entryAsar, + path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), + ); } } if (x64AsarMode === AsarMode.HAS_ASAR) { const x64AsarSha = await sha(path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar')); - const arm64AsarSha = await sha(path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar')); - + const arm64AsarSha = await sha( + path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar'), + ); + if (x64AsarSha !== arm64AsarSha) { - await fs.move(path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64.asar')); + await fs.move( + path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), + path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64.asar'), + ); const x64Unpacked = path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar.unpacked'); if (await fs.pathExists(x64Unpacked)) { - await fs.move(x64Unpacked, path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64.asar.unpacked')); + await fs.move( + x64Unpacked, + path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64.asar.unpacked'), + ); } - await fs.copy(path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar'), path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64.asar')); - const arm64Unpacked = path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar.unpacked'); + await fs.copy( + path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar'), + path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64.asar'), + ); + const arm64Unpacked = path.resolve( + opts.arm64AppPath, + 'Contents', + 'Resources', + 'app.asar.unpacked', + ); if (await fs.pathExists(arm64Unpacked)) { - await fs.copy(arm64Unpacked, path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64.asar.unpacked')); + await fs.copy( + arm64Unpacked, + path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64.asar.unpacked'), + ); } const entryAsar = path.resolve(tmpDir, 'entry-asar'); await fs.mkdir(entryAsar); - await fs.copy(path.resolve(__dirname, '..', '..', 'entry-asar', 'has-asar.js'), path.resolve(entryAsar, 'index.js')); - let pj = JSON.parse((await asar.extractFile(path.resolve(opts.x64AppPath, 'Contents', 'Resources', 'app.asar'), 'package.json')).toString('utf8')); + await fs.copy( + path.resolve(__dirname, '..', '..', 'entry-asar', 'has-asar.js'), + path.resolve(entryAsar, 'index.js'), + ); + let pj = JSON.parse( + ( + await asar.extractFile( + path.resolve(opts.x64AppPath, 'Contents', 'Resources', 'app.asar'), + 'package.json', + ) + ).toString('utf8'), + ); pj.main = 'index.js'; await fs.writeJson(path.resolve(entryAsar, 'package.json'), pj); - await asar.createPackage(entryAsar, path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar')); + await asar.createPackage( + entryAsar, + path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), + ); } } - for (const snapshotsFile of arm64Files.filter(f => f.type === AppFileType.SNAPSHOT)) { - await fs.copy(path.resolve(opts.arm64AppPath, snapshotsFile.relativePath), path.resolve(tmpApp, snapshotsFile.relativePath)); + for (const snapshotsFile of arm64Files.filter((f) => f.type === AppFileType.SNAPSHOT)) { + await fs.copy( + path.resolve(opts.arm64AppPath, snapshotsFile.relativePath), + path.resolve(tmpApp, snapshotsFile.relativePath), + ); } await spawn('mv', [tmpApp, opts.outAppPath]); diff --git a/src/sha.ts b/src/sha.ts new file mode 100644 index 0000000..1cb464a --- /dev/null +++ b/src/sha.ts @@ -0,0 +1,13 @@ +import * as fs from 'fs-extra'; +import * as crypto from 'crypto'; + +export const sha = async (filePath: string) => { + const hash = crypto.createHash('sha256'); + const fileStream = fs.createReadStream(filePath); + fileStream.pipe(hash); + await new Promise((resolve, reject) => { + fileStream.on('end', () => resolve()); + fileStream.on('error', (err) => reject(err)); + }); + return hash.digest('hex'); +};