"""
D2R Mod Manager - Diablo II Resurrected 모드 관리 툴
"""
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, simpledialog
import os
import json
import shutil
import re
from datetime import datetime
from pathlib import Path
import sys
# PyInstaller로 빌드된 exe는 __file__이 임시 폴더를 가리키므로
# sys.executable 기준(exe 실제 위치)으로 저장 경로를 잡음
if getattr(sys, "frozen", False):
_BASE_DIR = Path(sys.executable).parent
else:
_BASE_DIR = Path(__file__).parent
SAVE_FILE = _BASE_DIR / "d2r_mod_manager_state.json"
APP_ICON = "app_icon.ico"
def _get_ico_path() -> Path | None:
"""번들/스크립트 환경 모두에서 app_icon.ico 경로를 반환한다."""
if getattr(sys, "frozen", False):
bundle_dir = Path(sys._MEIPASS)
else:
bundle_dir = Path(__file__).parent
p = bundle_dir / APP_ICON
return p if p.exists() else None
def _set_icon(win):
"""타이틀바·Alt+Tab 아이콘을 설정한다. (작업표시줄은 _set_taskbar_icon 담당)"""
ico_path = _get_ico_path()
if ico_path:
try:
win.iconbitmap(str(ico_path))
except Exception:
pass
def _set_taskbar_icon(win):
"""ctypes로 HICON을 직접 윈도우 핸들에 전송해 작업표시줄 아이콘을 교체한다.
메인 Tk 창에만 호출하면 된다 (mainloop 진입 직후 after로 호출).
wm_frame()으로 실제 최상위 데코레이션 윈도우 핸들을 획득하고,
WS_EX_APPWINDOW를 명시적으로 설정해 작업표시줄 표시를 강제한다.
"""
if sys.platform != "win32":
return
ico_path = _get_ico_path()
if not ico_path:
return
try:
import ctypes
user32 = ctypes.windll.user32
GWL_EXSTYLE = -20
WS_EX_APPWINDOW = 0x00040000
WS_EX_TOOLWINDOW = 0x00000080
LR_LOADFROMFILE = 0x10
IMAGE_ICON = 1
WM_SETICON = 0x0080
ICON_SMALL = 0
ICON_BIG = 1
# winfo_id()는 내부 child 핸들을 반환할 수 있으므로
# wm_frame()으로 실제 최상위(데코레이션 포함) 윈도우 핸들을 획득
try:
hwnd = int(win.wm_frame(), 16)
except Exception:
hwnd = win.winfo_id()
# 큰 아이콘 (작업표시줄용)
big_size = user32.GetSystemMetrics(11) # SM_CXICON
hicon_big = user32.LoadImageW(
None, str(ico_path), IMAGE_ICON,
big_size, big_size, LR_LOADFROMFILE
)
# 작은 아이콘 (타이틀바용)
sm_size = user32.GetSystemMetrics(49) # SM_CXSMICON
hicon_sm = user32.LoadImageW(
None, str(ico_path), IMAGE_ICON,
sm_size, sm_size, LR_LOADFROMFILE
)
if hicon_big:
user32.SendMessageW(hwnd, WM_SETICON, ICON_BIG, hicon_big)
if hicon_sm:
user32.SendMessageW(hwnd, WM_SETICON, ICON_SMALL, hicon_sm)
# WS_EX_APPWINDOW 명시적 설정 → 작업표시줄에 항상 표시
ex_style = user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
ex_style = (ex_style | WS_EX_APPWINDOW) & ~WS_EX_TOOLWINDOW
user32.SetWindowLongW(hwnd, GWL_EXSTYLE, ex_style)
except Exception:
pass
def _set_appid():
"""Windows 작업표시줄 아이콘을 exe 아이콘으로 고정시키기 위해
AppUserModelID를 등록한다. 메인 창 생성 전에 한 번만 호출하면 된다.
"""
if sys.platform != "win32":
return
try:
import ctypes
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
"D2RModManager.App.1"
)
except Exception:
pass
# ──────────────────────────────────────────────
# 커스텀 텍스트 입력 다이얼로그
# (simpledialog.askstring 대체 → 아이콘 적용 가능)
# ──────────────────────────────────────────────
class AskStringDialog(tk.Toplevel):
"""단일 텍스트 입력을 받는 커스텀 다이얼로그.
result: 확인 시 입력 문자열, 취소/닫기 시 None.
"""
def __init__(self, parent, title: str, prompt: str, initial: str = ""):
super().__init__(parent)
self.title(title)
self.configure(bg=BG_DARK)
self.resizable(False, False)
self.result = None
_set_icon(self)
pw, ph = 440, 200
self.update_idletasks()
sw, sh = self.winfo_screenwidth(), self.winfo_screenheight()
self.geometry(f"{pw}x{ph}+{max(0,(sw-pw)//2)}+{max(0,(sh-ph)//2)}")
self.grab_set()
tk.Label(self, text=prompt, bg=BG_DARK, fg=TEXT_MAIN,
font=FONT_SMALL, wraplength=340, justify="left",
padx=20, pady=14).pack(fill="x")
self._var = tk.StringVar(value=initial)
entry = tk.Entry(self, textvariable=self._var,
bg=BG_CARD, fg=TEXT_MAIN, insertbackground=TEXT_MAIN,
font=FONT_BODY, relief="flat",
highlightthickness=1, highlightcolor=BORDER_GOLD,
highlightbackground=BORDER)
entry.pack(fill="x", padx=20)
entry.icursor("end")
entry.focus_set()
btn_frame = tk.Frame(self, bg=BG_DARK)
btn_frame.pack(fill="x", padx=20, pady=14)
styled_button(btn_frame, "확인", self._ok,
BTN_INSTALL, BTN_INSTALL_H, padx=22, pady=6).pack(side="left")
tk.Frame(btn_frame, bg=BG_DARK, width=10).pack(side="left")
styled_button(btn_frame, "취소", self.destroy,
"#e0e0e0", "#d0d0d0", fg=TEXT_MAIN, padx=22, pady=6).pack(side="left")
self.bind("<Return>", lambda e: self._ok())
self.bind("<Escape>", lambda e: self.destroy())
self.protocol("WM_DELETE_WINDOW", self.destroy)
def _ok(self):
self.result = self._var.get()
self.destroy()
@classmethod
def ask(cls, parent, title: str, prompt: str, initial: str = ""):
"""블로킹 호출. 확인 → 입력값(str), 취소 → None 반환."""
dlg = cls(parent, title, prompt, initial)
parent.wait_window(dlg)
return dlg.result
# 추가 모드를 영구 보관하는 폴더 (exe 옆 mod/ 디렉터리)
MOD_STORE_DIR = _BASE_DIR / "mod"
def _read_backup_labels(backup_root: Path) -> dict:
"""backup_root/backup_labels.json → {폴더명: 메모} dict"""
label_file = backup_root / "backup_labels.json"
if label_file.exists():
try:
return json.loads(label_file.read_text(encoding="utf-8"))
except Exception:
pass
return {}
def _write_backup_labels(backup_root: Path, labels: dict):
"""labels dict를 backup_root/backup_labels.json 에 저장"""
label_file = backup_root / "backup_labels.json"
try:
label_file.write_text(json.dumps(labels, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception:
pass
# ──────────────────────────────────────────────
# 로그 파일 경로 helper
# ──────────────────────────────────────────────
def _log_path_for(mod_folder: Path) -> Path:
"""mod_folder 옆 BackUp/ 안의 <모드명>_activity_log.json 경로 (모드별 분리)"""
mod_name = mod_folder.name
return mod_folder.parent / "BackUp" / f"{mod_name}_activity_log.json"
def _read_log(mod_folder: Path) -> list:
p = _log_path_for(mod_folder)
if p.exists():
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
pass
return []
def _write_log(mod_folder: Path, entries: list):
backup_root = mod_folder.parent / "BackUp"
backup_root.mkdir(exist_ok=True)
p = _log_path_for(mod_folder)
try:
p.write_text(json.dumps(entries, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception:
pass
def _ts_now() -> str:
"""현재 시각을 'YY:MM:DD:HH:MM:SS' 표시용 문자열로 반환"""
return datetime.now().strftime("%y:%m:%d %H:%M:%S")
# ──────────────────────────────────────────────
# 색상 & 폰트 테마 (Windows 11 네이티브 스타일)
# ──────────────────────────────────────────────
BG_DARK = "#f3f3f3" # 창 배경
BG_PANEL = "#e8e8e8" # 상태바 배경
BG_CARD = "#ffffff" # 카드 배경
BG_CARD_HOV = "#f5f5f5" # 카드 호버
BORDER = "#d0d0d0" # 일반 테두리
BORDER_GOLD = "#0078d4" # 강조 테두리 (Windows 블루)
ACCENT_GOLD = "#0078d4" # 강조색 (Windows 블루)
ACCENT_RED = "#c42b1c" # 위험 강조색
TEXT_MAIN = "#1a1a1a" # 기본 텍스트
TEXT_SUB = "#5a5a5a" # 보조 텍스트
TEXT_DIM = "#999999" # 흐린 텍스트
BTN_INSTALL = "#0078d4" # 설치 버튼 (Windows 블루)
BTN_INSTALL_H= "#006cbe"
BTN_REMOVE = "#c42b1c" # 제거 버튼 (빨간색)
BTN_REMOVE_H = "#ae2518"
BTN_BACKUP = "#107c10" # 백업 버튼 (초록색)
BTN_BACKUP_H = "#0e6b0e"
BTN_ROLL = "#7a5af8" # 롤백 버튼 (보라색)
BTN_ROLL_H = "#6b4fe0"
FONT_TITLE = ("Segoe UI", 18, "bold")
FONT_HEAD = ("Segoe UI", 12, "bold")
FONT_BODY = ("Segoe UI", 12)
FONT_SMALL = ("Segoe UI", 11)
FONT_MONO = ("Consolas", 11)
VALID_FOLDERS = {"global", "hd", "local"}
def styled_button(parent, text, command, bg, hover_bg,
fg="#ffffff", padx=10, pady=4, font=FONT_SMALL, **kw):
btn = tk.Label(parent, text=text, bg=bg, fg=fg,
font=font, padx=padx, pady=pady,
cursor="hand2", relief="flat", **kw)
btn.bind("<Button-1>", lambda e: command())
btn.bind("<Enter>", lambda e: btn.config(bg=hover_bg))
btn.bind("<Leave>", lambda e: btn.config(bg=bg))
return btn
# ──────────────────────────────────────────────
# 활동 로그 다이얼로그
# ──────────────────────────────────────────────
class LogDialog(tk.Toplevel):
def __init__(self, parent, entries: list, mod_name: str):
super().__init__(parent)
self.title("활동 로그")
self.configure(bg=BG_DARK)
self.resizable(True, True)
_set_icon(self)
pw, ph = 660, 520
self.update_idletasks()
sw, sh = self.winfo_screenwidth(), self.winfo_screenheight()
self.geometry(f"{pw}x{ph}+{max(0,(sw-pw)//2)}+{max(0,(sh-ph)//2)}")
self.grab_set()
tk.Label(self, text=f"활동 로그 — {mod_name}",
bg=BG_DARK, fg=ACCENT_GOLD, font=FONT_HEAD, pady=12).pack(fill="x")
tk.Frame(self, bg=BORDER_GOLD, height=1).pack(fill="x", padx=20)
frame = tk.Frame(self, bg=BG_DARK)
frame.pack(fill="both", expand=True, padx=20, pady=10)
sb = tk.Scrollbar(frame)
sb.pack(side="right", fill="y")
lb = tk.Listbox(
frame,
bg=BG_CARD, fg=TEXT_MAIN, font=FONT_MONO,
selectbackground=BORDER_GOLD, selectforeground="#ffffff",
relief="flat", bd=0,
highlightthickness=1, highlightcolor=BORDER_GOLD,
highlightbackground=BORDER_GOLD,
yscrollcommand=sb.set, activestyle="none"
)
lb.pack(side="left", fill="both", expand=True)
sb.config(command=lb.yview)
if entries:
for e in reversed(entries): # 최신 항목이 위로
lb.insert("end", f" {e['ts']} {e['msg']}")
else:
lb.insert("end", " (기록 없음)")
self.after(50, lb.focus_set)
btn_frame = tk.Frame(self, bg=BG_DARK)
btn_frame.pack(fill="x", padx=20, pady=(0, 14))
styled_button(btn_frame, "닫기", self.destroy,
"#e0e0e0", "#d0d0d0", fg=TEXT_MAIN, padx=22, pady=6).pack(side="left")
# ──────────────────────────────────────────────
# 롤백 선택 다이얼로그
# ──────────────────────────────────────────────
class RollbackDialog(tk.Toplevel):
def __init__(self, parent, backup_folder: Path, current_mod_name: str):
super().__init__(parent)
self.title("롤백 선택")
self.configure(bg=BG_DARK)
self.resizable(False, False)
self.selected = None
_set_icon(self)
self.current_mod_name = current_mod_name
self._backup_folder = backup_folder
self._labels = _read_backup_labels(backup_folder) if backup_folder.exists() else {}
# 화면 중앙 배치
pw, ph = 560, 460
self.update_idletasks()
sw = self.winfo_screenwidth()
sh = self.winfo_screenheight()
x = max(0, (sw - pw) // 2)
y = max(0, (sh - ph) // 2)
self.geometry(f"{pw}x{ph}+{x}+{y}")
self.grab_set()
# 제목
tk.Label(self, text="⟲ 롤백 포인트 선택",
bg=BG_DARK, fg=ACCENT_GOLD,
font=FONT_HEAD, pady=14).pack(fill="x")
sep = tk.Frame(self, bg=BORDER_GOLD, height=1)
sep.pack(fill="x", padx=20)
tk.Label(self, text="복원할 백업을 선택하세요.",
bg=BG_DARK, fg=TEXT_SUB, font=FONT_SMALL, pady=8).pack()
# 리스트
frame = tk.Frame(self, bg=BG_DARK)
frame.pack(fill="both", expand=True, padx=20, pady=(0, 10))
sb = tk.Scrollbar(frame)
sb.pack(side="right", fill="y")
self.listbox = tk.Listbox(
frame,
bg=BG_CARD, fg=TEXT_MAIN, font=FONT_MONO,
selectbackground=BORDER_GOLD, selectforeground="#ffffff",
relief="flat", bd=0,
highlightthickness=1, highlightcolor=BORDER_GOLD,
highlightbackground=BORDER_GOLD,
yscrollcommand=sb.set,
activestyle="none"
)
self.listbox.pack(side="left", fill="both", expand=True)
sb.config(command=self.listbox.yview)
# BackUp 폴더 내 .mpq 폴더 목록 수집
self.entries = []
if backup_folder.exists():
pattern = re.compile(
rf"^{re.escape(current_mod_name)}(d{{10,12}}).mpq$", re.IGNORECASE
)
for item in sorted(backup_folder.iterdir(), reverse=True):
if item.is_dir() and item.name.lower().endswith(".mpq"):
m = pattern.match(item.name)
if m:
ts = m.group(1) # 예: 2606112100
display = self._format_ts(ts, item.name)
self.entries.append((item.name, display))
self.listbox.insert("end", f" {display}")
if not self.entries:
self.listbox.insert("end", " (백업 없음)")
self.after(50, self.listbox.focus_set)
# 버튼
btn_frame = tk.Frame(self, bg=BG_DARK)
btn_frame.pack(fill="x", padx=20, pady=(0, 16))
styled_button(btn_frame, "✔ 복원", self._confirm,
BTN_INSTALL, BTN_INSTALL_H, padx=22, pady=6).pack(side="left")
tk.Frame(btn_frame, bg=BG_DARK, width=10).pack(side="left")
styled_button(btn_frame, "🗑 제거", self._delete_selected,
BTN_REMOVE, BTN_REMOVE_H, padx=22, pady=6).pack(side="left")
tk.Frame(btn_frame, bg=BG_DARK, width=10).pack(side="left")
styled_button(btn_frame, "✕ 취소", self.destroy,
"#e0e0e0", "#d0d0d0", fg=TEXT_MAIN, padx=22, pady=6).pack(side="left")
def _format_ts(self, ts: str, raw_name: str) -> str:
"""260612091036 → 2026-06-12 09:10:36 | 파싱 실패 시 raw_name"""
try:
year = int("20" + ts[0:2])
month = int(ts[2:4])
day = int(ts[4:6])
hour = int(ts[6:8])
minute = int(ts[8:10])
second = int(ts[10:12]) if len(ts) >= 12 else 0
date_str = f"{year}-{month:02d}-{day:02d} {hour:02d}:{minute:02d}:{second:02d}"
memo = self._labels.get(raw_name, "")
if memo:
return f"[{memo}] {date_str}"
else:
base = raw_name.replace(".mpq", "")
mod_only = re.sub(r"d{10,12}$", "", base)
return f"{mod_only} ({date_str})"
except Exception:
return raw_name.replace(".mpq", "")
def _confirm(self):
sel = self.listbox.curselection()
if not sel or not self.entries:
messagebox.showwarning("선택 없음", "복원할 백업을 선택하세요.", parent=self)
return
self.selected = self.entries[sel[0]][0]
self.destroy()
def _delete_selected(self):
sel = self.listbox.curselection()
if not sel or not self.entries:
messagebox.showwarning("선택 없음", "제거할 백업을 선택하세요.", parent=self)
return
folder_name, display = self.entries[sel[0]]
ok = messagebox.askyesno(
"백업 제거",
f"선택한 백업을 영구 삭제할까요?nn{display}nn이 작업은 되돌릴 수 없습니다.",
parent=self
)
if not ok:
return
target = self._backup_folder / folder_name
try:
shutil.rmtree(str(target))
except Exception as e:
messagebox.showerror("삭제 실패", str(e), parent=self)
return
# labels.json 에서도 제거
labels = _read_backup_labels(self._backup_folder)
if folder_name in labels:
del labels[folder_name]
_write_backup_labels(self._backup_folder, labels)
self._labels = labels
# 목록 갱신
idx = sel[0]
self.entries.pop(idx)
self.listbox.delete(idx)
if not self.entries:
self.listbox.insert("end", " (백업 없음)")
# ──────────────────────────────────────────────
# 메인 애플리케이션
# ──────────────────────────────────────────────
class D2RModManager(tk.Tk):
def __init__(self):
super().__init__()
self.title("D2RMT v0.1")
self.configure(bg=BG_DARK)
self.minsize(820, 620)
self.geometry("940x720")
_set_icon(self)
self.mod_folder: Path | None = None
self.mpq_folder: Path | None = None
self.mod_name: str = ""
self.addon_mods: list[dict] = []
self._activity_log: list[dict] = [] # 현재 mod_folder 세션 로그
self._build_ui()
self._load_state()
# 창이 완전히 그려진 뒤 작업표시줄 아이콘 교체
self.after(200, lambda: _set_taskbar_icon(self))
# ── 상태 저장 / 불러오기 ─────────────────────────────────────────────
# 추가 모드는 exe 옆 mod/<모드명>/ 폴더에 실제 파일을 복사해 두고,
# JSON 에는 mod_folder 경로만 저장한다.
# 재실행 시 mod/ 폴더를 스캔해 목록을 복원하므로 경로 소실 문제가 없다.
def _save_state(self):
"""mod_folder 경로만 JSON 에 저장 (addon_mods 는 mod/ 폴더가 정보 원본)."""
try:
data = {
"mod_folder": str(self.mod_folder) if self.mod_folder else None,
}
save_path = SAVE_FILE.resolve()
save_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception as e:
import traceback; traceback.print_exc()
messagebox.showerror("저장 실패", f"상태 저장 중 오류가 발생했습니다:n{e}")
def _load_state(self):
"""JSON 에서 mod_folder 복원, mod/ 폴더 스캔으로 addon_mods 복원."""
# ── mod_folder 복원
save_path = SAVE_FILE.resolve()
if save_path.exists():
try:
data = json.loads(save_path.read_text(encoding="utf-8"))
if data.get("mod_folder"):
p = Path(data["mod_folder"])
if p.exists():
mod_name = p.name
mpq_candidate = p / f"{mod_name}.mpq"
if not mpq_candidate.exists():
candidates = [x for x in p.iterdir()
if x.name.lower().endswith(".mpq")]
mpq_candidate = candidates[0] if candidates else None
if mpq_candidate:
self.mod_folder = p
self.mpq_folder = mpq_candidate
self.mod_name = mpq_candidate.name.replace(".mpq", "")
self.path_var.set(str(p))
self._render_current_mod()
# 저장된 로그 복원
self._activity_log = _read_log(p)
except Exception:
import traceback; traceback.print_exc()
# ── addon_mods 복원: mod/ 폴더 스캔
try:
if MOD_STORE_DIR.exists():
for mod_dir in sorted(MOD_STORE_DIR.iterdir()):
if not mod_dir.is_dir():
continue
mod_name = mod_dir.name
# 하위 폴더(global / hd / local) 중 존재하는 것만 수집
folders = [mod_dir / fn for fn in ("global", "hd", "local")
if (mod_dir / fn).is_dir()]
if folders:
self.addon_mods.append({"name": mod_name, "folders": folders})
except Exception:
import traceback; traceback.print_exc()
self._render_addon_list()
# 재실행 후 상태바: mod_folder가 복원된 경우 로그 다시 읽어 최신 항목 표시
# (mod_folder 복원 블록 안에서 _activity_log를 set했더라도 여기서 확정)
if self.mod_folder:
self._activity_log = _read_log(self.mod_folder)
fa = [e for e in self._activity_log if e.get("fa")]
if fa:
latest = fa[-1]
self.status_var.set(f" {latest['ts']} {latest['msg']} ▲ 로그 보기")
# ── UI 구성 ──────────────────────────────
def _build_ui(self):
# ── 하단 상태바를 먼저 pack(side=bottom) 해야
# outer(expand=True)가 남은 공간을 정확히 채움 → 스크롤바 상하 공백 없음
self.status_var = tk.StringVar(value=" 경로를 설정하여 시작하세요.")
status_bar = tk.Frame(self, bg=BG_PANEL, pady=5)
status_bar.pack(fill="x", side="bottom")
self._status_lbl = tk.Label(
status_bar, textvariable=self.status_var,
bg=BG_PANEL, fg=TEXT_SUB, font=FONT_SMALL, padx=12,
cursor="hand2", anchor="w", justify="left"
)
self._status_lbl.pack(side="left", fill="both", expand=True)
self._status_lbl.bind("<Button-1>", lambda e: self._open_log_dialog())
status_bar.bind("<Button-1>", lambda e: self._open_log_dialog())
# ── 우측 상단 고정, 스크롤 영역 밖
credit_bar = tk.Frame(self, bg=BG_DARK)
credit_bar.pack(fill="x", side="top")
tk.Label(
credit_bar,
text="디아블로2 인벤 애드온·모드 자료실 (닉네임 : 풍선 제작)",
bg=BG_DARK, fg=TEXT_DIM, font=("Segoe UI", 9),
anchor="e", padx=16, pady=2
).pack(side="right")
# ── 스크롤 가능 메인 영역 (상태바 다음에 pack → 공백 없이 꽉 참)
outer = tk.Frame(self, bg=BG_DARK, bd=0)
outer.pack(fill="both", expand=True)
canvas = tk.Canvas(outer, bg=BG_DARK, bd=0, highlightthickness=0)
vscroll = tk.Scrollbar(outer, orient="vertical", command=canvas.yview)
canvas.configure(yscrollcommand=vscroll.set)
vscroll.pack(side="right", fill="y")
canvas.pack(side="left", fill="both", expand=True)
# scroll_frame 자체에 좌우 padding 24 적용 → 스크롤바는 창 끝에 붙음
self.scroll_frame = tk.Frame(canvas, bg=BG_DARK, padx=24, pady=14)
self.scroll_window = canvas.create_window((0, 0), window=self.scroll_frame, anchor="nw")
def _update_scrollregion(e=None):
canvas.update_idletasks()
bbox = canvas.bbox("all")
if bbox:
# scrollregion 상단을 항상 0으로 고정 → 위로 스크롤 불가
canvas.configure(scrollregion=(0, 0, bbox[2], bbox[3]))
self.scroll_frame.bind("<Configure>", _update_scrollregion)
# canvas 너비 변경 시 scroll_frame 너비 동기화
def _on_canvas_configure(e):
canvas.itemconfig(self.scroll_window, width=e.width)
canvas.bind("<Configure>", _on_canvas_configure)
self._main_canvas = canvas
self._update_scrollregion = _update_scrollregion
# ── 마우스 휠 스크롤: canvas 위에 포커스가 있을 때만 동작
# (RollbackDialog 등 별도 창 제외)
def _on_mousewheel(e):
# 이벤트 위젯이 이 창(self) 소속인지 확인
try:
widget = e.widget
# toplevel 찾기
top = widget.winfo_toplevel()
if top is not self:
return # 다른 창(RollbackDialog 등)이면 무시
except Exception:
return
# scrollregion 크기가 canvas 높이보다 클 때만 스크롤
scroll_region = canvas.cget("scrollregion")
if not scroll_region:
return
try:
_, _, _, content_h = map(float, str(scroll_region).split())
except Exception:
return
if content_h <= canvas.winfo_height():
return # 내용이 창보다 작으면 스크롤 불필요
canvas.yview_scroll(int(-1 * (e.delta / 120)), "units")
self.bind_all("<MouseWheel>", _on_mousewheel)
self._build_path_section()
self._build_current_mod_section()
self._build_addon_section()
def _section_label(self, parent, text):
f = tk.Frame(parent, bg=BG_DARK)
f.pack(fill="x", pady=(14, 4))
tk.Label(f, text=text, bg=BG_DARK, fg=ACCENT_GOLD,
font=FONT_HEAD).pack(side="left")
tk.Frame(f, bg=BORDER, height=1).pack(side="left", fill="x", expand=True, padx=(10, 0))
def _build_path_section(self):
self._section_label(self.scroll_frame, "① 모드 폴더 경로 설정")
card = tk.Frame(self.scroll_frame, bg=BG_CARD,
highlightbackground=BORDER, highlightthickness=1)
card.pack(fill="x", pady=(0, 4))
inner = tk.Frame(card, bg=BG_CARD, padx=14, pady=10)
inner.pack(fill="x")
tk.Label(inner, text="경로", bg=BG_CARD, fg=TEXT_SUB,
font=FONT_SMALL, width=6, anchor="w").pack(side="left")
self.path_var = tk.StringVar(value="선택된 폴더 없음")
path_lbl = tk.Label(inner, textvariable=self.path_var,
bg=BG_CARD, fg=TEXT_MAIN, font=FONT_MONO,
anchor="w")
path_lbl.pack(side="left", fill="x", expand=True, padx=(0, 10))
styled_button(inner, "📁 폴더 선택", self._select_mod_folder,
BORDER_GOLD, ACCENT_GOLD, fg="#ffffff",
font=FONT_SMALL, padx=14, pady=6).pack(side="right")
def _build_current_mod_section(self):
self._section_label(self.scroll_frame, "② 현재 모드")
self.current_mod_frame = tk.Frame(self.scroll_frame, bg=BG_DARK)
self.current_mod_frame.pack(fill="x")
self._render_current_mod()
def _render_current_mod(self):
for w in self.current_mod_frame.winfo_children():
w.destroy()
if not self.mpq_folder:
card = tk.Frame(self.current_mod_frame, bg=BG_CARD,
highlightbackground=BORDER, highlightthickness=1)
card.pack(fill="x")
tk.Label(card, text="모드 폴더를 먼저 선택하세요.",
bg=BG_CARD, fg=TEXT_DIM, font=FONT_SMALL,
padx=14, pady=12).pack(anchor="w")
return
card = tk.Frame(self.current_mod_frame, bg=BG_CARD,
highlightbackground=BORDER_GOLD, highlightthickness=1)
card.pack(fill="x")
inner = tk.Frame(card, bg=BG_CARD, padx=14, pady=10)
inner.pack(fill="x")
# 모드 아이콘 + 이름
left = tk.Frame(inner, bg=BG_CARD)
left.pack(side="left", fill="x", expand=True)
tk.Label(left, text="🗂", bg=BG_CARD, fg=ACCENT_GOLD,
font=("Segoe UI Emoji", 18)).pack(side="left")
tk.Label(left, text=self.mod_name,
bg=BG_CARD, fg=TEXT_MAIN, font=FONT_HEAD,
padx=8).pack(side="left")
tk.Label(left, text=".mpq",
bg=BG_CARD, fg=TEXT_DIM, font=FONT_SMALL).pack(side="left")
# 버튼 우측 배치
right = tk.Frame(inner, bg=BG_CARD)
right.pack(side="right")
styled_button(right, "💾 백업", self._backup_mod,
BTN_BACKUP, BTN_BACKUP_H, padx=14, pady=6).pack(side="left", padx=4)
styled_button(right, "⟲ 롤백", self._rollback_mod,
BTN_ROLL, BTN_ROLL_H, padx=14, pady=6).pack(side="left", padx=4)
styled_button(right, "🛡 바닐라", self._restore_vanilla,
"#6b6b6b", "#555555", padx=14, pady=6).pack(side="left", padx=4)
# 경로 표시
path_frame = tk.Frame(card, bg=BG_CARD, padx=14, pady=(0, 8))
path_frame.pack(fill="x")
tk.Label(path_frame, text=str(self.mpq_folder),
bg=BG_CARD, fg=TEXT_DIM, font=FONT_MONO).pack(anchor="w")
def _build_addon_section(self):
self._section_label(self.scroll_frame, "③ 추가 모드 (스킨 / UI / 패치)")
# 추가 버튼
add_frame = tk.Frame(self.scroll_frame, bg=BG_DARK)
add_frame.pack(fill="x", pady=(0, 8))
styled_button(add_frame, "+ 모드 불러오기",
self._load_addon_mod,
BTN_INSTALL, BTN_INSTALL_H,
padx=18, pady=7, font=FONT_BODY).pack(side="left")
tk.Label(add_frame,
text=" ※ global / hd / local 폴더만 허용",
bg=BG_DARK, fg=TEXT_DIM, font=FONT_SMALL).pack(side="left")
self.addon_list_frame = tk.Frame(self.scroll_frame, bg=BG_DARK)
self.addon_list_frame.pack(fill="x")
self._render_addon_list()
def _render_addon_list(self):
for w in self.addon_list_frame.winfo_children():
w.destroy()
if not self.addon_mods:
tk.Label(self.addon_list_frame,
text="불러온 추가 모드가 없습니다.",
bg=BG_DARK, fg=TEXT_DIM, font=FONT_SMALL,
pady=8).pack(anchor="w")
return
for idx, mod in enumerate(self.addon_mods):
self._render_addon_row(idx, mod)
def _render_addon_row(self, idx: int, mod: dict):
card = tk.Frame(self.addon_list_frame, bg=BG_CARD,
highlightbackground=BORDER, highlightthickness=1)
card.pack(fill="x", pady=2)
inner = tk.Frame(card, bg=BG_CARD, padx=8, pady=8)
inner.pack(fill="x")
# ── 드래그 핸들 (≡ 아이콘)
handle = tk.Label(inner, text="≡", bg=BG_CARD, fg=TEXT_DIM,
font=("Segoe UI", 16), cursor="fleur", padx=6)
handle.pack(side="left")
# ── 모드명
left = tk.Frame(inner, bg=BG_CARD)
left.pack(side="left", fill="x", expand=True)
tk.Label(left, text=mod["name"],
bg=BG_CARD, fg=TEXT_MAIN, font=FONT_HEAD,
padx=6).pack(side="left")
# 포함 폴더 태그
for folder_path in mod["folders"]:
tag_color = {"global": "#d4edda",
"hd": "#d0e8f8",
"local": "#fce8d4"}.get(folder_path.name.lower(), BORDER)
tk.Label(left, text=folder_path.name,
bg=tag_color, fg=TEXT_MAIN,
font=FONT_SMALL, padx=6, pady=2,
relief="flat").pack(side="left", padx=2)
# ── 버튼 영역
right = tk.Frame(inner, bg=BG_CARD)
right.pack(side="right")
styled_button(right, "설치",
lambda i=idx: self._install_addon(i),
BTN_BACKUP, BTN_BACKUP_H,
padx=14, pady=5).pack(side="left", padx=3)
styled_button(right, "이름변경",
lambda i=idx: self._rename_addon(i),
"#5a6472", "#4a5462", fg="#ffffff",
padx=12, pady=5).pack(side="left", padx=3)
styled_button(right, "제거",
lambda i=idx: self._remove_addon(i),
BTN_REMOVE, BTN_REMOVE_H,
padx=14, pady=5).pack(side="left", padx=3)
# ── 드래그 & 드롭 순서 변경
self._bind_drag(handle, card, idx)
self._bind_drag(inner, card, idx)
# ── 동작 ─────────────────────────────────
def _select_mod_folder(self):
path = filedialog.askdirectory(title="mods 안의 모드 폴더 선택")
if not path:
return
mod_folder = Path(path)
# .mpq 폴더/파일 탐색 (폴더와 모드명이 일치해야 함)
mod_name = mod_folder.name
mpq_candidate = mod_folder / f"{mod_name}.mpq"
if not mpq_candidate.exists():
# .mpq 로 끝나는 것 아무거나 찾기
candidates = [p for p in mod_folder.iterdir()
if p.name.lower().endswith(".mpq")]
if not candidates:
messagebox.showerror(
"오류",
f"'{mod_folder}' 안에 .mpq 폴더/파일이 없습니다.n"
"올바른 모드 폴더를 선택하세요."
)
return
mpq_candidate = candidates[0]
self.mod_folder = mod_folder
self.mpq_folder = mpq_candidate
self.mod_name = mpq_candidate.name.replace(".mpq", "")
# 새 모드 로드 → 로그 초기화 후 첫 항목 기록
self._activity_log = _read_log(mod_folder)
self.path_var.set(str(mod_folder))
self._render_current_mod()
self._add_log(f"모드 로드: {self.mod_name}", file_affecting=True)
self._save_state()
def _backup_mod(self):
if not self.mpq_folder or not self.mpq_folder.exists():
messagebox.showerror("오류", "유효한 .mpq 폴더가 없습니다.")
return
# 백업 메모 입력 (빈 값 허용 → 메모 없음)
memo = AskStringDialog.ask(
self,
"백업 메모",
"이 백업 상태를 설명하는 메모를 입력하세요.n(비워두면 날짜/시간만 표시됩니다)"
)
if memo is None: # 취소 버튼
return
memo = memo.strip()
mods_folder = self.mod_folder.parent
backup_root = mods_folder / "BackUp"
backup_root.mkdir(exist_ok=True)
now = datetime.now()
ts = now.strftime("%y%m%d%H%M%S") # 예: 260612091036
dst_name = f"{self.mod_name}{ts}.mpq"
dst_path = backup_root / dst_name
if dst_path.exists():
messagebox.showwarning("중복", f"같은 이름의 백업이 이미 존재합니다:n{dst_name}")
return
try:
shutil.copytree(str(self.mpq_folder), str(dst_path))
except Exception as e:
messagebox.showerror("백업 실패", str(e))
return
# 메모가 있으면 backup_labels.json 에 저장
if memo:
labels = _read_backup_labels(backup_root)
labels[dst_name] = memo
_write_backup_labels(backup_root, labels)
# 백업 시점 로그 항목 추가 후 스냅샷을 백업 이름으로 함께 저장
label_str = f" [{memo}]" if memo else ""
self._add_log(f"백업{label_str} → {dst_name}", file_affecting=True)
# 현재 로그를 이 백업 폴더에도 복사 (롤백 때 복원용)
try:
snap_path = dst_path / "_activity_log_snapshot.json"
snap_path.write_text(
json.dumps(self._activity_log, ensure_ascii=False, indent=2),
encoding="utf-8"
)
except Exception:
pass
messagebox.showinfo("백업 완료", f"백업이 저장되었습니다:n{dst_path}")
def _rollback_mod(self):
if not self.mod_folder:
messagebox.showerror("오류", "모드 폴더가 설정되지 않았습니다.")
return
mods_folder = self.mod_folder.parent
backup_root = mods_folder / "BackUp"
if not backup_root.exists() or not any(backup_root.iterdir()):
messagebox.showinfo("백업 없음", "BackUp 폴더에 저장된 백업이 없습니다.")
return
dlg = RollbackDialog(self, backup_root, self.mod_name)
self.wait_window(dlg)
if not dlg.selected:
return
selected_path = backup_root / dlg.selected
confirm = messagebox.askyesno(
"롤백 확인",
f"현재 모드 '{self.mod_name}.mpq'를 삭제하고n"
f"'{dlg.selected}'으로 복원하시겠습니까?nn"
"이 작업은 되돌릴 수 없습니다."
)
if not confirm:
return
try:
# 현재 모드 삭제
if self.mpq_folder.exists():
shutil.rmtree(str(self.mpq_folder))
# 백업 복사 (원래 이름으로)
target = self.mod_folder / f"{self.mod_name}.mpq"
shutil.copytree(str(selected_path), str(target))
self.mpq_folder = target
except Exception as e:
messagebox.showerror("롤백 실패", str(e))
return
# 백업에 저장된 로그 스냅샷 복원
snap = target / "_activity_log_snapshot.json"
if snap.exists():
try:
self._activity_log = json.loads(snap.read_text(encoding="utf-8"))
_write_log(self.mod_folder, self._activity_log)
except Exception:
pass
self._add_log(f"롤백 완료 → {dlg.selected}", file_affecting=True)
messagebox.showinfo("롤백 완료",
f"'{self.mod_name}.mpq'이 복원되었습니다.")
self._render_current_mod()
def _restore_vanilla(self):
"""현재 모드의 data 폴더 안 global / hd / local 폴더를 삭제해 바닐라 상태로 복원한다."""
if not self.mpq_folder or not self.mpq_folder.exists():
messagebox.showerror("오류", "현재 모드(.mpq)가 설정되지 않았습니다.")
return
data_folder = self.mpq_folder / "data"
targets = [data_folder / name for name in ("global", "hd", "local")
if (data_folder / name).exists()]
if not targets:
messagebox.showinfo(
"이미 바닐라 상태",
f"'{data_folder}' 안에 global / hd / local 폴더가 없습니다.n"
"이미 바닐라(순정) 상태입니다."
)
return
folder_list = "n".join(f" • {t.name}" for t in targets)
confirmed = messagebox.askyesno(
"⚠️ 바닐라 복원 확인",
f"아래 폴더를 영구적으로 삭제합니다.nn"
f"{folder_list}nn"
f"경로: {data_folder}nn"
"삭제된 파일은 복구할 수 없습니다.n"
"계속하시겠습니까?",
icon="warning"
)
if not confirmed:
return
errors = []
deleted = []
for t in targets:
try:
shutil.rmtree(str(t))
deleted.append(t.name)
except Exception as e:
errors.append(f"{t.name}: {e}")
if errors:
messagebox.showerror("삭제 오류", "n".join(errors))
else:
deleted_str = ", ".join(deleted)
messagebox.showinfo(
"바닐라 복원 완료",
f"다음 폴더가 삭제되었습니다: {deleted_str}nn"
"클라이언트가 순정(바닐라) 상태로 실행됩니다."
)
self._add_log(f"바닐라 복원 — 삭제: {deleted_str}", file_affecting=True)
def _load_addon_mod(self):
"""폴더 다중 선택 → 유효성 검사 → 모드명 입력 → 등록"""
folders: list[Path] = []
while True:
path = filedialog.askdirectory(
title=f"모드 폴더 선택 (global/hd/local) ― 현재 {len(folders)}개 선택됨 | 취소 시 완료"
)
if not path:
break
p = Path(path)
if p.name.lower() not in VALID_FOLDERS:
messagebox.showerror(
"잘못된 폴더",
f"'{p.name}' 폴더는 허용되지 않습니다.n"
"global, hd, local 폴더만 선택할 수 있습니다."
)
continue
if p.name.lower() in ("global", "local") and p.parent.name.lower() == "hd":
messagebox.showerror(
"경로 오류",
f"선택한 '{p.name}' 폴더의 상위가 'hd' 입니다.nn"
f"현재 경로: {p}nn"
"이 경로는 data/hd/global (또는 local) 로 설치됩니다.n"
"일반 모드에 필요한 경로는 data/global (또는 data/local) 이므로,n"
"hd 폴더 바깥의 올바른 global / local 폴더를 선택하세요."
)
continue
if p not in folders:
folders.append(p)
if not folders:
return
mod_name = AskStringDialog.ask(
self,
"모드명 입력",
"이 모드의 이름을 입력하세요:"
)
if not mod_name or not mod_name.strip():
return
name = mod_name.strip()
# ── mod/<모드명>/ 폴더에 파일 복사 (영구 보관)
store_dir = MOD_STORE_DIR / name
if store_dir.exists():
overwrite = messagebox.askyesno(
"이미 존재",
f"'{name}' 모드가 이미 저장되어 있습니다.n덮어쓸까요?"
)
if not overwrite:
return
shutil.rmtree(str(store_dir))
try:
store_dir.mkdir(parents=True, exist_ok=True)
for src in folders:
dst = store_dir / src.name
shutil.copytree(str(src), str(dst))
except Exception as e:
messagebox.showerror("저장 실패", f"mod 폴더에 복사 중 오류:n{e}")
return
# ── addon_mods 에 등록 (folders는 mod/ 내부 경로)
stored_folders = [store_dir / src.name for src in folders]
self.addon_mods.append({"name": name, "folders": stored_folders})
self._render_addon_list()
self._add_log(f"추가 모드 등록: {name}")
self._save_state()
def _install_addon(self, idx: int):
if not self.mpq_folder or not self.mpq_folder.exists():
messagebox.showerror("오류", "현재 모드(.mpq)가 설정되지 않았습니다.")
return
mod = self.addon_mods[idx]
data_folder = self.mpq_folder / "data"
if not data_folder.exists():
confirm = messagebox.askyesno(
"data 폴더 없음",
f"'{data_folder}' 폴더가 없습니다. 생성하고 계속할까요?"
)
if not confirm:
return
data_folder.mkdir(parents=True)
errors = []
for src in mod["folders"]:
dst = data_folder / src.name
try:
dst.mkdir(parents=True, exist_ok=True)
shutil.copytree(str(src), str(dst), dirs_exist_ok=True)
except Exception as e:
errors.append(f"{src.name}: {e}")
if errors:
messagebox.showerror("설치 오류", "n".join(errors))
else:
messagebox.showinfo("설치 완료",
f"'{mod['name']}' 모드가 설치되었습니다.n"
f"→ {data_folder}")
self._add_log(f"설치 완료: {mod['name']}", file_affecting=True)
def _remove_addon(self, idx: int):
mod = self.addon_mods[idx]
confirm = messagebox.askyesno(
"제거 확인",
f"'{mod['name']}' 모드를 목록에서 제거할까요?nn"
"저장된 mod 폴더의 파일도 함께 삭제됩니다."
)
if confirm:
# mod/<모드명>/ 폴더 삭제
store_dir = MOD_STORE_DIR / mod["name"]
if store_dir.exists():
try:
shutil.rmtree(str(store_dir))
except Exception as e:
messagebox.showerror("삭제 실패", f"mod 폴더 삭제 중 오류:n{e}")
self.addon_mods.pop(idx)
self._render_addon_list()
self._add_log(f"모드 제거: {mod['name']}")
self._save_state()
def _rename_addon(self, idx: int):
mod = self.addon_mods[idx]
new_name = AskStringDialog.ask(
self,
"이름 변경",
f"'{mod['name']}' 의 새 이름을 입력하세요:",
initial=mod["name"]
)
if not new_name or not new_name.strip():
return
new_name = new_name.strip()
if new_name == mod["name"]:
return
# mod/ 폴더 이름 변경
old_dir = MOD_STORE_DIR / mod["name"]
new_dir = MOD_STORE_DIR / new_name
if new_dir.exists():
messagebox.showerror("이름 충돌", f"'{new_name}' 이름의 모드가 이미 존재합니다.")
return
try:
if old_dir.exists():
old_dir.rename(new_dir)
except Exception as e:
messagebox.showerror("이름 변경 실패", str(e))
return
# addon_mods 내부 경로도 새 이름으로 업데이트
new_folders = [new_dir / f.name for f in mod["folders"]]
self.addon_mods[idx] = {"name": new_name, "folders": new_folders}
self._render_addon_list()
self._add_log(f"이름 변경: {mod['name']} → {new_name}")
# ── 드래그 & 드롭 순서 변경 ─────────────────────────────────────────
def _bind_drag(self, widget: tk.Widget, card: tk.Frame, idx: int):
"""widget 에 드래그 이벤트를 바인딩해 addon_mods 순서를 변경한다."""
state = {}
def on_press(e, i=idx):
state["start_y"] = e.y_root
state["idx"] = i
# 드래그 중인 카드 강조
card.configure(highlightbackground=ACCENT_GOLD)
def on_drag(e):
pass # 시각 피드백은 release 시 처리
def on_release(e, i=idx):
card.configure(highlightbackground=BORDER)
if "start_y" not in state:
return
dy = e.y_root - state["start_y"]
# 카드 높이를 동적으로 구함 (기본 ~42px)
try:
card_h = max(card.winfo_height(), 42)
except Exception:
card_h = 42
steps = round(dy / card_h)
if steps == 0:
return
src = i
dst = max(0, min(len(self.addon_mods) - 1, src + steps))
if src == dst:
return
# 순서 변경
item = self.addon_mods.pop(src)
self.addon_mods.insert(dst, item)
self._render_addon_list()
self._add_log(f"순서 변경: '{item['name']}' {src+1}번 → {dst+1}번")
widget.bind("<ButtonPress-1>", on_press)
widget.bind("<B1-Motion>", on_drag)
widget.bind("<ButtonRelease-1>", on_release)
# ── 로그 & 상태바 ────────────────────────────────────────────────────
def _add_log(self, msg: str, file_affecting: bool = False):
"""로그 항목 추가, 상태바 갱신, 파일 저장.
file_affecting=True 인 항목만 로그 다이얼로그에 표시된다.
"""
entry = {"ts": _ts_now(), "msg": msg, "fa": file_affecting}
self._activity_log.append(entry)
# 상태바에는 file_affecting 항목만 표시 (없으면 마지막 항목)
fa_entries = [e for e in self._activity_log if e.get("fa")]
latest = fa_entries[-1] if fa_entries else entry
self.status_var.set(f" {latest['ts']} {latest['msg']} ▲ 로그 보기")
# mod_folder 가 설정돼 있으면 즉시 파일에 저장
if self.mod_folder:
_write_log(self.mod_folder, self._activity_log)
def _open_log_dialog(self):
fa_entries = [e for e in self._activity_log if e.get("fa")]
if not fa_entries:
messagebox.showinfo("로그 없음",
"아직 기록된 파일 변경 이력이 없습니다.n"
"모드를 설치하거나 롤백해보세요.")
return
mod_name = self.mod_name or "—"
LogDialog(self, fa_entries, mod_name)
# ──────────────────────────────────────────────
# 진입점
# ──────────────────────────────────────────────
if __name__ == "__main__":
_set_appid()
app = D2RModManager()
app.mainloop()