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
2 changes: 2 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,8 @@ jobs:
- name: Build macOS distributables
# Retry for transient Apple timestamp-server failures during code signing.
run: ./scripts/retry.sh 3 30 make dist-mac
- name: Smoke test packaged macOS attach_file runtime
run: make check-mac-attach-file-runtime
- name: Upload x64 artifact
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
Expand Down
15 changes: 14 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ include fmt.mk
.PHONY: build-renderer version build-icons build-static build-docker-runtime verify-docker-runtime-artifacts
.PHONY: lint lint-fix typecheck typecheck-react-native mobile-web mobile-cors-proxy mobile-sandbox static-check static-check-full
.PHONY: test test-unit test-integration test-watch test-coverage test-e2e test-e2e-perf smoke-test
.PHONY: dist dist-mac dist-win dist-linux install-mac-arm64 check-appimage-icons check-mac-attach-file-runtime
.PHONY: dist dist-mac dist-win dist-linux install-mac-arm64 ensure-mac-sharp-runtime-deps check-appimage-icons check-mac-attach-file-runtime
.PHONY: vscode-ext vscode-ext-install
.PHONY: docs-server check-docs-links
.PHONY: storybook storybook-run storybook-build test-storybook chromatic
Expand Down Expand Up @@ -481,7 +481,17 @@ test-e2e-perf: ## Run automated performance profiling scenarios
dist: build ## Build distributable packages
@bun x electron-builder --publish never

ensure-mac-sharp-runtime-deps: node_modules/.installed ## Install macOS x64+arm64 sharp runtime deps for packaging
@echo "Installing macOS sharp runtime optional dependencies..."
@# Issue #3338: Bun installs optional packages for the current host by default,
@# but electron-builder packages both Intel and Apple Silicon apps from one
@# node_modules tree. Install each documented Darwin CPU runtime before packaging
@# so the x64 app does not accidentally ship with only arm64 native assets.
@bun install --frozen-lockfile --os=darwin --cpu=x64
@bun install --frozen-lockfile --os=darwin --cpu=arm64

dist-mac: build ## Build macOS distributables (x64 + arm64)
@$(MAKE) --no-print-directory ensure-mac-sharp-runtime-deps
@if [ -n "$$CSC_LINK" ]; then \
echo "🔐 Code signing enabled - using unified build for correct yml..."; \
bun x electron-builder --mac --x64 --arm64 --publish never; \
Expand All @@ -494,15 +504,18 @@ dist-mac: build ## Build macOS distributables (x64 + arm64)
@echo "✅ Both architectures built successfully"

dist-mac-release: build ## Build and publish macOS distributables (x64 + arm64)
@$(MAKE) --no-print-directory ensure-mac-sharp-runtime-deps
@echo "🔐 Building macOS x64 + arm64 (unified for correct yml)..."
@bun x electron-builder --mac --x64 --arm64 --publish always
@echo "✅ Both architectures built and published successfully"

dist-mac-x64: build ## Build macOS x64 distributable only
@$(MAKE) --no-print-directory ensure-mac-sharp-runtime-deps
@echo "Building macOS x64..."
@bun x electron-builder --mac --x64 --publish never

dist-mac-arm64: build ## Build macOS arm64 distributable only
@$(MAKE) --no-print-directory ensure-mac-sharp-runtime-deps
@echo "Building macOS arm64..."
@bun x electron-builder --mac --arm64 --publish never

Expand Down
200 changes: 165 additions & 35 deletions scripts/checkMacAttachFileRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,40 @@ const APP_ASAR_UNPACKED_NODE_MODULES = [
["node_modules", "@img"],
] as const;

type MacAppArchitecture = "x64" | "arm64";

interface SharpRuntimePackages {
binding: string;
libvips: string;
}

const MAC_APP_ARCHITECTURES: MacAppArchitecture[] = ["x64", "arm64"];
const SHARP_RUNTIME_PACKAGES_BY_ARCHITECTURE: Record<MacAppArchitecture, SharpRuntimePackages> = {
x64: {
binding: "sharp-darwin-x64",
libvips: "sharp-libvips-darwin-x64",
},
arm64: {
binding: "sharp-darwin-arm64",
libvips: "sharp-libvips-darwin-arm64",
},
};

const APP_BUNDLE_PREFERRED_SUFFIXES_BY_HOST_ARCHITECTURE: Record<MacAppArchitecture, string[]> = {
x64: [
path.join("release", "mac-x64", APP_NAME),
path.join("release", "mac", APP_NAME),
path.join("release", "mac-universal", APP_NAME),
path.join("release", "mac-arm64", APP_NAME),
],
arm64: [
path.join("release", "mac-arm64", APP_NAME),
path.join("release", "mac-universal", APP_NAME),
path.join("release", "mac", APP_NAME),
path.join("release", "mac-x64", APP_NAME),
],
};

