From bb0d4895c89b2abcfc2951691a78f8308319f9a6 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Sun, 31 May 2026 23:47:33 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20fix:=20bundle=20macOS=20sharp=20?= =?UTF-8?q?runtimes=20for=20both=20architectures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Install Darwin sharp optional dependencies for both x64 and arm64 before macOS packaging, and strengthen the packaged attach_file smoke test to assert the architecture-specific sharp/libvips assets are present. --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$0.00`_ --- .github/workflows/pr.yml | 2 + Makefile | 15 +- scripts/checkMacAttachFileRuntime.ts | 200 ++++++++++++++++++++++----- 3 files changed, 181 insertions(+), 36 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 51fef25850..c56ae37bf8 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -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: diff --git a/Makefile b/Makefile index 0035e9e7d3..3435712971 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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; \ @@ -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 diff --git a/scripts/checkMacAttachFileRuntime.ts b/scripts/checkMacAttachFileRuntime.ts index ef5170166d..12191a8f88 100644 --- a/scripts/checkMacAttachFileRuntime.ts +++ b/scripts/checkMacAttachFileRuntime.ts @@ -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 = { + 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 = { + 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); @@ -49,27 +83,17 @@ async function findAppBundles(rootDir: string): Promise { return results; } -async function chooseDefaultAppBundle(): Promise { - 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) { @@ -80,6 +104,43 @@ async function chooseDefaultAppBundle(): Promise { 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(); + 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 { async function walk(dirPath: string): Promise { const entries = await listDirectoryEntries(dirPath); @@ -102,26 +163,60 @@ async function findFileMatching(rootDir: string, pattern: RegExp): Promise { +async function assertDirectoryExists(dirPath: string, message: string): Promise { + const stat = await fs.stat(dirPath).catch(() => null); + assert(stat?.isDirectory(), message); +} + +async function verifyUnpackedSharpAssets( + appBundlePath: string, + architectures: MacAppArchitecture[] +): Promise { 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( @@ -195,21 +290,56 @@ function runPackagedSmokeApp( ); } +async function getDefaultAppBundles(): Promise { + 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 { + 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 { 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(); + 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 }); }