#!/usr/bin/env node /** * Mac 산출물 정리 * - zip(포터블): .app + 실행하기.command * - dmg(설치형): 동일 서명 .app + 「응용 프로그램에 설치하고 실행.command」 * (Gatekeeper 「손상됨」팝업 방지 — 설치 시 xattr -cr 수행) */ import { execFileSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { DOWNLOAD_ARTIFACTS } from "./download-artifacts.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const distDir = path.join(root, "dist"); const appName = "EX AI Chatbot.app"; const packagingMac = path.join(root, "packaging", "mac"); const installCmdName = "응용 프로그램에 설치하고 실행.command"; const runCmdName = "실행하기.command"; const guideName = "설치안내.txt"; const TARGETS = [ { appDirs: [path.join(distDir, "mac-x64", appName), path.join(distDir, "mac", appName)], zipName: DOWNLOAD_ARTIFACTS.macIntelPortable, dmgName: DOWNLOAD_ARTIFACTS.macIntelInstaller, label: "intel (x64)", }, { appDirs: [path.join(distDir, "mac-arm64", appName)], zipName: DOWNLOAD_ARTIFACTS.macSiliconPortable, dmgName: DOWNLOAD_ARTIFACTS.macSiliconInstaller, label: "silicon (arm64)", }, ]; function codesignApp(appPath, label) { try { execFileSync( "codesign", [ "--force", "--deep", "--sign", "-", "--entitlements", path.join(packagingMac, "entitlements.plist"), appPath, ], { stdio: "inherit" }, ); } catch (e) { console.warn(`[finalize-mac] entitlements codesign 실패 (${label}), ad-hoc 재시도:`, e.message); try { execFileSync("codesign", ["--force", "--deep", "--sign", "-", appPath], { stdio: "inherit", }); } catch (e2) { console.warn(`[finalize-mac] codesign 실패 (${label}):`, e2.message); } } try { execFileSync("xattr", ["-cr", appPath], { stdio: "ignore" }); } catch { /* ignore */ } } function ensurePackagingFiles() { for (const name of [runCmdName, installCmdName, guideName]) { const p = path.join(packagingMac, name); if (!fs.existsSync(p)) throw new Error(`packaging/mac 파일 없음: ${name}`); } } function packZip(appPath, zipName, label) { console.log(`[finalize-mac] zip ${label}: ${appPath}`); const stage = fs.mkdtempSync(path.join(os.tmpdir(), "ex-ai-mac-zip-")); try { fs.cpSync(appPath, path.join(stage, appName), { recursive: true }); fs.copyFileSync(path.join(packagingMac, runCmdName), path.join(stage, runCmdName)); fs.copyFileSync(path.join(packagingMac, guideName), path.join(stage, guideName)); fs.chmodSync(path.join(stage, runCmdName), 0o755); try { execFileSync("xattr", ["-cr", stage], { stdio: "ignore" }); } catch { /* ignore */ } const zipPath = path.join(distDir, zipName); if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath); execFileSync("zip", ["-r", "-y", zipPath, appName, runCmdName, guideName], { cwd: stage, stdio: "inherit", }); const size = fs.statSync(zipPath).size; console.log(`[finalize-mac] ✅ zip ${zipName} (${Math.round(size / 1024 / 1024)} MB)`); return true; } finally { fs.rmSync(stage, { recursive: true, force: true }); } } function detachStaleVolumes() { for (const vol of ["EX AI Chatbot", "EX AI Chatbot Intel", "EX AI Chatbot Silicon"]) { try { execFileSync("hdiutil", ["detach", `/Volumes/${vol}`, "-force"], { stdio: "ignore" }); } catch { /* not mounted */ } } } function packDmg(appPath, dmgName, label) { console.log(`[finalize-mac] dmg ${label}: ${appPath}`); const stage = fs.mkdtempSync(path.join(os.tmpdir(), "ex-ai-mac-dmg-")); const rwDmg = path.join(os.tmpdir(), `ex-ai-mac-${Date.now()}-${process.pid}.dmg`); // arch별 볼륨명 — 동시/잔여 마운트 충돌 방지 const volName = label.includes("silicon") ? "EX AI Chatbot Silicon" : "EX AI Chatbot Intel"; try { fs.cpSync(appPath, path.join(stage, appName), { recursive: true }); fs.copyFileSync(path.join(packagingMac, installCmdName), path.join(stage, installCmdName)); fs.copyFileSync(path.join(packagingMac, guideName), path.join(stage, guideName)); fs.chmodSync(path.join(stage, installCmdName), 0o755); // Applications 폴더 바로가기 (일반 dmg UX) try { fs.symlinkSync("/Applications", path.join(stage, "Applications")); } catch { /* ignore */ } try { execFileSync("xattr", ["-cr", stage], { stdio: "ignore" }); } catch { /* ignore */ } const outPath = path.join(distDir, dmgName); if (fs.existsSync(outPath)) fs.unlinkSync(outPath); if (fs.existsSync(rwDmg)) fs.unlinkSync(rwDmg); detachStaleVolumes(); // 읽기 전용 압축 dmg 생성 (electron-builder DMG 미사용 — hdiutil 경합 회피) // Finder/Spotlight가 잠깐 잡고 있으면 "리소스 사용 중"이 나므로 재시도한다. const createArgs = [ "create", "-volname", volName, "-srcfolder", stage, "-ov", "-format", "UDZO", outPath, ]; let lastErr = null; for (let attempt = 1; attempt <= 4; attempt += 1) { try { if (attempt > 1) { console.warn(`[finalize-mac] hdiutil create 재시도 ${attempt}/4 (${label})`); detachStaleVolumes(); try { execFileSync("sleep", [String(1.5 * attempt)], { stdio: "ignore" }); } catch { /* ignore */ } } execFileSync("hdiutil", createArgs, { stdio: "inherit" }); lastErr = null; break; } catch (e) { lastErr = e; const msg = String(e?.stderr || e?.message || e); const busy = /리소스 사용 중|Resource busy|Device busy|hdiutil/i.test(msg) || e?.status === 1; if (!busy || attempt === 4) break; } } if (lastErr) throw lastErr; const size = fs.statSync(outPath).size; console.log(`[finalize-mac] ✅ dmg ${dmgName} (${Math.round(size / 1024 / 1024)} MB)`); return true; } finally { fs.rmSync(stage, { recursive: true, force: true }); try { if (fs.existsSync(rwDmg)) fs.unlinkSync(rwDmg); } catch { /* ignore */ } } } if (!fs.existsSync(distDir)) { console.error("[finalize-mac] dist 없음"); process.exit(1); } ensurePackagingFiles(); let done = 0; for (const t of TARGETS) { const appPath = t.appDirs.find((p) => fs.existsSync(p)); if (!appPath) { console.warn(`[finalize-mac] skip ${t.label}: .app 없음`); continue; } codesignApp(appPath, t.label); if (packDmg(appPath, t.dmgName, t.label)) done += 1; if (packZip(appPath, t.zipName, t.label)) done += 1; } // 빌더 raw zip/dmg 잔여 정리 for (const raw of fs.readdirSync(distDir)) { if (/^raw-mac-/.test(raw) && (raw.endsWith(".zip") || raw.endsWith(".dmg"))) { fs.unlinkSync(path.join(distDir, raw)); console.log(`[finalize-mac] removed ${raw}`); } } if (done === 0) { console.error("[finalize-mac] 패키징할 Mac 산출물이 없습니다."); process.exit(1); }