Skip to content

Commit 38ab1c3

Browse files
feat: add option to merge ASARs (#34)
* feat: fuse ASARs * Rename, improve * Rename option * Drop universal from MACHO_MAGIC
1 parent 9f86e1d commit 38ab1c3

File tree

4 files changed

+202
-3
lines changed

4 files changed

+202
-3
lines changed

package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"apple silicon",
1111
"universal"
1212
],
13-
"repository": {
13+
"repository": {
1414
"type": "git",
1515
"url": "https://github.com/electron/universal.git"
1616
},
@@ -33,6 +33,7 @@
3333
"@continuous-auth/semantic-release-npm": "^2.0.0",
3434
"@types/debug": "^4.1.5",
3535
"@types/fs-extra": "^9.0.4",
36+
"@types/minimatch": "^3.0.5",
3637
"@types/node": "^14.14.7",
3738
"@types/plist": "^3.0.2",
3839
"husky": "^4.3.0",
@@ -47,6 +48,7 @@
4748
"debug": "^4.3.1",
4849
"dir-compare": "^2.4.0",
4950
"fs-extra": "^9.0.1",
51+
"minimatch": "^3.0.4",
5052
"plist": "^3.0.4"
5153
},
5254
"husky": {

src/asar-utils.ts

+173
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,38 @@
11
import * as asar from 'asar';
2+
import { execFileSync } from 'child_process';
23
import * as crypto from 'crypto';
34
import * as fs from 'fs-extra';
45
import * as path from 'path';
6+
import * as minimatch from 'minimatch';
7+
import * as os from 'os';
58
import { d } from './debug';
69

10+
const LIPO = 'lipo';
11+
712
export enum AsarMode {
813
NO_ASAR,
914
HAS_ASAR,
1015
}
1116

17+
export type MergeASARsOptions = {
18+
x64AsarPath: string;
19+
arm64AsarPath: string;
20+
outputAsarPath: string;
21+
22+
singleArchFiles?: string;
23+
};
24+
25+
// See: https://github.com/apple-opensource-mirror/llvmCore/blob/0c60489d96c87140db9a6a14c6e82b15f5e5d252/include/llvm/Object/MachOFormat.h#L108-L112
26+
const MACHO_MAGIC = new Set([
27+
// 32-bit Mach-O
28+
0xfeedface,
29+
0xcefaedfe,
30+
31+
// 64-bit Mach-O
32+
0xfeedfacf,
33+
0xcffaedfe,
34+
]);
35+
1236
export const detectAsarMode = async (appPath: string) => {
1337
d('checking asar mode of', appPath);
1438
const asarPath = path.resolve(appPath, 'Contents', 'Resources', 'app.asar');
@@ -31,3 +55,152 @@ export const generateAsarIntegrity = (asarPath: string) => {
3155
.digest('hex'),
3256
};
3357
};
58+
59+
function toRelativePath(file: string): string {
60+
return file.replace(/^\//, '');
61+
}
62+
63+
function isDirectory(a: string, file: string): boolean {
64+
return Boolean('files' in asar.statFile(a, file));
65+
}
66+
67+
function checkSingleArch(archive: string, file: string, allowList?: string): void {
68+
if (allowList === undefined || !minimatch(file, allowList, { matchBase: true })) {
69+
throw new Error(
70+
`Detected unique file "${file}" in "${archive}" not covered by ` +
71+
`allowList rule: "${allowList}"`,
72+
);
73+
}
74+
}
75+
76+
export const mergeASARs = async ({
77+
x64AsarPath,
78+
arm64AsarPath,
79+
outputAsarPath,
80+
singleArchFiles,
81+
}: MergeASARsOptions): Promise<void> => {
82+
d(`merging ${x64AsarPath} and ${arm64AsarPath}`);
83+
84+
const x64Files = new Set(asar.listPackage(x64AsarPath).map(toRelativePath));
85+
const arm64Files = new Set(asar.listPackage(arm64AsarPath).map(toRelativePath));
86+
87+
//
88+
// Build set of unpacked directories and files
89+
//
90+
91+
const unpackedFiles = new Set<string>();
92+
93+
function buildUnpacked(a: string, fileList: Set<string>): void {
94+
for (const file of fileList) {
95+
const stat = asar.statFile(a, file);
96+
97+
if (!('unpacked' in stat) || !stat.unpacked) {
98+
continue;
99+
}
100+
101+
if ('files' in stat) {
102+
continue;
103+
}
104+
unpackedFiles.add(file);
105+
}
106+
}
107+
108+
buildUnpacked(x64AsarPath, x64Files);
109+
buildUnpacked(arm64AsarPath, arm64Files);
110+
111+
//
112+
// Build list of files/directories unique to each asar
113+
//
114+
115+
for (const file of x64Files) {
116+
if (!arm64Files.has(file)) {
117+
checkSingleArch(x64AsarPath, file, singleArchFiles);
118+
}
119+
}
120+
const arm64Unique = [];
121+
for (const file of arm64Files) {
122+
if (!x64Files.has(file)) {
123+
checkSingleArch(arm64AsarPath, file, singleArchFiles);
124+
arm64Unique.push(file);
125+
}
126+
}
127+
128+
//
129+
// Find common bindings with different content
130+
//
131+
132+
const commonBindings = [];
133+
for (const file of x64Files) {
134+
if (!arm64Files.has(file)) {
135+
continue;
136+
}
137+
138+
// Skip directories
139+
if (isDirectory(x64AsarPath, file)) {
140+
continue;
141+
}
142+
143+
const x64Content = asar.extractFile(x64AsarPath, file);
144+
const arm64Content = asar.extractFile(arm64AsarPath, file);
145+
146+
if (x64Content.compare(arm64Content) === 0) {
147+
continue;
148+
}
149+
150+
if (!MACHO_MAGIC.has(x64Content.readUInt32LE(0))) {
151+
throw new Error(`Can't reconcile two non-macho files ${file}`);
152+
}
153+
154+
commonBindings.push(file);
155+
}
156+
157+
//
158+
// Extract both
159+
//
160+
161+
const x64Dir = await fs.mkdtemp(path.join(os.tmpdir(), 'x64-'));
162+
const arm64Dir = await fs.mkdtemp(path.join(os.tmpdir(), 'arm64-'));
163+
164+
try {
165+
d(`extracting ${x64AsarPath} to ${x64Dir}`);
166+
asar.extractAll(x64AsarPath, x64Dir);
167+
168+
d(`extracting ${arm64AsarPath} to ${arm64Dir}`);
169+
asar.extractAll(arm64AsarPath, arm64Dir);
170+
171+
for (const file of arm64Unique) {
172+
const source = path.resolve(arm64Dir, file);
173+
const destination = path.resolve(x64Dir, file);
174+
175+
if (isDirectory(arm64AsarPath, file)) {
176+
d(`creating unique directory: ${file}`);
177+
await fs.mkdirp(destination);
178+
continue;
179+
}
180+
181+
d(`xopying unique file: ${file}`);
182+
await fs.mkdirp(path.dirname(destination));
183+
await fs.copy(source, destination);
184+
}
185+
186+
for (const binding of commonBindings) {
187+
const source = await fs.realpath(path.resolve(arm64Dir, binding));
188+
const destination = await fs.realpath(path.resolve(x64Dir, binding));
189+
190+
d(`merging binding: ${binding}`);
191+
execFileSync(LIPO, [source, destination, '-create', '-output', destination]);
192+
}
193+
194+
d(`creating archive at ${outputAsarPath}`);
195+
196+
const resolvedUnpack = Array.from(unpackedFiles).map((file) => path.join(x64Dir, file));
197+
198+
await asar.createPackageWithOptions(x64Dir, outputAsarPath, {
199+
unpack: `{${resolvedUnpack.join(',')}}`,
200+
});
201+
202+
d('done merging');
203+
} finally {
204+
await Promise.all([fs.remove(x64Dir), fs.remove(arm64Dir)]);
205+
}
206+
};

src/index.ts

+21-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import * as plist from 'plist';
88
import * as dircompare from 'dir-compare';
99

1010
import { AppFile, AppFileType, getAllAppFiles } from './file-utils';
11-
import { AsarMode, detectAsarMode, generateAsarIntegrity } from './asar-utils';
11+
import { AsarMode, detectAsarMode, generateAsarIntegrity, mergeASARs } from './asar-utils';
1212
import { sha } from './sha';
1313
import { d } from './debug';
1414

@@ -31,6 +31,14 @@ type MakeUniversalOpts = {
3131
* Forcefully overwrite any existing files that are in the way of generating the universal application
3232
*/
3333
force: boolean;
34+
/**
35+
* Merge x64 and arm64 ASARs into one.
36+
*/
37+
mergeASARs?: boolean;
38+
/**
39+
* Minimatch pattern of paths that are allowed to be present in one of the ASAR files, but not in the other.
40+
*/
41+
singleArchFiles?: string;
3442
};
3543

3644
const dupedFiles = (files: AppFile[]) =>
@@ -186,7 +194,18 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
186194
* look at codifying that assumption as actual logic.
187195
*/
188196
// FIXME: Codify the assumption that app.asar.unpacked only contains native modules
189-
if (x64AsarMode === AsarMode.HAS_ASAR) {
197+
if (x64AsarMode === AsarMode.HAS_ASAR && opts.mergeASARs) {
198+
d('merging x64 and arm64 asars');
199+
const output = path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar');
200+
await mergeASARs({
201+
x64AsarPath: path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'),
202+
arm64AsarPath: path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar'),
203+
outputAsarPath: output,
204+
singleArchFiles: opts.singleArchFiles,
205+
});
206+
207+
generatedIntegrity['Resources/app.asar'] = generateAsarIntegrity(output);
208+
} else if (x64AsarMode === AsarMode.HAS_ASAR) {
190209
d('checking if the x64 and arm64 asars are identical');
191210
const x64AsarSha = await sha(path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'));
192211
const arm64AsarSha = await sha(

yarn.lock

+5
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,11 @@
294294
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
295295
integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==
296296

297+
"@types/minimatch@^3.0.5":
298+
version "3.0.5"
299+
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40"
300+
integrity sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==
301+
297302
"@types/minimist@^1.2.0":
298303
version "1.2.1"
299304
resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.1.tgz#283f669ff76d7b8260df8ab7a4262cc83d988256"

0 commit comments

Comments
 (0)