From f848dee343eaa4b28a2f614b8400234c5982b879 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Thu, 27 Aug 2026 06:38:33 +0800 Subject: [PATCH] fix: package sharp for both macOS architectures (#1344) --- .github/workflows/electron.yml | 17 ++++ scripts/install-macos-sharp.cjs | 27 ++++++ scripts/verify-macos-sharp.cjs | 150 +++++++++++++++++++++++++++++ scripts/verify-macos-sharp.test.ts | 62 ++++++++++++ 4 files changed, 256 insertions(+) create mode 100644 scripts/install-macos-sharp.cjs create mode 100644 scripts/verify-macos-sharp.cjs create mode 100644 scripts/verify-macos-sharp.test.ts diff --git a/.github/workflows/electron.yml b/.github/workflows/electron.yml index 27b1a4fb..a2c125e1 100644 --- a/.github/workflows/electron.yml +++ b/.github/workflows/electron.yml @@ -427,6 +427,7 @@ jobs: npm ci npm install --force @rollup/rollup-darwin-arm64 npm install dmg-license + node scripts/install-macos-sharp.cjs - name: Check for Code Signing Certificates id: check_certs @@ -538,6 +539,14 @@ jobs: fi npx electron-builder --mac dmg --universal --x64 --arm64 --publish never + - name: Verify macOS sharp packaging + run: | + artifacts=(release/termix_macos_*_dmg.dmg) + if [ -f release/termix_macos_universal_mas.pkg ]; then + artifacts+=(release/termix_macos_universal_mas.pkg) + fi + node scripts/verify-macos-sharp.cjs "${artifacts[@]}" + - name: Upload macOS MAS PKG if: steps.check_certs.outputs.has_certs == 'true' && hashFiles('release/termix_macos_universal_mas.pkg') != '' && (inputs.artifact_destination == 'file' || inputs.artifact_destination == 'release' || inputs.artifact_destination == 'submit') uses: actions/upload-artifact@v7 @@ -923,6 +932,9 @@ jobs: echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT echo "checksum=$CHECKSUM" >> $GITHUB_OUTPUT + - name: Verify macOS sharp packaging + run: node scripts/verify-macos-sharp.cjs release_asset/termix_macos_universal_dmg.dmg + - name: Prepare Homebrew submission files run: | VERSION="${{ steps.package-version.outputs.version }}" @@ -1013,6 +1025,7 @@ jobs: npm ci npm install --force @rollup/rollup-darwin-arm64 npm install dmg-license + node scripts/install-macos-sharp.cjs - name: Check for Code Signing Certificates id: check_certs @@ -1154,6 +1167,10 @@ jobs: BUILD_VERSION="${{ steps.build_number.outputs.build_version || github.run_number }}" npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION" + - name: Verify macOS sharp packaging + if: steps.check_certs.outputs.has_certs == 'true' + run: node scripts/verify-macos-sharp.cjs release/termix_macos_universal_mas.pkg + - name: Generate App Store release notes id: asc_notes if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true' diff --git a/scripts/install-macos-sharp.cjs b/scripts/install-macos-sharp.cjs new file mode 100644 index 00000000..9dc34566 --- /dev/null +++ b/scripts/install-macos-sharp.cjs @@ -0,0 +1,27 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { execFileSync } = require("node:child_process"); + +function findPackage(start) { + let directory = path.dirname(start); + while (directory !== path.dirname(directory)) { + const manifest = path.join(directory, "package.json"); + if (fs.existsSync(manifest)) return JSON.parse(fs.readFileSync(manifest)); + directory = path.dirname(directory); + } + throw new Error("Could not locate the installed sharp package manifest"); +} + +const sharpPackage = findPackage(require.resolve("sharp")); +const packages = [ + "@img/sharp-darwin-arm64", + "@img/sharp-darwin-x64", + "@img/sharp-libvips-darwin-arm64", + "@img/sharp-libvips-darwin-x64", +].map((name) => `${name}@${sharpPackage.optionalDependencies[name]}`); + +execFileSync( + process.platform === "win32" ? "npm.cmd" : "npm", + ["install", "--force", "--no-save", ...packages], + { stdio: "inherit" }, +); diff --git a/scripts/verify-macos-sharp.cjs b/scripts/verify-macos-sharp.cjs new file mode 100644 index 00000000..776a9bf7 --- /dev/null +++ b/scripts/verify-macos-sharp.cjs @@ -0,0 +1,150 @@ +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { execFileSync, spawnSync } = require("node:child_process"); + +const architectures = { + x64: ["x64"], + arm64: ["arm64"], + universal: ["x64", "arm64"], +}; + +function expectedArchitecture(artifact) { + const name = path.basename(artifact); + if (name.includes("_x64_")) return "x64"; + if (name.includes("_arm64_")) return "arm64"; + if (name.includes("_universal_")) return "universal"; + throw new Error(`Cannot determine architecture from artifact name: ${name}`); +} + +function findApp(root) { + const pending = [root]; + while (pending.length) { + const current = pending.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const candidate = path.join(current, entry.name); + if (entry.isDirectory() && entry.name.endsWith(".app")) return candidate; + if (entry.isDirectory()) pending.push(candidate); + } + } + throw new Error(`No .app bundle found below ${root}`); +} + +function containsFile(root, suffix) { + const pending = [root]; + while (pending.length) { + const current = pending.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const candidate = path.join(current, entry.name); + if (entry.isDirectory()) pending.push(candidate); + if (entry.isFile() && entry.name.endsWith(suffix)) return true; + } + } + return false; +} + +function verifyApp(app, architecture, runtimeCheck) { + const modules = path.join( + app, + "Contents/Resources/app.asar.unpacked/node_modules", + ); + + for (const arch of architectures[architecture]) { + for (const [packageName, nativeSuffix] of [ + [`sharp-darwin-${arch}`, ".node"], + [`sharp-libvips-darwin-${arch}`, ".dylib"], + ]) { + const packagePath = path.join(modules, "@img", packageName); + if ( + !fs.existsSync(packagePath) || + !containsFile(packagePath, nativeSuffix) + ) { + throw new Error( + `${path.basename(app)} is missing the native binary from @img/${packageName}`, + ); + } + } + } + + if (!runtimeCheck) return; + + const executable = path.join(app, "Contents/MacOS/Termix"); + const sharpPath = path.join(modules, "sharp"); + const smoke = [ + "const sharp = require(process.argv[1]);", + "sharp({create:{width:1,height:1,channels:4,background:'#000'}})", + ".png().toBuffer().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1); });", + ].join(""); + + for (const arch of architectures[architecture]) { + const result = spawnSync( + "arch", + [ + arch === "x64" ? "-x86_64" : "-arm64", + executable, + "-e", + smoke, + sharpPath, + ], + { + encoding: "utf8", + env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, + }, + ); + if (result.status !== 0) { + throw new Error( + `${path.basename(app)} failed the ${arch} sharp runtime smoke test:\n${result.stderr || result.stdout}`, + ); + } + } +} + +function verifyArtifact(artifact) { + const architecture = expectedArchitecture(artifact); + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "termix-sharp-")); + let mountedAt; + + try { + if (artifact.endsWith(".dmg")) { + mountedAt = path.join(temporaryRoot, "mounted"); + fs.mkdirSync(mountedAt); + execFileSync("hdiutil", [ + "attach", + artifact, + "-readonly", + "-nobrowse", + "-mountpoint", + mountedAt, + ]); + verifyApp(findApp(mountedAt), architecture, true); + } else if (artifact.endsWith(".pkg")) { + const expanded = path.join(temporaryRoot, "expanded"); + execFileSync("pkgutil", ["--expand-full", artifact, expanded]); + verifyApp(findApp(expanded), architecture, false); + } else { + throw new Error(`Unsupported macOS artifact: ${artifact}`); + } + console.log(`Verified macOS sharp packaging: ${path.basename(artifact)}`); + } finally { + if (mountedAt) { + spawnSync("hdiutil", ["detach", mountedAt], { stdio: "ignore" }); + } + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +module.exports = { expectedArchitecture, verifyApp }; + +if (require.main === module) { + if (process.platform !== "darwin") { + throw new Error("macOS sharp artifact verification must run on macOS"); + } + if (process.argv.length < 3) { + throw new Error( + "Usage: node scripts/verify-macos-sharp.cjs [...]", + ); + } + + for (const artifact of process.argv.slice(2)) + verifyArtifact(path.resolve(artifact)); +} diff --git a/scripts/verify-macos-sharp.test.ts b/scripts/verify-macos-sharp.test.ts new file mode 100644 index 00000000..8da946ac --- /dev/null +++ b/scripts/verify-macos-sharp.test.ts @@ -0,0 +1,62 @@ +import fs from "node:fs"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const { expectedArchitecture, verifyApp } = require("./verify-macos-sharp.cjs"); + +const temporaryDirectories: string[] = []; + +function createApp(architectures: string[]) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-sharp-test-")); + temporaryDirectories.push(root); + const app = path.join(root, "Termix.app"); + const modules = path.join( + app, + "Contents/Resources/app.asar.unpacked/node_modules/@img", + ); + + for (const architecture of architectures) { + const sharp = path.join(modules, `sharp-darwin-${architecture}/lib`); + const libvips = path.join( + modules, + `sharp-libvips-darwin-${architecture}/lib`, + ); + fs.mkdirSync(sharp, { recursive: true }); + fs.mkdirSync(libvips, { recursive: true }); + fs.writeFileSync(path.join(sharp, `sharp-darwin-${architecture}.node`), ""); + fs.writeFileSync(path.join(libvips, "libvips.dylib"), ""); + } + + return app; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("macOS sharp artifact verification", () => { + it("derives the expected architecture from artifact names", () => { + expect(expectedArchitecture("termix_macos_x64_dmg.dmg")).toBe("x64"); + expect(expectedArchitecture("termix_macos_arm64_dmg.dmg")).toBe("arm64"); + expect(expectedArchitecture("termix_macos_universal_mas.pkg")).toBe( + "universal", + ); + }); + + it("accepts a universal app with both sharp architectures", () => { + expect(() => + verifyApp(createApp(["x64", "arm64"]), "universal", false), + ).not.toThrow(); + }); + + it("rejects an x64 app containing only arm64 sharp binaries", () => { + expect(() => verifyApp(createApp(["arm64"]), "x64", false)).toThrow( + /sharp-darwin-x64/, + ); + }); +});