Deploy: exdev sync 2026-08-03 13:59:26

This commit is contained in:
Macbook
2026-08-03 13:59:26 +09:00
parent 39f41d90b0
commit 62604fe1b7
19 changed files with 6505 additions and 10 deletions
@@ -0,0 +1,230 @@
#!/usr/bin/env python3
"""
공식 길통이+차로(ai-mascot.png)로 Electron 앱 아이콘 생성
- build/icon.png (1024)
- build/icon.icns (macOS)
- build/icon.ico (Windows)
"""
from __future__ import annotations
import plistlib
import shutil
import struct
import subprocess
import sys
import tempfile
import zlib
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BUILD = ROOT / "build"
SRC_CANDIDATES = [
ROOT.parent / "chatbotApp-chatbot2.0-agent" / "web" / "public" / "images" / "ai-mascot.png",
ROOT.parent / "chatbotApp-chatbot2.0-agent" / "public-ai" / "images" / "ai-mascot.png",
]
# EX 브랜드 블루
BG = (36, 107, 235, 255)
WHITE = (255, 255, 255, 255)
def _venv_python(venv: Path) -> Path:
if sys.platform == "win32":
return venv / "Scripts" / "python.exe"
py = venv / "bin" / "python3"
return py if py.exists() else venv / "bin" / "python"
def _in_venv(venv: Path) -> bool:
"""Homebrew venv의 bin/python은 시스템 바이너리 symlink라 executable.resolve() 비교는 불가."""
try:
return Path(sys.prefix).resolve() == venv.resolve()
except OSError:
return False
def _ensure_pip(py: Path) -> None:
try:
subprocess.check_call([str(py), "-m", "pip", "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except subprocess.CalledProcessError:
print("[build-icons] ensurepip으로 pip 부트스트랩…", flush=True)
subprocess.check_call([str(py), "-m", "ensurepip", "--upgrade"])
def _pip_install_pillow(py: Path) -> None:
_ensure_pip(py)
cmd = [str(py), "-m", "pip", "install", "Pillow", "-q"]
try:
subprocess.check_call(cmd)
except subprocess.CalledProcessError:
print("[build-icons] pip 재시도 (ensurepip 후)…", flush=True)
subprocess.check_call([str(py), "-m", "ensurepip", "--upgrade"])
subprocess.check_call(cmd)
def require_pillow():
try:
from PIL import Image, ImageDraw # noqa: F401
return
except ImportError:
pass
venv = ROOT / ".venv-icons"
py = _venv_python(venv)
if not py.exists():
print("[build-icons] venv 생성 중…", flush=True)
subprocess.check_call([sys.executable, "-m", "venv", str(venv)])
py = _venv_python(venv)
print("[build-icons] Pillow 설치 중…", flush=True)
_pip_install_pillow(py)
# 현재 프로세스에 Pillow가 없으면 venv로 재실행 (sys.prefix로 판별)
if not _in_venv(venv):
raise SystemExit(subprocess.call([str(py), str(Path(__file__).resolve()), *sys.argv[1:]]))
from PIL import Image, ImageDraw # noqa: F401
def rounded_rect_mask(size: int, radius: int):
from PIL import Image, ImageDraw
mask = Image.new("L", (size, size), 0)
draw = ImageDraw.Draw(mask)
draw.rounded_rectangle((0, 0, size - 1, size - 1), radius=radius, fill=255)
return mask
def make_icon_png(src: Path, out: Path, size: int = 1024) -> None:
from PIL import Image, ImageDraw, ImageFilter
mascot = Image.open(src).convert("RGBA")
# 흰 배경 제거에 가깝게: 거의 흰 픽셀을 투명 처리
pixels = mascot.load()
w, h = mascot.size
for y in range(h):
for x in range(w):
r, g, b, a = pixels[x, y]
if r > 245 and g > 245 and b > 245:
pixels[x, y] = (r, g, b, 0)
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(canvas)
# macOS 아이콘용 라운드 사각 (약 22% radius)
radius = int(size * 0.223)
draw.rounded_rectangle((0, 0, size - 1, size - 1), radius=radius, fill=BG)
# 안쪽 밝은 원형 배지
badge = Image.new("RGBA", (size, size), (0, 0, 0, 0))
bd = ImageDraw.Draw(badge)
pad = int(size * 0.08)
bd.ellipse((pad, pad, size - pad, size - pad), fill=(255, 255, 255, 235))
canvas = Image.alpha_composite(canvas, badge)
# 마스코트 배치 (여백 확보)
max_side = int(size * 0.78)
mw, mh = mascot.size
scale = min(max_side / mw, max_side / mh)
nw, nh = int(mw * scale), int(mh * scale)
mascot = mascot.resize((nw, nh), Image.Resampling.LANCZOS)
ox = (size - nw) // 2
oy = (size - nh) // 2 + int(size * 0.02)
canvas.alpha_composite(mascot, (ox, oy))
# 마스크로 모서리 정리
mask = rounded_rect_mask(size, radius)
out_img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
out_img.paste(canvas, (0, 0), mask=mask)
out.parent.mkdir(parents=True, exist_ok=True)
out_img.save(out, "PNG")
print(f"[build-icons] ✅ {out} ({size}x{size})")
def build_icns(png_1024: Path, icns_path: Path) -> None:
sizes = [
(16, "icon_16x16.png"),
(32, "diana.k@example.org"),
(32, "icon_32x32.png"),
(64, "ivan.p@example.net"),
(128, "icon_128x128.png"),
(256, "wendy.h@example.net"),
(256, "icon_256x256.png"),
(512, "wendy.h@example.net"),
(512, "icon_512x512.png"),
(1024, "walt.e@example.net"),
]
iconset = BUILD / "icon.iconset"
if iconset.exists():
shutil.rmtree(iconset)
iconset.mkdir(parents=True)
from PIL import Image
base = Image.open(png_1024).convert("RGBA")
for px, name in sizes:
img = base.resize((px, px), Image.Resampling.LANCZOS)
img.save(iconset / name, "PNG")
if icns_path.exists():
icns_path.unlink()
subprocess.check_call(["iconutil", "-c", "icns", str(iconset), "-o", str(icns_path)])
shutil.rmtree(iconset)
print(f"[build-icons] ✅ {icns_path}")
def _png_chunk(tag: bytes, data: bytes) -> bytes:
return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
def rgba_to_png_bytes(img) -> bytes:
"""Pillow 이미지 → PNG bytes (ICO용)."""
import io
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def build_ico(png_1024: Path, ico_path: Path) -> None:
"""여러 해상도 PNG를 담은 .ico 생성 (electron-builder는 256 필수)."""
from PIL import Image
sizes = [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)]
base = Image.open(png_1024).convert("RGBA")
# Pillow: 원본에서 sizes= 로 리사이즈한 멀티 엔트리 ICO 생성
# (append_images + 작은 첫 프레임은 16x16만 남는 버그 회피)
base.save(ico_path, format="ICO", sizes=sizes)
print(f"[build-icons] ✅ {ico_path}")
def main() -> int:
require_pillow()
src = next((p for p in SRC_CANDIDATES if p.exists()), None)
if not src:
print("[build-icons] ai-mascot.png 없음", file=sys.stderr)
return 1
BUILD.mkdir(parents=True, exist_ok=True)
png = BUILD / "icon.png"
make_icon_png(src, png, 1024)
# electron-builder 호환 별칭
shutil.copy2(png, BUILD / "icon-1024.png")
if sys.platform == "darwin":
try:
build_icns(png, BUILD / "icon.icns")
except Exception as e:
print(f"[build-icons] icns 실패 (png만 사용): {e}", file=sys.stderr)
else:
print("[build-icons] icns는 macOS에서만 생성합니다")
try:
build_ico(png, BUILD / "icon.ico")
except Exception as e:
print(f"[build-icons] ico 실패: {e}", file=sys.stderr)
return 1
print("[build-icons] 완료")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,23 @@
/** 다운로드 산출물 파일명 (공백 포함 — URL 인코딩 필요) */
export const DOWNLOAD_ARTIFACTS = {
macIntelInstaller: "exAIChatbot 2.0-mac-intel.dmg",
macIntelPortable: "exAIChatbot 2.0-mac-intel.zip",
macSiliconInstaller: "exAIChatbot 2.0-mac-silicon.dmg",
macSiliconPortable: "exAIChatbot 2.0-mac-silicon.zip",
win32Installer: "exAIChatbot 2.0-win32-Setup.exe",
win32Portable: "exAIChatbot 2.0-win32-portable.exe",
win64Installer: "exAIChatbot 2.0-win64-Setup.exe",
win64Portable: "exAIChatbot 2.0-win64-portable.exe",
};
export const DOWNLOAD_ARTIFACT_LIST = Object.values(DOWNLOAD_ARTIFACTS);
/** 이전 배포 파일명 (정리용) */
export const OBSOLETE_DOWNLOAD_ARTIFACTS = [
"exAIChatbot 2.0-win32.zip",
"exAIChatbot 2.0-win64.zip",
"EX-AI-Chatbot-mac-universal.zip",
"EX-AI-Chatbot-Setup.exe",
"EX-AI-Chatbot-Setup.exe.blockmap",
"EX-AI-Chatbot-mac-universal.zip.blockmap",
];
@@ -0,0 +1,226 @@
#!/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);
}
@@ -0,0 +1,69 @@
#!/usr/bin/env node
/**
* Windows 산출물 정리
* - Setup.exe (NSIS 설치형)
* - portable.exe (포터블)
*/
import fs from "node:fs";
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 MAP = [
{
patterns: [/^raw-win-x64-Setup\.exe$/i],
out: DOWNLOAD_ARTIFACTS.win64Installer,
},
{
patterns: [/^raw-win-x64-portable\.exe$/i],
out: DOWNLOAD_ARTIFACTS.win64Portable,
},
{
patterns: [/^raw-win-ia32-Setup\.exe$/i, /^raw-win-i386-Setup\.exe$/i],
out: DOWNLOAD_ARTIFACTS.win32Installer,
},
{
patterns: [/^raw-win-ia32-portable\.exe$/i, /^raw-win-i386-portable\.exe$/i],
out: DOWNLOAD_ARTIFACTS.win32Portable,
},
];
if (!fs.existsSync(distDir)) {
console.error("[finalize-win] dist 없음");
process.exit(1);
}
const files = fs.readdirSync(distDir);
let done = 0;
for (const rule of MAP) {
const srcName = files.find((f) => rule.patterns.some((re) => re.test(f)));
if (!srcName) {
console.warn(`[finalize-win] skip (원본 없음): ${rule.out}`);
continue;
}
const src = path.join(distDir, srcName);
const dest = path.join(distDir, rule.out);
if (src !== dest) {
if (fs.existsSync(dest)) fs.unlinkSync(dest);
fs.renameSync(src, dest);
}
const size = fs.statSync(dest).size;
console.log(`[finalize-win] ✅ ${rule.out} (${Math.round(size / 1024 / 1024)} MB) ← ${srcName}`);
done += 1;
}
for (const f of fs.readdirSync(distDir)) {
if (/^raw-win-/.test(f)) {
fs.unlinkSync(path.join(distDir, f));
console.log(`[finalize-win] removed ${f}`);
}
}
if (done === 0) {
console.error("[finalize-win] Windows 설치/포터블 산출물을 찾지 못했습니다.");
process.exit(1);
}
@@ -0,0 +1,76 @@
#!/usr/bin/env node
/**
* electron-builder 산출물 → chatbotApp public-ai/downloads/ 복사
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
DOWNLOAD_ARTIFACTS,
DOWNLOAD_ARTIFACT_LIST,
OBSOLETE_DOWNLOAD_ARTIFACTS,
} from "./download-artifacts.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const distDir = path.join(root, "dist");
const downloadsDir = path.resolve(
root,
"../chatbotApp-chatbot2.0-agent/public-ai/downloads",
);
const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
if (!fs.existsSync(distDir)) {
console.error(`[publish-downloads] dist 없음: ${distDir}`);
console.error("먼저 npm run dist:mac 또는 dist:win 을 실행하세요.");
process.exit(1);
}
fs.mkdirSync(downloadsDir, { recursive: true });
for (const name of OBSOLETE_DOWNLOAD_ARTIFACTS) {
const p = path.join(downloadsDir, name);
if (fs.existsSync(p)) {
fs.unlinkSync(p);
console.log(`[publish-downloads] removed obsolete ${name}`);
}
}
const copied = [];
for (const name of DOWNLOAD_ARTIFACT_LIST) {
const src = path.join(distDir, name);
if (!fs.existsSync(src)) {
console.warn(`[publish-downloads] skip (없음): ${name}`);
continue;
}
const dest = path.join(downloadsDir, name);
fs.copyFileSync(src, dest);
const stat = fs.statSync(dest);
copied.push({
file: name,
size: stat.size,
mtime: stat.mtime.toISOString(),
});
console.log(`[publish-downloads] ${name} → ${dest}`);
}
const manifest = {
version: pkg.version,
updatedAt: new Date().toISOString(),
files: { ...DOWNLOAD_ARTIFACTS },
artifacts: copied,
note:
"미서명 빌드 — Mac: dmg(설치) / zip(포터블·실행하기.command). Windows: Setup.exe(설치) / portable.exe(포터블).",
};
fs.writeFileSync(
path.join(downloadsDir, "manifest.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
"utf8",
);
if (copied.length === 0) {
console.error("[publish-downloads] 복사된 파일이 없습니다.");
process.exit(1);
}
console.log(`[publish-downloads] ✅ ${copied.length}개 → ${downloadsDir}`);