Skip to content

Commit d11807e

Browse files
feat: fuse ASARs
1 parent 9f86e1d commit d11807e

File tree

4 files changed

+193
-3
lines changed

4 files changed

+193
-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

+164
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,28 @@
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

710
export enum AsarMode {
811
NO_ASAR,
912
HAS_ASAR,
1013
}
1114

15+
export type FuseASARsOptions = {
16+
x64: string;
17+
arm64: string;
18+
output: string;
19+
20+
lipo?: string;
21+
uniqueAllowList?: string;
22+
};
23+
24+
const MACHO_MAGIC = new Set([0xfeedface, 0xcefaedfe, 0xfeedfacf, 0xcffaedfe]);
25+
1226
export const detectAsarMode = async (appPath: string) => {
1327
d('checking asar mode of', appPath);
1428
const asarPath = path.resolve(appPath, 'Contents', 'Resources', 'app.asar');
@@ -31,3 +45,153 @@ export const generateAsarIntegrity = (asarPath: string) => {
3145
.digest('hex'),
3246
};
3347
};
48+
49+
function toRelativePath(file: string): string {
50+
return file.replace(/^\//, '');
51+
}
52+
53+
function isDirectory(a: string, file: string): boolean {
54+
return Boolean('files' in asar.statFile(a, file));
55+
}
56+
57+
function checkUnique(archive: string, file: string, allowList?: string): void {
58+
if (allowList === undefined || !minimatch(file, allowList, { matchBase: true })) {
59+
throw new Error(
60+
`Detected unique file "${file}" in "${archive}" not covered by ` +
61+
`allowList rule: "${allowList}"`,
62+
);
63+
}
64+
}
65+
66+
export const fuseASARs = async ({
67+
x64,
68+
arm64,
69+
output,
70+
lipo = 'lipo',
71+
uniqueAllowList,
72+
}: FuseASARsOptions): Promise<void> => {
73+
d(`Fusing ${x64} and ${arm64}`);
74+
75+
const x64Files = new Set(asar.listPackage(x64).map(toRelativePath));
76+
const arm64Files = new Set(asar.listPackage(arm64).map(toRelativePath));
77+
78+
//
79+
// Build set of unpacked directories and files
80+
//
81+
82+
const unpackedFiles = new Set<string>();
83+
84+
function buildUnpacked(a: string, fileList: Set<string>): void {
85+
for (const file of fileList) {
86+
const stat = asar.statFile(a, file);
87+
88+
if (!('unpacked' in stat) || !stat.unpacked) {
89+
continue;
90+
}
91+
92+
if ('files' in stat) {
93+
continue;
94+
}
95+
unpackedFiles.add(file);
96+
}
97+
}
98+
99+
buildUnpacked(x64, x64Files);
100+
buildUnpacked(arm64, arm64Files);
101+
102+
//
103+
// Build list of files/directories unique to each asar
104+
//
105+
106+
for (const file of x64Files) {
107+
if (!arm64Files.has(file)) {
108+
checkUnique(x64, file, uniqueAllowList);
109+
}
110+
}
111+
const arm64Unique = [];
112+
for (const file of arm64Files) {
113+
if (!x64Files.has(file)) {
114+
checkUnique(arm64, file, uniqueAllowList);
115+
arm64Unique.push(file);
116+
}
117+
}
118+
119+
//
120+
// Find common bindings with different content
121+
//
122+
123+
const commonBindings = [];
124+
for (const file of x64Files) {
125+
if (!arm64Files.has(file)) {
126+
continue;
127+
}
128+
129+
// Skip directories
130+
if (isDirectory(x64, file)) {
131+
continue;
132+
}
133+
134+
const x64Content = asar.extractFile(x64, file);
135+
const arm64Content = asar.extractFile(arm64, file);
136+
137+
if (x64Content.compare(arm64Content) === 0) {
138+
continue;
139+
}
140+
141+
if (!MACHO_MAGIC.has(x64Content.readUInt32LE(0))) {
142+
throw new Error(`Can't reconcile two non-macho files ${file}`);
143+
}
144+
145+
commonBindings.push(file);
146+
}
147+
148+
//
149+
// Extract both
150+
//
151+
152+
const x64Dir = await fs.mkdtemp(path.join(os.tmpdir(), 'x64-'));
153+
const arm64Dir = await fs.mkdtemp(path.join(os.tmpdir(), 'arm64-'));
154+
155+
try {
156+
d(`Extracting ${x64} to ${x64Dir}`);
157+
asar.extractAll(x64, x64Dir);
158+
159+
d(`Extracting ${arm64} to ${arm64Dir}`);
160+
asar.extractAll(arm64, arm64Dir);
161+
162+
for (const file of arm64Unique) {
163+
const source = path.resolve(arm64Dir, file);
164+
const destination = path.resolve(x64Dir, file);
165+
166+
if (isDirectory(arm64, file)) {
167+
d(`Creating unique directory: ${file}`);
168+
await fs.mkdirp(destination);
169+
continue;
170+
}
171+
172+
d(`Copying unique file: ${file}`);
173+
await fs.mkdirp(path.dirname(destination));
174+
await fs.copy(source, destination);
175+
}
176+
177+
for (const binding of commonBindings) {
178+
const source = await fs.realpath(path.resolve(arm64Dir, binding));
179+
const destination = await fs.realpath(path.resolve(x64Dir, binding));
180+
181+
d(`Fusing binding: ${binding}`);
182+
execFileSync(lipo, [source, destination, '-create', '-output', destination]);
183+
}
184+
185+
d(`Creating archive at ${output}`);
186+
187+
const resolvedUnpack = Array.from(unpackedFiles).map((file) => path.join(x64Dir, file));
188+
189+
await asar.createPackageWithOptions(x64Dir, output, {
190+
unpack: `{${resolvedUnpack.join(',')}}`,
191+
});
192+
193+
d('Done fusing');
194+
} finally {
195+
await Promise.all([fs.remove(x64Dir), fs.remove(arm64Dir)]);
196+
}
197+
};

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, fuseASARs } 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+
* Fuse x64 and arm64 ASARs into one.
36+
*/
37+
fuseASARs?: boolean;
38+
/**
39+
* Minimatch pattern of paths that are allowed to be unique in ASARs.
40+
*/
41+
fuseASARsAllowList?: 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.fuseASARs) {
198+
d('fusing x64 and arm64 asars');
199+
const output = path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar');
200+
await fuseASARs({
201+
x64: path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'),
202+
arm64: path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar'),
203+
output,
204+
uniqueAllowList: opts.fuseASARsAllowList,
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)