diff --git a/package.json b/package.json index 052e8b0..1a7145a 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ }, "devDependencies": { "@continuous-auth/semantic-release-npm": "^2.0.0", + "@types/debug": "^4.1.5", "@types/fs-extra": "^9.0.4", "@types/node": "^14.14.7", "husky": "^4.3.0", @@ -38,6 +39,7 @@ "dependencies": { "@malept/cross-spawn-promise": "^1.1.0", "asar": "^3.0.3", + "debug": "^4.3.1", "dir-compare": "^2.4.0", "fs-extra": "^9.0.1" }, diff --git a/src/asar-utils.ts b/src/asar-utils.ts index 725dfa9..aa002ce 100644 --- a/src/asar-utils.ts +++ b/src/asar-utils.ts @@ -1,5 +1,6 @@ import * as fs from 'fs-extra'; import * as path from 'path'; +import { d } from './debug'; export enum AsarMode { NO_ASAR, @@ -7,9 +8,14 @@ export enum AsarMode { } export const detectAsarMode = async (appPath: string) => { + d('checking asar mode of', appPath); const asarPath = path.resolve(appPath, 'Contents', 'Resources', 'app.asar'); - if (!(await fs.pathExists(asarPath))) return AsarMode.NO_ASAR; + if (!(await fs.pathExists(asarPath))) { + d('determined no asar'); + return AsarMode.NO_ASAR; + } + d('determined has asar'); return AsarMode.HAS_ASAR; }; diff --git a/src/debug.ts b/src/debug.ts new file mode 100644 index 0000000..e01d4c8 --- /dev/null +++ b/src/debug.ts @@ -0,0 +1,3 @@ +import * as debug from 'debug'; + +export const d = debug('electron-universal'); diff --git a/src/index.ts b/src/index.ts index b614f1f..1b4bda0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import * as dircompare from 'dir-compare'; import { AppFile, AppFileType, getAllAppFiles } from './file-utils'; import { AsarMode, detectAsarMode } from './asar-utils'; import { sha } from './sha'; +import { d } from './debug'; type MakeUniversalOpts = { /** @@ -33,6 +34,8 @@ const dupedFiles = (files: AppFile[]) => files.filter((f) => f.type !== AppFileType.SNAPSHOT && f.type !== AppFileType.APP_CODE); export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise => { + d('making a universal app with options', opts); + if (process.platform !== 'darwin') throw new Error('@electron/universal is only supported on darwin platforms'); if (!opts.x64AppPath || !path.isAbsolute(opts.x64AppPath)) @@ -43,17 +46,21 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = throw new Error('Expected opts.outAppPath to be an absolute path but it was not'); if (await fs.pathExists(opts.outAppPath)) { + d('output path exists already'); if (!opts.force) { throw new Error( `The out path "${opts.outAppPath}" already exists and force is not set to true`, ); } else { + d('overwriting existing application because force == true'); await fs.remove(opts.outAppPath); } } const x64AsarMode = await detectAsarMode(opts.x64AppPath); const arm64AsarMode = await detectAsarMode(opts.arm64AppPath); + d('detected x64AsarMode =', x64AsarMode); + d('detected arm64AsarMode =', arm64AsarMode); if (x64AsarMode !== arm64AsarMode) throw new Error( @@ -61,8 +68,10 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = ); const tmpDir = await fs.mkdtemp(path.resolve(os.tmpdir(), 'electron-universal-')); + d('building universal app in', tmpDir); try { + d('copying x64 app as starter template'); const tmpApp = path.resolve(tmpDir, 'Tmp.app'); await spawn('cp', ['-R', opts.x64AppPath, tmpApp]); @@ -80,6 +89,7 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = uniqueToArm64.push(file.relativePath); } if (uniqueToX64.length !== 0 || uniqueToArm64.length !== 0) { + d('some files were not in both builds, aborting'); console.error({ uniqueToX64, uniqueToArm64, @@ -93,7 +103,7 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = 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}`); + d('SHA for file', file.relativePath, `does not match across builds ${x64Sha}!=${arm64Sha}`); throw new Error( `Expected all non-binary files to have identical SHAs when creating a universal build but "${file.relativePath}" did not`, ); @@ -101,23 +111,38 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = } for (const machOFile of x64Files.filter((f) => f.type === AppFileType.MACHO)) { + const first = await fs.realpath(path.resolve(tmpApp, machOFile.relativePath)); + const second = await fs.realpath(path.resolve(opts.arm64AppPath, machOFile.relativePath)); + + d('joining two MachO files with lipo', { + first, + second, + }); await spawn('lipo', [ - await fs.realpath(path.resolve(tmpApp, machOFile.relativePath)), - await fs.realpath(path.resolve(opts.arm64AppPath, machOFile.relativePath)), + first, + second, '-create', '-output', await fs.realpath(path.resolve(tmpApp, machOFile.relativePath)), ]); } + /** + * If we don't have an ASAR we need to check if the two "app" folders are identical, if + * they are then we can just leave one there and call it a day. If the app folders for x64 + * and arm64 are different though we need to rename each folder and create a new fake "app" + * entrypoint to dynamically load the correct app folder + */ if (x64AsarMode === AsarMode.NO_ASAR) { - const comparison = dircompare.compareSync( + d('checking if the x64 and arm64 app folders are identical'); + const comparison = await dircompare.compare( path.resolve(tmpApp, 'Contents', 'Resources', 'app'), path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app'), { compareSize: true, compareContent: true }, ); if (!comparison.same) { + d('x64 and arm64 app folders are different, creating dynamic entry ASAR'); await fs.move( path.resolve(tmpApp, 'Contents', 'Resources', 'app'), path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64'), @@ -142,16 +167,28 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = entryAsar, path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), ); + } else { + d('x64 and arm64 app folders are the same'); } } + /** + * If we have an ASAR we just need to check if the two "app.asar" files have the same hash, + * if they are, same as above, we can leave one there and call it a day. If they're different + * we have to make a dynamic entrypoint. There is an assumption made here that every file in + * app.asar.unpacked is a native node module. This assumption _may_ not be true so we should + * look at codifying that assumption as actual logic. + */ + // FIXME: Codify the assumption that app.asar.unpacked only contains native modules if (x64AsarMode === AsarMode.HAS_ASAR) { + d('checking if the x64 and arm64 asars are identical'); const x64AsarSha = await sha(path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar')); const arm64AsarSha = await sha( path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar'), ); if (x64AsarSha !== arm64AsarSha) { + d('x64 and arm64 asars are different'); await fs.move( path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64.asar'), @@ -201,16 +238,20 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = entryAsar, path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), ); + } else { + d('x64 and arm64 asars are the same'); } } for (const snapshotsFile of arm64Files.filter((f) => f.type === AppFileType.SNAPSHOT)) { + d('copying snapshot file', snapshotsFile.relativePath, 'to target application'); await fs.copy( path.resolve(opts.arm64AppPath, snapshotsFile.relativePath), path.resolve(tmpApp, snapshotsFile.relativePath), ); } + d('moving final universal app to target destination'); await spawn('mv', [tmpApp, opts.outAppPath]); } catch (err) { throw err; diff --git a/src/sha.ts b/src/sha.ts index 1cb464a..a56add5 100644 --- a/src/sha.ts +++ b/src/sha.ts @@ -1,7 +1,9 @@ import * as fs from 'fs-extra'; import * as crypto from 'crypto'; +import { d } from './debug'; export const sha = async (filePath: string) => { + d('hashing', filePath); const hash = crypto.createHash('sha256'); const fileStream = fs.createReadStream(filePath); fileStream.pipe(hash); diff --git a/yarn.lock b/yarn.lock index 3705e8a..de041ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -269,6 +269,11 @@ resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== +"@types/debug@^4.1.5": + version "4.1.5" + resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.5.tgz#b14efa8852b7768d898906613c23f688713e02cd" + integrity sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ== + "@types/fs-extra@^9.0.4": version "9.0.4" resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-9.0.4.tgz#12553138cf0438db9a31cdc8b0a3aa9332eb67aa" @@ -1143,6 +1148,13 @@ debug@^3.1.0: dependencies: ms "^2.1.1" +debug@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" + integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== + dependencies: + ms "2.1.2" + debuglog@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492"