Deploy: exdev sync 2026-08-03 13:59:26
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.venv-icons/
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,48 @@
|
||||
# EX AI Chatbot 데스크톱 앱
|
||||
|
||||
Electron 래퍼 — 브라우저 주소표시줄 없이 챗봇을 독립 창으로 실행합니다.
|
||||
|
||||
## 빌드 · 배포
|
||||
|
||||
```bash
|
||||
./build-and-publish.sh
|
||||
# 또는
|
||||
npm run dist:mac # dmg(설치) + zip(포터블), intel + silicon
|
||||
npm run dist:win # Setup.exe(설치) + portable.exe, win32 + win64
|
||||
npm run publish-downloads
|
||||
```
|
||||
|
||||
산출물은 `chatbotApp-chatbot2.0-agent/public-ai/downloads/` 로 복사됩니다.
|
||||
|
||||
| 구분 | 파일 |
|
||||
|------|------|
|
||||
| Mac 설치 (Intel) | `exAIChatbot 2.0-mac-intel.dmg` |
|
||||
| Mac 포터블 (Intel) | `exAIChatbot 2.0-mac-intel.zip` |
|
||||
| Mac 설치 (Apple Silicon) | `exAIChatbot 2.0-mac-silicon.dmg` |
|
||||
| Mac 포터블 (Apple Silicon) | `exAIChatbot 2.0-mac-silicon.zip` |
|
||||
| Windows 설치 64비트 | `exAIChatbot 2.0-win64-Setup.exe` |
|
||||
| Windows 포터블 64비트 | `exAIChatbot 2.0-win64-portable.exe` |
|
||||
| Windows 설치 32비트 | `exAIChatbot 2.0-win32-Setup.exe` |
|
||||
| Windows 포터블 32비트 | `exAIChatbot 2.0-win32-portable.exe` |
|
||||
|
||||
다운로드 URL 예:
|
||||
|
||||
- `/chatbot/ai/downloads/exAIChatbot%202.0-mac-silicon.dmg`
|
||||
- `/chatbot/ai/downloads/exAIChatbot%202.0-win64-Setup.exe`
|
||||
|
||||
## 앱 아이콘
|
||||
|
||||
공식 길통이·차로(`ai-mascot.png`)로 Dock/바로가기 아이콘을 생성합니다.
|
||||
|
||||
```bash
|
||||
npm run build:icons # build/icon.png · icon.icns · icon.ico
|
||||
```
|
||||
|
||||
`dist:mac` / `dist:win` 실행 시 자동으로 아이콘을 다시 만든 뒤 패키징합니다.
|
||||
|
||||
## 사용 안내
|
||||
|
||||
- **Mac dmg:** 앱을 Applications로 드래그. 차단 시 설정 화면「Mac 설치 명령 복사」.
|
||||
- **Mac zip:** `실행하기.command` 또는 `.app` 실행.
|
||||
- **Windows Setup:** 설치 마법사 실행.
|
||||
- **Windows portable:** 압축 없이 exe 바로 실행. SmartScreen → 추가 정보 → 실행.
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
# Electron 데스크톱 앱 재빌드 → chatbotApp public-ai/downloads/ 배포
|
||||
#
|
||||
# ./chatbotDesktopApp-chatbot2.0/build-and-publish.sh
|
||||
# SKIP_WIN=1 ./chatbotDesktopApp-chatbot2.0/build-and-publish.sh # Mac만
|
||||
# SKIP_MAC=1 ./chatbotDesktopApp-chatbot2.0/build-and-publish.sh # Win만
|
||||
set -euo pipefail
|
||||
|
||||
DESKTOP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${DESKTOP_DIR}/.." && pwd)"
|
||||
DOWNLOADS_DIR="${ROOT_DIR}/chatbotApp-chatbot2.0-agent/public-ai/downloads"
|
||||
|
||||
export PATH="/opt/homebrew/bin:/usr/local/bin:${PATH}"
|
||||
export CSC_IDENTITY_AUTO_DISCOVERY="${CSC_IDENTITY_AUTO_DISCOVERY:-false}"
|
||||
|
||||
log() { echo "[desktop-build $(date '+%H:%M:%S')] $*"; }
|
||||
warn() { log "⚠️ $*"; }
|
||||
fail() { log "ERROR: $*"; exit 1; }
|
||||
|
||||
[[ -f "${DESKTOP_DIR}/package.json" ]] || fail "package.json 없음: ${DESKTOP_DIR}"
|
||||
|
||||
cd "${DESKTOP_DIR}"
|
||||
|
||||
if [[ ! -d node_modules/electron ]]; then
|
||||
log "npm 의존성 설치..."
|
||||
npm ci --prefer-offline 2>/dev/null || npm install
|
||||
else
|
||||
log "npm 의존성 확인..."
|
||||
npm ci --prefer-offline 2>/dev/null || npm install --prefer-offline
|
||||
fi
|
||||
|
||||
SKIP_MAC="${SKIP_MAC:-0}"
|
||||
SKIP_WIN="${SKIP_WIN:-0}"
|
||||
built_any=false
|
||||
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
if [[ "${SKIP_MAC}" != "1" ]]; then
|
||||
log "잔여 DMG 볼륨 정리..."
|
||||
hdiutil detach "/Volumes/EX AI Chatbot" -force 2>/dev/null || true
|
||||
hdiutil detach "/Volumes/EX AI Chatbot Intel" -force 2>/dev/null || true
|
||||
hdiutil detach "/Volumes/EX AI Chatbot Silicon" -force 2>/dev/null || true
|
||||
log "Mac Intel + Apple Silicon (dmg+zip) 빌드..."
|
||||
# electron-builder는 arch별 dir(.app)만 순차 생성 → finalize-mac이 dmg/zip 생성
|
||||
# (dmg를 동시 빌드하면 hdiutil detach 충돌로 실패함)
|
||||
npm run dist:mac
|
||||
built_any=true
|
||||
fi
|
||||
if [[ "${SKIP_WIN}" != "1" ]]; then
|
||||
log "Windows 32/64비트 (Setup+portable) 빌드 (크로스)..."
|
||||
if npm run dist:win; then
|
||||
built_any=true
|
||||
else
|
||||
warn "Windows 빌드 실패 — Mac 산출물만 배포합니다"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
Linux)
|
||||
if [[ "${SKIP_WIN}" != "1" ]]; then
|
||||
log "Windows 32/64비트 (Setup+portable) 빌드..."
|
||||
if npm run dist:win; then
|
||||
built_any=true
|
||||
else
|
||||
warn "Windows 빌드 실패"
|
||||
fi
|
||||
fi
|
||||
if [[ "${SKIP_MAC}" != "1" ]]; then
|
||||
warn "Linux에서는 Mac 빌드를 건너뜁니다 (SKIP_MAC 강제)"
|
||||
fi
|
||||
;;
|
||||
MINGW*|MSYS*|CYGWIN*|Windows_NT)
|
||||
if [[ "${SKIP_WIN}" != "1" ]]; then
|
||||
log "Windows 32/64비트 (Setup+portable) 빌드..."
|
||||
npm run dist:win
|
||||
built_any=true
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
fail "지원하지 않는 OS: $(uname -s)"
|
||||
;;
|
||||
esac
|
||||
|
||||
$built_any || fail "빌드된 산출물이 없습니다"
|
||||
|
||||
log "downloads/ 로 복사..."
|
||||
npm run publish-downloads
|
||||
|
||||
[[ -d "${DOWNLOADS_DIR}" ]] || fail "downloads 디렉터리 없음: ${DOWNLOADS_DIR}"
|
||||
log "✅ 데스크톱 앱 배포 완료 → ${DOWNLOADS_DIR}"
|
||||
ls -lh "${DOWNLOADS_DIR}" | sed 's/^/ /'
|
||||
@@ -0,0 +1,176 @@
|
||||
const { app, BrowserWindow, shell, Menu, session } = require("electron");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
|
||||
// Gatekeeper / ad-hoc 서명 환경에서 GPU Helper SIGTRAP 완화
|
||||
app.disableHardwareAcceleration();
|
||||
app.commandLine.appendSwitch("disable-gpu");
|
||||
app.commandLine.appendSwitch("disable-gpu-compositing");
|
||||
app.commandLine.appendSwitch("disable-software-rasterizer");
|
||||
app.commandLine.appendSwitch("no-sandbox");
|
||||
app.commandLine.appendSwitch("disable-dev-shm-usage");
|
||||
|
||||
const WINDOW_WIDTH = 390;
|
||||
const WINDOW_HEIGHT = 720;
|
||||
|
||||
const DEFAULT_URL =
|
||||
process.env.CHATBOT_APP_URL ||
|
||||
"https://chatbot.exdev.co.kr/chatbot/ai/?desktop=1";
|
||||
|
||||
const logFile = path.join(app.getPath("userData"), "ex-ai-chatbot.log");
|
||||
|
||||
function log(msg) {
|
||||
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
||||
try {
|
||||
fs.appendFileSync(logFile, line);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
console.log(msg);
|
||||
}
|
||||
|
||||
/** 챗봇 주소에서 온 마이크 요청은 매번 묻지 않고 허용한다. (음성인식 반복 권한창 방지) */
|
||||
function allowMicrophoneForChatbot() {
|
||||
const allowed = new Set(["media", "audioCapture", "mediaKeySystem"]);
|
||||
const appOrigin = (() => {
|
||||
try {
|
||||
return new URL(DEFAULT_URL).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const isChatbotOrigin = (url) => {
|
||||
if (!appOrigin || !url) return false;
|
||||
try {
|
||||
return new URL(url).origin === appOrigin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const ses = session.defaultSession;
|
||||
|
||||
ses.setPermissionRequestHandler((webContents, permission, callback, details) => {
|
||||
const url = details?.requestingUrl || webContents?.getURL();
|
||||
callback(allowed.has(permission) && isChatbotOrigin(url));
|
||||
});
|
||||
|
||||
ses.setPermissionCheckHandler((_webContents, permission, requestingOrigin, details) => {
|
||||
const url = requestingOrigin || details?.requestingUrl;
|
||||
return allowed.has(permission) && isChatbotOrigin(url);
|
||||
});
|
||||
|
||||
if (typeof ses.setDevicePermissionHandler === "function") {
|
||||
ses.setDevicePermissionHandler((details) => isChatbotOrigin(details?.origin));
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const iconPath = path.join(__dirname, "build", process.platform === "win32" ? "icon.ico" : "icon.png");
|
||||
const winOpts = {
|
||||
width: WINDOW_WIDTH,
|
||||
height: WINDOW_HEIGHT,
|
||||
minWidth: 360,
|
||||
minHeight: 520,
|
||||
title: "한국도로공사 AI 챗봇",
|
||||
backgroundColor: "#ffffff",
|
||||
autoHideMenuBar: true,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
webSecurity: true,
|
||||
},
|
||||
};
|
||||
if (fs.existsSync(iconPath)) {
|
||||
winOpts.icon = iconPath;
|
||||
}
|
||||
const win = new BrowserWindow(winOpts);
|
||||
|
||||
Menu.setApplicationMenu(null);
|
||||
|
||||
win.once("ready-to-show", () => {
|
||||
win.show();
|
||||
if (process.platform === "darwin" && app.dock) {
|
||||
const dockIcon = path.join(__dirname, "build", "icon.png");
|
||||
if (fs.existsSync(dockIcon)) {
|
||||
try {
|
||||
app.dock.setIcon(dockIcon);
|
||||
} catch (e) {
|
||||
log(`dock.setIcon failed: ${e}`);
|
||||
}
|
||||
}
|
||||
app.dock.show();
|
||||
}
|
||||
});
|
||||
|
||||
win.webContents.on("did-fail-load", (_e, code, desc, url) => {
|
||||
log(`did-fail-load code=${code} desc=${desc} url=${url}`);
|
||||
// 네트워크 실패 시에도 빈 창으로 종료되지 않도록 안내 페이지
|
||||
const html = `<!doctype html><meta charset="utf-8"/>
|
||||
<body style="font:14px -apple-system,sans-serif;padding:40px;line-height:1.6">
|
||||
<h2>연결 실패</h2>
|
||||
<p>챗봇 서버에 연결하지 못했습니다.</p>
|
||||
<p style="color:#666">${code}: ${desc}</p>
|
||||
<p><a href="${DEFAULT_URL}">다시 시도</a></p>
|
||||
</body>`;
|
||||
win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
|
||||
});
|
||||
|
||||
win.webContents.on("render-process-gone", (_e, details) => {
|
||||
log(`render-process-gone ${JSON.stringify(details)}`);
|
||||
});
|
||||
|
||||
win.on("unresponsive", () => log("window unresponsive"));
|
||||
|
||||
log(`loadURL ${DEFAULT_URL}`);
|
||||
win.loadURL(DEFAULT_URL).catch((err) => log(`loadURL error: ${err}`));
|
||||
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
const gotLock = app.requestSingleInstanceLock();
|
||||
if (!gotLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on("second-instance", () => {
|
||||
const wins = BrowserWindow.getAllWindows();
|
||||
if (wins.length > 0) {
|
||||
if (wins[0].isMinimized()) wins[0].restore();
|
||||
wins[0].focus();
|
||||
} else {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
app.setName("EX AI Chatbot");
|
||||
log(`ready userData=${app.getPath("userData")}`);
|
||||
allowMicrophoneForChatbot();
|
||||
createWindow();
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
process.on("uncaughtException", (err) => {
|
||||
log(`uncaughtException ${err?.stack || err}`);
|
||||
});
|
||||
}
|
||||
+5288
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"name": "ex-ai-chatbot-desktop",
|
||||
"version": "1.0.0",
|
||||
"description": "한국도로공사 AI 챗봇 데스크톱 앱 (Electron 래퍼)",
|
||||
"main": "main.js",
|
||||
"author": "EX",
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"build:icons": "python3 scripts/build-icons.py",
|
||||
"dist:mac:detach": "hdiutil detach '/Volumes/EX AI Chatbot' -force 2>/dev/null || hdiutil detach '/Volumes/EX AI Chatbot Intel' -force 2>/dev/null || hdiutil detach '/Volumes/EX AI Chatbot Silicon' -force 2>/dev/null || true",
|
||||
"dist:mac:x64": "npm run dist:mac:detach && electron-builder --mac dir --x64",
|
||||
"dist:mac:arm64": "npm run dist:mac:detach && electron-builder --mac dir --arm64",
|
||||
"dist:mac": "npm run build:icons && npm run dist:mac:x64 && npm run dist:mac:arm64 && node scripts/finalize-mac.mjs",
|
||||
"dist:win": "npm run build:icons && electron-builder --win --x64 --ia32 && node scripts/finalize-win.mjs",
|
||||
"dist:all": "npm run dist:mac && npm run dist:win",
|
||||
"publish-downloads": "node scripts/publish-downloads.mjs"
|
||||
},
|
||||
"build": {
|
||||
"appId": "kr.co.ex.aichatbot",
|
||||
"productName": "EX AI Chatbot",
|
||||
"copyright": "Copyright © Korea Expressway Corporation",
|
||||
"directories": {
|
||||
"output": "dist",
|
||||
"buildResources": "build"
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"package.json",
|
||||
"build/icon.png",
|
||||
"build/icon.ico"
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.utilities",
|
||||
"icon": "build/icon.icns",
|
||||
"target": ["dir"],
|
||||
"identity": null
|
||||
},
|
||||
"win": {
|
||||
"icon": "build/icon.ico",
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": ["x64", "ia32"]
|
||||
},
|
||||
{
|
||||
"target": "portable",
|
||||
"arch": ["x64", "ia32"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"shortcutName": "EX AI Chatbot",
|
||||
"installerIcon": "build/icon.ico",
|
||||
"uninstallerIcon": "build/icon.ico",
|
||||
"artifactName": "raw-win-${arch}-Setup.${ext}"
|
||||
},
|
||||
"portable": {
|
||||
"artifactName": "raw-win-${arch}-portable.${ext}"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^35.1.2",
|
||||
"electron-builder": "^25.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,36 @@
|
||||
EX AI Chatbot — Mac 설치 안내
|
||||
================================
|
||||
|
||||
이 패키지는 Apple 공증(notarization)이 없는 사내/개발용 빌드입니다.
|
||||
Chrome 등으로 받은 뒤 앱을 바로 열면
|
||||
「손상되었기 때문에 열 수 없습니다」가 뜰 수 있습니다.
|
||||
(포터블 zip의 「실행하기.command」는 이 문제를 자동으로 피합니다.)
|
||||
|
||||
────────────────────────────────
|
||||
A. 설치 파일 (.dmg) — 반드시 아래 순서
|
||||
────────────────────────────────
|
||||
1. dmg를 연다
|
||||
2. 「응용 프로그램에 설치하고 실행.command」를 더블클릭
|
||||
→ Applications에 설치 + 검역 속성 제거 + 실행까지 한 번에 처리
|
||||
3. (대안) 앱을 Applications로 드래그한 뒤, 아래 C. 터미널 명령을 실행
|
||||
|
||||
※ Applications의 앱을 더블클릭만 하면 macOS가 다시 차단할 수 있습니다.
|
||||
처음 한 번은 반드시 「설치하고 실행.command」또는 터미널(xattr)을 사용하세요.
|
||||
|
||||
────────────────────────────────
|
||||
B. 포터블 (.zip)
|
||||
────────────────────────────────
|
||||
1. zip 압축 해제
|
||||
2. 「실행하기.command」를 더블클릭
|
||||
3. 차단되면 아래 「터미널 설치」를 사용
|
||||
|
||||
────────────────────────────────
|
||||
C. 터미널 설치 (손상됨 / 차단될 때)
|
||||
────────────────────────────────
|
||||
1. Spotlight(⌘+스페이스) → 「터미널」실행
|
||||
2. 챗봇 설정 화면의 「Mac 설치 명령 복사」붙여넣기(⌘+V) 후 Enter
|
||||
|
||||
직접 입력 (Applications에 넣은 경우):
|
||||
xattr -cr "/Applications/EX AI Chatbot.app" && open "/Applications/EX AI Chatbot.app"
|
||||
|
||||
Apple Silicon → mac-silicon / Intel → mac-intel 파일을 받으세요.
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
# 주의: 웹에서 받은 .command 는 macOS가 같이 차단할 수 있습니다.
|
||||
# 차단되면 「설치안내.txt」의 터미널 방법을 사용하세요.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
APP="EX AI Chatbot.app"
|
||||
|
||||
if [[ ! -d "${APP}" ]]; then
|
||||
osascript -e 'display dialog "같은 폴더에 「EX AI Chatbot.app」이 없습니다.\n압축을 해제한 뒤, 차단되면 터미널 설치 방법을 사용하세요.\n(설치안내.txt 참고)" buttons {"확인"} default button 1 with title "EX AI Chatbot"' >/dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 자기 자신·앱 검역 제거 시도 (권한이 되면 성공)
|
||||
xattr -cr . 2>/dev/null || true
|
||||
xattr -cr "${APP}" 2>/dev/null || true
|
||||
|
||||
open "${APP}"
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
# DMG에서 실행: Applications로 복사 + Gatekeeper 검역 제거 후 실행
|
||||
# (미서명 빌드에서 「손상되었기 때문에 열 수 없습니다」 방지)
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
APP_NAME="EX AI Chatbot.app"
|
||||
SRC="$(pwd)/${APP_NAME}"
|
||||
DEST="/Applications/${APP_NAME}"
|
||||
|
||||
if [[ ! -d "${SRC}" ]]; then
|
||||
osascript -e 'display dialog "같은 디스크에 「EX AI Chatbot.app」이 없습니다.\ndmg 창에서 이 스크립트를 실행해 주세요." buttons {"확인"} default button 1 with title "EX AI Chatbot"' >/dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 기존 설치 교체
|
||||
rm -rf "${DEST}"
|
||||
cp -R "${SRC}" "${DEST}"
|
||||
|
||||
# 다운로드/복사로 붙은 quarantine 제거 (손상됨 팝업 핵심 해결)
|
||||
xattr -cr "${DEST}" 2>/dev/null || true
|
||||
|
||||
open "${DEST}"
|
||||
|
||||
osascript -e 'display dialog "응용 프로그램에 설치하고 실행했습니다.\n다음에 실행할 때는 Launchpad 또는 응용 프로그램 폴더의 「EX AI Chatbot」을 사용하세요." buttons {"확인"} default button 1 with title "EX AI Chatbot"' >/dev/null 2>&1 || true
|
||||
@@ -0,0 +1,6 @@
|
||||
const { contextBridge } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("exDesktop", {
|
||||
isDesktop: true,
|
||||
platform: process.platform,
|
||||
});
|
||||
@@ -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}`);
|
||||
@@ -48,16 +48,21 @@ Gitea(`https://github.exdev.co.kr`)에서 **`exAichatbot_agent`** 저장소 생
|
||||
|
||||
## 로컬 Mac → Gitea push → 서버 배포
|
||||
|
||||
개발 Mac에서 **한 번에** GitHub 최신 반영 · Gitea push · 서버 빌드·배포:
|
||||
개발 Mac에서 **한 번에** Gitea 최신 반영 · 로컬 변경 commit/push · 서버 빌드·배포:
|
||||
|
||||
```bash
|
||||
./start-all-server.sh
|
||||
# = 로컬 빌드 → Gitea(exdev) push → 원격 배포 (공식 GitHub push 없음)
|
||||
# = (push-to-gitea) Gitea fetch/merge → 로컬 commit → Gitea push → 원격 배포
|
||||
# 공식 GitHub push 없음
|
||||
|
||||
./git_fetch_all.sh
|
||||
# = 확인 후 공식 GitHub(KoreaExpressWayCorp) 반영 전용
|
||||
# = 공식 GitHub(KoreaExpressWayCorp) submodule sync/commit/push 전용
|
||||
```
|
||||
|
||||
`push-to-gitea.sh` 는 `git_fetch_all.sh` 와 같이 **원격(Gitea) 변경을 먼저 merge** 한 뒤 로컬을 올립니다.
|
||||
충돌 시 스크립트가 멈추므로 수동 해결 후 재실행하세요.
|
||||
(서버 work tree에만 있고 Gitea에 없는 수정은 포함되지 않습니다 — 배포는 Gitea 기준입니다.)
|
||||
|
||||
| 옵션 | 설명 |
|
||||
|------|------|
|
||||
| `--local` | 예전처럼 `local-dev/start-all.sh` 로컬 개발 모드 |
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# 로컬 monorepo(현재 소스) → Gitea push (post-receive / 원격 배포 트리거)
|
||||
# 로컬 monorepo → Gitea(exdev) 동기화 후 push (post-receive / 원격 배포 트리거)
|
||||
#
|
||||
# ./server-dev/push-to-gitea.sh
|
||||
# ./server-dev/push-to-gitea.sh --dry-run
|
||||
#
|
||||
# 흐름 (git_fetch_all.sh 와 유사):
|
||||
# 1) Gitea(main) fetch
|
||||
# 2) 원격 변경을 현재 소스에 merge
|
||||
# 3) 로컬 변경 commit
|
||||
# 4) Gitea push
|
||||
#
|
||||
# 공식 GitHub(KoreaExpressWayCorp) 에는 push 하지 않음.
|
||||
# 공식 GitHub 반영은 ./git_fetch_all.sh 전용.
|
||||
set -euo pipefail
|
||||
@@ -17,7 +23,7 @@ for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) DRY_RUN=true ;;
|
||||
-h|--help)
|
||||
sed -n '2,10p' "$0"
|
||||
sed -n '2,16p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
@@ -65,6 +71,72 @@ reset_submodule_noise "exAiChatBot-chatbot2.0-agent" \
|
||||
reset_submodule_noise "chatbotApi-chatbot2.0-agent" gradlew
|
||||
reset_submodule_noise "kakaoChatbotSkill-chatbot2.0-agent" gradlew
|
||||
|
||||
working_tree_dirty() {
|
||||
! git diff --quiet || ! git diff --cached --quiet
|
||||
}
|
||||
|
||||
# Gitea 원격 변경을 현재 브랜치에 반영 (commit 전에 수행)
|
||||
sync_from_gitea() {
|
||||
log "Gitea fetch ${GITEA_REMOTE}/${GIT_BRANCH}..."
|
||||
if ! git fetch "${GITEA_REMOTE}" "${GIT_BRANCH}"; then
|
||||
warn "Gitea fetch 실패 — 네트워크/원격 확인 필요. 로컬만으로 계속합니다."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! git rev-parse "${GITEA_REMOTE}/${GIT_BRANCH}" >/dev/null 2>&1; then
|
||||
log "원격 브랜치 없음 — 최초 push 대상"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local local_rev remote_rev
|
||||
local_rev="$(git rev-parse HEAD)"
|
||||
remote_rev="$(git rev-parse "${GITEA_REMOTE}/${GIT_BRANCH}")"
|
||||
|
||||
if [[ "${local_rev}" == "${remote_rev}" ]]; then
|
||||
log "Gitea 와 HEAD 동일 (${local_rev:0:7}) — merge 생략"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if git merge-base --is-ancestor "${remote_rev}" "${local_rev}" 2>/dev/null; then
|
||||
log "로컬이 Gitea 보다 앞섬 (${local_rev:0:7} → 원격 ${remote_rev:0:7}) — merge 생략"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Gitea 변경 반영: merge ${GITEA_REMOTE}/${GIT_BRANCH} (${remote_rev:0:7})"
|
||||
if $DRY_RUN; then
|
||||
log "[dry-run] git merge --no-edit ${GITEA_REMOTE}/${GIT_BRANCH}"
|
||||
git log --oneline "HEAD..${GITEA_REMOTE}/${GIT_BRANCH}" | head -20 || true
|
||||
return 0
|
||||
fi
|
||||
|
||||
local stashed=false
|
||||
if working_tree_dirty; then
|
||||
log "로컬 미커밋 변경 stash 후 merge..."
|
||||
git stash push -u -m "push-to-gitea: pre-merge $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
stashed=true
|
||||
fi
|
||||
|
||||
if ! git merge --no-edit "${GITEA_REMOTE}/${GIT_BRANCH}"; then
|
||||
if $stashed; then
|
||||
warn "merge 충돌 — stash 는 유지됨. 해결 후: git stash pop && ./server-dev/push-to-gitea.sh"
|
||||
else
|
||||
warn "merge 충돌 — 해결 후 ./server-dev/push-to-gitea.sh 재실행"
|
||||
fi
|
||||
fail "Gitea merge 실패 (충돌 수동 해결 필요)"
|
||||
fi
|
||||
|
||||
if $stashed; then
|
||||
log "stash 복원..."
|
||||
if ! git stash pop; then
|
||||
fail "stash pop 충돌 — git status 확인 후 해결·재실행"
|
||||
fi
|
||||
fi
|
||||
|
||||
log "✅ Gitea 변경 merge 완료"
|
||||
}
|
||||
|
||||
sync_from_gitea
|
||||
|
||||
if $DRY_RUN; then
|
||||
log "[dry-run] git add / commit / push 생략"
|
||||
git status -sb
|
||||
@@ -80,7 +152,8 @@ git add local-dev/ server-dev/ scripts/ git_fetch_all.sh 2>/dev/null || true
|
||||
git add start-all-server.sh stop-all-server.sh 2>/dev/null || true
|
||||
|
||||
for path in chatbotApp-chatbot2.0-agent chatbotApi-chatbot2.0-agent \
|
||||
exAiChatBot-chatbot2.0-agent kakaoChatbotSkill-chatbot2.0-agent; do
|
||||
exAiChatBot-chatbot2.0-agent kakaoChatbotSkill-chatbot2.0-agent \
|
||||
chatbotDesktopApp-chatbot2.0; do
|
||||
[[ -d "${path}" ]] && git add "${path}" 2>/dev/null || true
|
||||
done
|
||||
|
||||
@@ -95,7 +168,6 @@ else
|
||||
log "monorepo 스테이징 변경 없음"
|
||||
fi
|
||||
|
||||
git fetch "${GITEA_REMOTE}" "${GIT_BRANCH}" 2>/dev/null || true
|
||||
local_rev="$(git rev-parse HEAD)"
|
||||
remote_rev=""
|
||||
if git rev-parse "${GITEA_REMOTE}/${GIT_BRANCH}" >/dev/null 2>&1; then
|
||||
@@ -107,6 +179,22 @@ if [[ -n "${remote_rev}" && "${local_rev}" == "${remote_rev}" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# push 직전 한 번 더 원격 확인 (동시 배포 대비)
|
||||
git fetch "${GITEA_REMOTE}" "${GIT_BRANCH}" 2>/dev/null || true
|
||||
if git rev-parse "${GITEA_REMOTE}/${GIT_BRANCH}" >/dev/null 2>&1; then
|
||||
remote_rev="$(git rev-parse "${GITEA_REMOTE}/${GIT_BRANCH}")"
|
||||
if [[ "${local_rev}" != "${remote_rev}" ]] \
|
||||
&& ! git merge-base --is-ancestor "${remote_rev}" "${local_rev}" 2>/dev/null; then
|
||||
log "push 직전 원격 추가 커밋 발견 — 재 merge..."
|
||||
if ! git merge --no-edit "${GITEA_REMOTE}/${GIT_BRANCH}"; then
|
||||
fail "push 직전 merge 실패 — 충돌 해결 후 재실행"
|
||||
fi
|
||||
local_rev="$(git rev-parse HEAD)"
|
||||
fi
|
||||
fi
|
||||
|
||||
log "Gitea push ${GITEA_REMOTE} ${GIT_BRANCH} (${local_rev:0:7})..."
|
||||
git push "${GITEA_REMOTE}" "${GIT_BRANCH}"
|
||||
if ! git push "${GITEA_REMOTE}" "${GIT_BRANCH}"; then
|
||||
fail "Gitea push 실패 — 다른 사람이 먼저 push 했을 수 있음. 재실행: ./server-dev/push-to-gitea.sh"
|
||||
fi
|
||||
log "✅ Gitea push 완료 — post-receive 배포 트리거됨"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
# ./server-dev/start-all-server.sh --skip-oracle # TROAD Oracle 기동 생략
|
||||
# ./server-dev/start-all-server.sh --local # 로컬 Mac 개발 모드 (local-dev 위임)
|
||||
#
|
||||
# 로컬 Mac 실행 시: 현재 소스 → 개발서버(Gitea/exdev)만 반영·배포.
|
||||
# 로컬 Mac 실행 시: Gitea(exdev) 최신 merge → 현재 소스 commit/push → 원격 배포.
|
||||
# 공식 GitHub(KoreaExpressWayCorp) push 는 하지 않음 — ./git_fetch_all.sh 전용.
|
||||
set -euo pipefail
|
||||
|
||||
@@ -173,7 +173,7 @@ build_desktop_app() {
|
||||
}
|
||||
|
||||
deploy_from_local_mac() {
|
||||
log "로컬 Mac → Gitea(exdev) push → chatbot.exdev.co.kr 배포"
|
||||
log "로컬 Mac → Gitea(exdev) sync(merge) → push → chatbot.exdev.co.kr 배포"
|
||||
log "공식 GitHub push 생략 (필요 시 ./git_fetch_all.sh)"
|
||||
build_chatbot_app_for_deploy
|
||||
push_source_to_gitea
|
||||
|
||||
Reference in New Issue
Block a user