4 Commits

Author SHA1 Message Date
Niels Leenheer
477a52e779 fix: use setEncoding() and read() for crypto.createHash instead of digest() (#11) 2020-11-20 02:05:59 -08:00
Samuel Attard
107823fc2c fix: use realpath when scanning app files 2020-11-19 14:43:08 -08:00
Samuel Attard
621083fe1f fix: add debug logging 2020-11-19 10:01:20 -08:00
Samuel Attard
0770238718 chore: cleanup files and split into modules 2020-11-19 09:49:17 -08:00
7 changed files with 257 additions and 101 deletions

View File

@@ -27,6 +27,7 @@
}, },
"devDependencies": { "devDependencies": {
"@continuous-auth/semantic-release-npm": "^2.0.0", "@continuous-auth/semantic-release-npm": "^2.0.0",
"@types/debug": "^4.1.5",
"@types/fs-extra": "^9.0.4", "@types/fs-extra": "^9.0.4",
"@types/node": "^14.14.7", "@types/node": "^14.14.7",
"husky": "^4.3.0", "husky": "^4.3.0",
@@ -38,7 +39,18 @@
"dependencies": { "dependencies": {
"@malept/cross-spawn-promise": "^1.1.0", "@malept/cross-spawn-promise": "^1.1.0",
"asar": "^3.0.3", "asar": "^3.0.3",
"debug": "^4.3.1",
"dir-compare": "^2.4.0", "dir-compare": "^2.4.0",
"fs-extra": "^9.0.1" "fs-extra": "^9.0.1"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.ts": [
"prettier --write"
]
} }
} }

21
src/asar-utils.ts Normal file
View File

@@ -0,0 +1,21 @@
import * as fs from 'fs-extra';
import * as path from 'path';
import { d } from './debug';
export enum AsarMode {
NO_ASAR,
HAS_ASAR,
}
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))) {
d('determined no asar');
return AsarMode.NO_ASAR;
}
d('determined has asar');
return AsarMode.HAS_ASAR;
};

3
src/debug.ts Normal file
View File

@@ -0,0 +1,3 @@
import * as debug from 'debug';
export const d = debug('electron-universal');

61
src/file-utils.ts Normal file
View File

@@ -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<AppFile[]> => {
const files: AppFile[] = [];
const visited = new Set<string>();
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;
};

View File