function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
Expand Down Expand Up @@ -49,27 +83,17 @@ async function findAppBundles(rootDir: string): Promise<string[]> {
return results;
}

async function chooseDefaultAppBundle(): Promise<string> {
const appBundles = await findAppBundles(RELEASE_DIR);
function getHostMacAppArchitecture(): MacAppArchitecture {
assert(
appBundles.length > 0,
`No ${APP_NAME} found under ${RELEASE_DIR}. Run make dist-mac first.`
process.arch === "x64" || process.arch === "arm64",
`Unsupported macOS architecture for attach-file smoke test: ${process.arch}`
);
return process.arch;
}

function chooseDefaultAppBundle(appBundles: string[]): string {
const preferredSuffixes =
process.arch === "arm64"
? [
path.join("release", "mac-arm64", APP_NAME),
path.join("release", "mac", APP_NAME),
path.join("release", "mac-universal", APP_NAME),
path.join("release", "mac-x64", APP_NAME),
]
: [
path.join("release", "mac-x64", APP_NAME),
path.join("release", "mac", APP_NAME),
path.join("release", "mac-universal", APP_NAME),
path.join("release", "mac-arm64", APP_NAME),
];
APP_BUNDLE_PREFERRED_SUFFIXES_BY_HOST_ARCHITECTURE[getHostMacAppArchitecture()];
for (const suffix of preferredSuffixes) {
const match = appBundles.find((appBundle) => appBundle.endsWith(suffix));
if (match != null) {
Expand All @@ -80,6 +104,43 @@ async function chooseDefaultAppBundle(): Promise<string> {
return appBundles.sort()[0]!;
}

function toMacAppArchitecture(arch: string): MacAppArchitecture | null {
if (arch === "x64" || arch === "x86_64") {
return "x64";
}
if (arch === "arm64") {
return "arm64";
}
return null;
}

function getPackagedAppArchitectures(appBundlePath: string): MacAppArchitecture[] {
const executablePath = path.join(appBundlePath, "Contents", "MacOS", "mux");
const result = spawnSync("lipo", ["-archs", executablePath], {
encoding: "utf8",
timeout: 10_000,
});
assert(result.error == null, `Failed to inspect macOS app architecture: ${result.error}`);
assert(
result.status === 0,
`Failed to inspect macOS app architecture for ${executablePath}: ${result.stderr}`
);

const architectures = new Set<MacAppArchitecture>();
for (const rawArch of result.stdout.trim().split(/\s+/)) {
const architecture = toMacAppArchitecture(rawArch);
if (architecture != null) {
architectures.add(architecture);
}
}

assert(
architectures.size > 0,
`No supported macOS architecture found for ${executablePath}: ${result.stdout}`
);
return MAC_APP_ARCHITECTURES.filter((architecture) => architectures.has(architecture));
}

async function findFileMatching(rootDir: string, pattern: RegExp): Promise<string | null> {
async function walk(dirPath: string): Promise<string | null> {
const entries = await listDirectoryEntries(dirPath);
Expand All @@ -102,26 +163,60 @@ async function findFileMatching(rootDir: string, pattern: RegExp): Promise<strin
return await walk(rootDir);
}

async function verifyUnpackedSharpAssets(appBundlePath: string): Promise<void> {
async function assertDirectoryExists(dirPath: string, message: string): Promise<void> {
const stat = await fs.stat(dirPath).catch(() => null);
assert(stat?.isDirectory(), message);
}

async function verifyUnpackedSharpAssets(
appBundlePath: string,
architectures: MacAppArchitecture[]
): Promise<void> {
const unpackedRoot = path.join(appBundlePath, "Contents", "Resources", "app.asar.unpacked");
for (const segments of APP_ASAR_UNPACKED_NODE_MODULES) {
const requiredPath = path.join(unpackedRoot, ...segments);
const stat = await fs.stat(requiredPath).catch(() => null);
assert(stat?.isDirectory(), `Missing unpacked runtime directory: ${requiredPath}`);
await assertDirectoryExists(
requiredPath,
`Missing unpacked runtime directory: ${requiredPath}`
);
}

const unpackedNodeModules = path.join(unpackedRoot, "node_modules");
const sharpBinaryPath = await findFileMatching(unpackedNodeModules, /sharp.*\.node$/);
assert(
sharpBinaryPath != null,
`Missing unpacked sharp native binary under ${unpackedNodeModules}`
);
for (const architecture of architectures) {
const packages = SHARP_RUNTIME_PACKAGES_BY_ARCHITECTURE[architecture];
const sharpPackagePath = path.join(unpackedNodeModules, "@img", packages.binding);
const libvipsPackagePath = path.join(unpackedNodeModules, "@img", packages.libvips);

const libvipsPath = await findFileMatching(unpackedNodeModules, /libvips-cpp\..*\.dylib$/);
assert(libvipsPath != null, `Missing unpacked libvips dylib under ${unpackedNodeModules}`);
// Issue #3338: a generic "any sharp binary exists" check allowed the x64 app
// to ship with only arm64 sharp assets. Assert the exact runtime packages for
// each packaged architecture so Intel Macs cannot fail at startup again.
await assertDirectoryExists(
sharpPackagePath,
`Missing ${architecture} sharp runtime package: ${sharpPackagePath}`
);
await assertDirectoryExists(
libvipsPackagePath,
`Missing ${architecture} libvips runtime package: ${libvipsPackagePath}`
);

console.log(`[attach-file-smoke] unpacked sharp binary: ${sharpBinaryPath}`);
console.log(`[attach-file-smoke] unpacked libvips dylib: ${libvipsPath}`);
const sharpBinaryPath = await findFileMatching(
sharpPackagePath,
new RegExp(`${packages.binding}\\.node$`)
);
assert(
sharpBinaryPath != null,
`Missing ${architecture} sharp native binary under ${sharpPackagePath}`
);

const libvipsPath = await findFileMatching(libvipsPackagePath, /libvips-cpp\..*\.dylib$/);
assert(
libvipsPath != null,
`Missing ${architecture} libvips dylib under ${libvipsPackagePath}`
);

console.log(`[attach-file-smoke] ${architecture} unpacked sharp binary: ${sharpBinaryPath}`);
console.log(`[attach-file-smoke] ${architecture} unpacked libvips dylib: ${libvipsPath}`);
}
}

async function createFixtureImages(
Expand Down Expand Up @@ -195,21 +290,56 @@ function runPackagedSmokeApp(
);
}

async function getDefaultAppBundles(): Promise<string[]> {
const appBundles = await findAppBundles(RELEASE_DIR);
assert(
appBundles.length > 0,
`No ${APP_NAME} found under ${RELEASE_DIR}. Run make dist-mac first.`
);
return appBundles;
}

async function verifyPackagedApp(appBundlePath: string): Promise<MacAppArchitecture[]> {
const appStat = await fs.stat(appBundlePath).catch(() => null);
assert(appStat?.isDirectory(), `macOS app bundle not found: ${appBundlePath}`);

const architectures = getPackagedAppArchitectures(appBundlePath);
console.log(
`[attach-file-smoke] verifying app bundle ${appBundlePath} (${architectures.join(", ")})`
);
await verifyUnpackedSharpAssets(appBundlePath, architectures);
return architectures;
}

async function main(): Promise<void> {
assert(process.platform === "darwin", "checkMacAttachFileRuntime.ts only runs on macOS");

const requestedAppBundle = process.argv[2];
const appBundlePath = requestedAppBundle ?? (await chooseDefaultAppBundle());
const appStat = await fs.stat(appBundlePath).catch(() => null);
assert(appStat?.isDirectory(), `macOS app bundle not found: ${appBundlePath}`);
const appBundles =
requestedAppBundle != null ? [requestedAppBundle] : await getDefaultAppBundles();
const verifiedArchitectures = new Set<MacAppArchitecture>();
for (const appBundlePath of appBundles) {
for (const architecture of await verifyPackagedApp(appBundlePath)) {
verifiedArchitectures.add(architecture);
}
}

if (requestedAppBundle == null) {
for (const architecture of MAC_APP_ARCHITECTURES) {
assert(
verifiedArchitectures.has(architecture),
`No packaged ${architecture} ${APP_NAME} found under ${RELEASE_DIR}. Run make dist-mac first.`
);
}
}

console.log(`[attach-file-smoke] using app bundle ${appBundlePath}`);
await verifyUnpackedSharpAssets(appBundlePath);
const smokeAppBundlePath = requestedAppBundle ?? chooseDefaultAppBundle(appBundles);
console.log(`[attach-file-smoke] running app smoke test with ${smokeAppBundlePath}`);

const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-attach-file-smoke-"));
try {
const fixturePaths = await createFixtureImages(tempDir);
runPackagedSmokeApp(appBundlePath, fixturePaths);
runPackagedSmokeApp(smokeAppBundlePath, fixturePaths);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
Expand Down
Loading