Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@
"@tsconfig/node22": "^22.0.1",
"@types/cross-zip": "^4.0.1",
"@types/debug": "^4.1.10",
"@types/minimatch": "^5.1.2",
"@types/node": "~22.10.7",
"@types/plist": "^3.0.4",
"cross-zip": "^4.0.0",
Expand All @@ -54,10 +53,7 @@
},
"dependencies": {
"@electron/asar": "^4.0.0",
"@malept/cross-spawn-promise": "^2.0.0",
"debug": "^4.3.1",
"dir-compare": "^4.2.0",
"minimatch": "^9.0.3",
"plist": "^3.1.0"
},
"lint-staged": {
Expand Down
6 changes: 2 additions & 4 deletions src/asar-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,8 @@ import os from 'node:os';
import path from 'node:path';

import * as asar from '@electron/asar';
import { minimatch } from 'minimatch';

import { d } from './debug.js';
import { MACHO_MAGIC, MACHO_UNIVERSAL_MAGIC } from './file-utils.js';
import { MACHO_MAGIC, MACHO_UNIVERSAL_MAGIC, matchGlob } from './file-utils.js';

const LIPO = 'lipo';

Expand Down Expand Up @@ -57,7 +55,7 @@ function isDirectory(a: string, file: string): boolean {
}

function checkSingleArch(archive: string, file: string, allowList?: string): void {
if (allowList === undefined || !minimatch(file, allowList, { matchBase: true })) {
if (allowList === undefined || !matchGlob(file, allowList)) {
throw new Error(
`Detected unique file "${file}" in "${archive}" not covered by ` +
`allowList rule: "${allowList}"`,
Expand Down
71 changes: 66 additions & 5 deletions src/file-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import fs from 'node:fs';
import path from 'node:path';
import { promises as stream } from 'node:stream';

import { minimatch } from 'minimatch';

// See: https://github.com/apple-opensource-mirror/llvmCore/blob/0c60489d96c87140db9a6a14c6e82b15f5e5d252/include/llvm/Object/MachOFormat.h#L108-L112
export const MACHO_MAGIC = new Set([
// 32-bit Mach-O
Expand Down Expand Up @@ -35,6 +33,71 @@ export const isMachO = (header: Buffer): boolean => {
return false;
};

/**
* Glob match with matchBase semantics: if the pattern contains no `/`,
* only the basename of `filePath` is tested against the pattern.
*/
export const matchGlob = (filePath: string, pattern: string): boolean => {
return path.matchesGlob(pattern.includes('/') ? filePath : path.basename(filePath), pattern);
};

export type DiffEntry = {
state: 'equal' | 'distinct' | 'left' | 'right';
name1?: string;
relativePath: string;
};

export async function compareDirectories(dir1: string, dir2: string): Promise<DiffEntry[]> {
async function getFiles(dir: string, rel = ''): Promise<Map<string, string>> {
const entries = new Map<string, string>();
for (const item of await fs.promises.readdir(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, item.name);
const relPath = rel ? path.join(rel, item.name) : item.name;
// For symlinks, stat the target to determine if it's a file or directory
const isDir =
item.isDirectory() ||
(item.isSymbolicLink() && (await fs.promises.stat(fullPath)).isDirectory());
if (isDir) {
for (const [k, v] of await getFiles(fullPath, relPath)) {
entries.set(k, v);
}
} else if (item.isFile() || item.isSymbolicLink()) {
entries.set(relPath, fullPath);
}
}
return entries;
}

const files1 = await getFiles(dir1);
const files2 = await getFiles(dir2);
const results: DiffEntry[] = [];

for (const relFile of new Set([...files1.keys(), ...files2.keys()])) {
const name = path.basename(relFile);
const relDir = path.dirname(relFile);
const relativePath = relDir === '.' ? '' : relDir;

if (!files1.has(relFile)) {
results.push({ state: 'right', relativePath });
continue;
}
if (!files2.has(relFile)) {
results.push({ state: 'left', name1: name, relativePath });
continue;
}

const content1 = await fs.promises.readFile(files1.get(relFile)!);
const content2 = await fs.promises.readFile(files2.get(relFile)!);
results.push({
state: content1.equals(content2) ? 'equal' : 'distinct',
name1: name,
relativePath,
});
}

return results;
}

const UNPACKED_ASAR_PATH = path.join('Contents', 'Resources', 'app.asar.unpacked');

export enum AppFileType {
Expand Down Expand Up @@ -67,9 +130,7 @@ const isSingleArchFile = (relativePath: string, opts: GetAllAppFilesOpts): boole
return false;
}

return minimatch(unpackedPath, opts.singleArchFiles, {
matchBase: true,
});
return matchGlob(unpackedPath, opts.singleArchFiles);
};

/**
Expand Down
35 changes: 21 additions & 14 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
import { execFile as execFileCb } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';

import * as asar from '@electron/asar';
import { spawn } from '@malept/cross-spawn-promise';
import * as dircompare from 'dir-compare';
import { minimatch } from 'minimatch';
import plist from 'plist';

import { AsarMode, detectAsarMode, isUniversalMachO, mergeASARs } from './asar-utils.js';
import { AppFile, AppFileType, fsMove, getAllAppFiles, readMachOHeader } from './file-utils.js';
import {
AppFile,
AppFileType,
compareDirectories,
fsMove,
getAllAppFiles,
matchGlob,
readMachOHeader,
} from './file-utils.js';
import { sha } from './sha.js';
import { d } from './debug.js';
import { computeIntegrityData } from './integrity.js';

const execFile = promisify(execFileCb);

/**
* Options to pass into the {@link makeUniversalApp} function.
*
Expand Down Expand Up @@ -125,11 +134,11 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
// On APFS (standard on modern macOS), -c does a copy-on-write clone
// that's near-instant even for multi-hundred-MB apps.
d('copying x64 app as starter template via APFS clone (cp -cR)');
await spawn('cp', ['-cR', opts.x64AppPath, tmpApp]);
await execFile('cp', ['-cR', opts.x64AppPath, tmpApp]);
} catch {
// -c fails on non-APFS volumes; fall back to a regular copy.
d('APFS clone unsupported, falling back to regular cp -R');
await spawn('cp', ['-R', opts.x64AppPath, tmpApp]);
await execFile('cp', ['-R', opts.x64AppPath, tmpApp]);
}

const uniqueToX64: string[] = [];
Expand Down Expand Up @@ -194,7 +203,7 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
if (x64Sha === arm64Sha) {
if (
opts.x64ArchFiles === undefined ||
!minimatch(machOFile.relativePath, opts.x64ArchFiles, { matchBase: true })
!matchGlob(machOFile.relativePath, opts.x64ArchFiles)
) {
throw new Error(
`Detected file "${machOFile.relativePath}" that's the same in both x64 and arm64 builds and not covered by the ` +
Expand All @@ -214,7 +223,7 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
first,
second,
});
await spawn('lipo', [
await execFile('lipo', [
first,
second,
'-create',
Expand All @@ -232,12 +241,11 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
*/
if (x64AsarMode === AsarMode.NO_ASAR) {
d('checking if the x64 and arm64 app folders are identical');
const comparison = await dircompare.compare(
const diffSet = await compareDirectories(
path.resolve(tmpApp, 'Contents', 'Resources', 'app'),
path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app'),
{ compareSize: true, compareContent: true },
);
const differences = comparison.diffSet!.filter((difference) => difference.state !== 'equal');
const differences = diffSet.filter((difference) => difference.state !== 'equal');
d(`Found ${differences.length} difference(s) between the x64 and arm64 folders`);
const nonMergedDifferences = differences.filter(
(difference) =>
Expand Down Expand Up @@ -390,8 +398,7 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
}

const injectAsarIntegrity =
!opts.infoPlistsToIgnore ||
minimatch(plistFile.relativePath, opts.infoPlistsToIgnore, { matchBase: true });
!opts.infoPlistsToIgnore || matchGlob(plistFile.relativePath, opts.infoPlistsToIgnore);
const mergedPlist = injectAsarIntegrity
? { ...x64Plist, ElectronAsarIntegrity: generatedIntegrity }
: { ...x64Plist };
Expand All @@ -412,7 +419,7 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =

d('moving final universal app to target destination');
await fs.promises.mkdir(path.dirname(opts.outAppPath), { recursive: true });
await spawn('mv', [tmpApp, opts.outAppPath]);
await fsMove(tmpApp, opts.outAppPath);
} catch (err) {
throw err;
} finally {
Expand Down
147 changes: 147 additions & 0 deletions test/compare-directories.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

import { afterEach, describe, expect, it } from 'vitest';

import { compareDirectories, DiffEntry } from '../src/file-utils.js';

const sortDiff = (entries: DiffEntry[]) =>
[...entries].sort((a, b) => {
const pathA = a.relativePath + (a.name1 ?? '');
const pathB = b.relativePath + (b.name1 ?? '');
return pathA.localeCompare(pathB);
});

describe('compareDirectories', () => {
let tmpDir: string;
let dir1: string;
let dir2: string;

const setup = async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'compare-test-'));
dir1 = path.join(tmpDir, 'dir1');
dir2 = path.join(tmpDir, 'dir2');
await fs.promises.mkdir(dir1, { recursive: true });
await fs.promises.mkdir(dir2, { recursive: true });
};

afterEach(async () => {
if (tmpDir) {
await fs.promises.rm(tmpDir, { recursive: true, force: true });
}
});

it('should report identical directories as all equal', async () => {
await setup();
await fs.promises.writeFile(path.join(dir1, 'a.txt'), 'hello');
await fs.promises.writeFile(path.join(dir2, 'a.txt'), 'hello');

const results = await compareDirectories(dir1, dir2);
expect(results).toEqual([{ state: 'equal', name1: 'a.txt', relativePath: '' }]);
});

it('should detect files with different content as distinct', async () => {
await setup();
await fs.promises.writeFile(path.join(dir1, 'a.txt'), 'hello');
await fs.promises.writeFile(path.join(dir2, 'a.txt'), 'world');

const results = await compareDirectories(dir1, dir2);
expect(results).toEqual([{ state: 'distinct', name1: 'a.txt', relativePath: '' }]);
});

it('should detect left-only files', async () => {
await setup();
await fs.promises.writeFile(path.join(dir1, 'only-left.txt'), 'data');

const results = await compareDirectories(dir1, dir2);
expect(results).toEqual([{ state: 'left', name1: 'only-left.txt', relativePath: '' }]);
});

it('should detect right-only files (name1 is undefined)', async () => {
await setup();
await fs.promises.writeFile(path.join(dir2, 'only-right.txt'), 'data');

const results = await compareDirectories(dir1, dir2);
expect(results).toEqual([{ state: 'right', relativePath: '' }]);
});

it('should handle nested directories', async () => {
await setup();
await fs.promises.mkdir(path.join(dir1, 'sub'), { recursive: true });
await fs.promises.mkdir(path.join(dir2, 'sub'), { recursive: true });
await fs.promises.writeFile(path.join(dir1, 'sub', 'nested.txt'), 'same');
await fs.promises.writeFile(path.join(dir2, 'sub', 'nested.txt'), 'same');

const results = await compareDirectories(dir1, dir2);
expect(results).toEqual([{ state: 'equal', name1: 'nested.txt', relativePath: 'sub' }]);
});

it('should handle deeply nested files with correct relativePath', async () => {
await setup();
await fs.promises.mkdir(path.join(dir1, 'a', 'b'), { recursive: true });
await fs.promises.mkdir(path.join(dir2, 'a', 'b'), { recursive: true });
await fs.promises.writeFile(path.join(dir1, 'a', 'b', 'deep.txt'), 'x');
await fs.promises.writeFile(path.join(dir2, 'a', 'b', 'deep.txt'), 'y');

const results = await compareDirectories(dir1, dir2);
expect(results).toEqual([
{ state: 'distinct', name1: 'deep.txt', relativePath: path.join('a', 'b') },
]);
});

it('should handle empty directories', async () => {
await setup();
const results = await compareDirectories(dir1, dir2);
expect(results).toEqual([]);
});

it('should handle mixed states across multiple files', async () => {
await setup();
await fs.promises.writeFile(path.join(dir1, 'same.txt'), 'same');
await fs.promises.writeFile(path.join(dir2, 'same.txt'), 'same');
await fs.promises.writeFile(path.join(dir1, 'diff.txt'), 'v1');
await fs.promises.writeFile(path.join(dir2, 'diff.txt'), 'v2');
await fs.promises.writeFile(path.join(dir1, 'left-only.txt'), 'left');
await fs.promises.writeFile(path.join(dir2, 'right-only.txt'), 'right');

const results = sortDiff(await compareDirectories(dir1, dir2));
expect(results).toEqual(
sortDiff([
{ state: 'equal', name1: 'same.txt', relativePath: '' },
{ state: 'distinct', name1: 'diff.txt', relativePath: '' },
{ state: 'left', name1: 'left-only.txt', relativePath: '' },
{ state: 'right', relativePath: '' },
]),
);
});

it('should follow symlinks and compare target content', async () => {
await setup();
await fs.promises.writeFile(path.join(dir1, 'real.txt'), 'content');
await fs.promises.symlink(path.join(dir1, 'real.txt'), path.join(dir1, 'link.txt'));
await fs.promises.writeFile(path.join(dir2, 'link.txt'), 'content');

const results = sortDiff(await compareDirectories(dir1, dir2));
const linkEntry = results.find((r) => r.name1 === 'link.txt');
expect(linkEntry?.state).toBe('equal');
});

it('should traverse symlinked directories', async () => {
await setup();
// dir1: realdir/file.txt + linkdir -> realdir
await fs.promises.mkdir(path.join(dir1, 'realdir'));
await fs.promises.writeFile(path.join(dir1, 'realdir', 'file.txt'), 'data');
await fs.promises.symlink(path.join(dir1, 'realdir'), path.join(dir1, 'linkdir'));
// dir2: same structure
await fs.promises.mkdir(path.join(dir2, 'realdir'));
await fs.promises.writeFile(path.join(dir2, 'realdir', 'file.txt'), 'data');
await fs.promises.symlink(path.join(dir2, 'realdir'), path.join(dir2, 'linkdir'));

const results = await compareDirectories(dir1, dir2);
const linkdirEntry = results.find((r) => r.relativePath === 'linkdir');
expect(linkdirEntry).toBeDefined();
expect(linkdirEntry!.state).toBe('equal');
expect(linkdirEntry!.name1).toBe('file.txt');
});
});
Loading