@@ -1,12 +1,13 @@
import { spawn } from '@malept/cross-spawn-promise'; import { spawn } from '@malept/cross-spawn-promise';
import * as asar from 'asar'; import * as asar from 'asar';
import * as crypto from 'crypto';
import * as fs from 'fs-extra'; import * as fs from 'fs-extra';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import * as dircompare from 'dir-compare'; import * as dircompare from 'dir-compare';
import { AppFile, AppFileType, getAllAppFiles } from './file-utils';
const MACHO_PREFIX = 'Mach-O '; import { AsarMode, detectAsarMode } from './asar-utils';
import { sha } from './sha';
import { d } from './debug';
type MakeUniversalOpts = { type MakeUniversalOpts = {
/** /**
@@ -29,78 +30,12 @@ type MakeUniversalOpts = {
force: boolean; force: boolean;
}; };
enum AsarMode { const dupedFiles = (files: AppFile[]) =>
NO_ASAR, files.filter((f) => f.type !== AppFileType.SNAPSHOT && f.type !== AppFileType.APP_CODE);
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<AppFile[]> => {
const files: AppFile[] = [];
const visited = new Set<string>();
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');
}
export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> => { export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> => {
d('making a universal app with options', opts);
if (process.platform !== 'darwin') if (process.platform !== 'darwin')
throw new Error('@electron/universal is only supported on darwin platforms'); throw new Error('@electron/universal is only supported on darwin platforms');
if (!opts.x64AppPath || !path.isAbsolute(opts.x64AppPath)) if (!opts.x64AppPath || !path.isAbsolute(opts.x64AppPath))
@@ -111,17 +46,21 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
throw new Error('Expected opts.outAppPath to be an absolute path but it was not'); throw new Error('Expected opts.outAppPath to be an absolute path but it was not');
if (await fs.pathExists(opts.outAppPath)) { if (await fs.pathExists(opts.outAppPath)) {
d('output path exists already');
if (!opts.force) { if (!opts.force) {
throw new Error( throw new Error(
`The out path "${opts.outAppPath}" already exists and force is not set to true`, `The out path "${opts.outAppPath}" already exists and force is not set to true`,
); );
} else { } else {
d('overwriting existing application because force == true');
await fs.remove(opts.outAppPath); await fs.remove(opts.outAppPath);
} }
} }
const x64AsarMode = await detectAsarMode(opts.x64AppPath); const x64AsarMode = await detectAsarMode(opts.x64AppPath);
const arm64AsarMode = await detectAsarMode(opts.arm64AppPath); const arm64AsarMode = await detectAsarMode(opts.arm64AppPath);
d('detected x64AsarMode =', x64AsarMode);
d('detected arm64AsarMode =', arm64AsarMode);
if (x64AsarMode !== arm64AsarMode) if (x64AsarMode !== arm64AsarMode)
throw new Error( throw new Error(
@@ -129,23 +68,28 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
); );
const tmpDir = await fs.mkdtemp(path.resolve(os.tmpdir(), 'electron-universal-')); const tmpDir = await fs.mkdtemp(path.resolve(os.tmpdir(), 'electron-universal-'));
d('building universal app in', tmpDir);
try { try {
d('copying x64 app as starter template');
const tmpApp = path.resolve(tmpDir, 'Tmp.app'); const tmpApp = path.resolve(tmpDir, 'Tmp.app');
await spawn('cp', ['-R', opts.x64AppPath, tmpApp]); await spawn('cp', ['-R', opts.x64AppPath, tmpApp]);
const uniqueToX64: string[] = []; const uniqueToX64: string[] = [];
const uniqueToArm64: string[] = []; const uniqueToArm64: string[] = [];
const x64Files = await getAllFiles(await fs.realpath(tmpApp)); const x64Files = await getAllAppFiles(await fs.realpath(tmpApp));
const arm64Files = await getAllFiles(opts.arm64AppPath); const arm64Files = await getAllAppFiles(await fs.realpath(opts.arm64AppPath));
for (const file of dupedFiles(x64Files)) { 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)) { 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) { if (uniqueToX64.length !== 0 || uniqueToArm64.length !== 0) {
d('some files were not in both builds, aborting');
console.error({ console.error({
uniqueToX64, uniqueToX64,
uniqueToArm64, uniqueToArm64,
@@ -155,73 +99,160 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
); );
} }
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 x64Sha = await sha(path.resolve(opts.x64AppPath, file.relativePath));
const arm64Sha = await sha(path.resolve(opts.arm64AppPath, file.relativePath)); const arm64Sha = await sha(path.resolve(opts.arm64AppPath, file.relativePath));
if (x64Sha !== arm64Sha) { 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`); 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)) {
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 spawn('lipo', [
await fs.realpath(path.resolve(tmpApp, machOFile.relativePath)), first,
await fs.realpath(path.resolve(opts.arm64AppPath, machOFile.relativePath)), second,
'-create', '-create',
'-output', '-output',
await fs.realpath(path.resolve(tmpApp, machOFile.relativePath)), 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) { 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 }); 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) { if (!comparison.same) {
await fs.move(path.resolve(tmpApp, 'Contents', 'Resources', 'app'), path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64')); d('x64 and arm64 app folders are different, creating dynamic entry ASAR');
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'); const entryAsar = path.resolve(tmpDir, 'entry-asar');
await fs.mkdir(entryAsar); await fs.mkdir(entryAsar);
await fs.copy(path.resolve(__dirname, '..', '..', 'entry-asar', 'no-asar.js'), path.resolve(entryAsar, 'index.js')); await fs.copy(
let pj = await fs.readJson(path.resolve(opts.x64AppPath, 'Contents', 'Resources', 'app', 'package.json')); 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'; pj.main = 'index.js';
await fs.writeJson(path.resolve(entryAsar, 'package.json'), pj); 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'),
);
} 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) { 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 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) { if (x64AsarSha !== arm64AsarSha) {
await fs.move(path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64.asar')); 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'),
);
const x64Unpacked = path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar.unpacked'); const x64Unpacked = path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar.unpacked');
if (await fs.pathExists(x64Unpacked)) { 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')); await fs.copy(
const arm64Unpacked = path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar.unpacked'); 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)) { 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'); const entryAsar = path.resolve(tmpDir, 'entry-asar');
await fs.mkdir(entryAsar); await fs.mkdir(entryAsar);
await fs.copy(path.resolve(__dirname, '..', '..', 'entry-asar', 'has-asar.js'), path.resolve(entryAsar, 'index.js')); await fs.copy(
let pj = JSON.parse((await asar.extractFile(path.resolve(opts.x64AppPath, 'Contents', 'Resources', 'app.asar'), 'package.json')).toString('utf8')); 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'; pj.main = 'index.js';
await fs.writeJson(path.resolve(entryAsar, 'package.json'), pj); 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'),
);
} else {
d('x64 and arm64 asars are the same');
} }
} }
for (const snapshotsFile of arm64Files.filter(f => f.type === AppFileType.SNAPSHOT)) { 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)); 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 fs.mkdirp(path.dirname(opts.outAppPath));
await spawn('mv', [tmpApp, opts.outAppPath]); await spawn('mv', [tmpApp, opts.outAppPath]);
} catch (err) { } catch (err) {
throw err; throw err;

16
src/sha.ts Normal file
View File

@@ -0,0 +1,16 @@
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');
hash.setEncoding('hex');
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.read();
};

View File

@@ -269,6 +269,11 @@
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82"
integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== 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": "@types/fs-extra@^9.0.4":
version "9.0.4" version "9.0.4"
resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-9.0.4.tgz#12553138cf0438db9a31cdc8b0a3aa9332eb67aa" resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-9.0.4.tgz#12553138cf0438db9a31cdc8b0a3aa9332eb67aa"
@@ -1143,6 +1148,13 @@ debug@^3.1.0:
dependencies: dependencies:
ms "^2.1.1" 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: debuglog@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492" resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492"