#!/usr/bin/env python3 """ mc_server_builder.py Minecraft マルチサーバー自動構築・管理ツール Velocity (proxy) + Paper/Purpur/Spigot (backend) 構成を自動セットアップ・管理 Windows: GUI (tkinter) / Linux: CUI 更新履歴: v2.2 - Java バージョン自動解決機能を追加 Java 21未満が検出された場合、PATH全体・既知インストール先を自動探索して Java 21以上に切り替え。見つからない場合は自動インストールを試みる。 v2.1 - プラグイン検索結果にアイコン画像を表示 / 複数サーバーへの一括インストール対応 インストール済みプラグインのバージョン・ダウンロード先を自動保存 起動時にプラグインの更新を自動チェック v2.0 - UIをダークテーマにリデザイン / 自動アップデート・Watchdog 再起動機能追加 v1.x - 初期リリース """ import sys import os import platform import json import shutil import subprocess import threading import urllib.request import urllib.error import urllib.parse import re import time import secrets import string from pathlib import Path from dataclasses import dataclass, field, asdict from typing import Optional IS_WINDOWS = platform.system() == "Windows" # ───────────────────────────────────────────── # バージョン管理・自動アップデート # ───────────────────────────────────────────── CURRENT_VERSION = "2.2" UPDATE_BASE_URL = "https://exi-server.site/mcserverbilder/" UPDATE_CHECK_URL = UPDATE_BASE_URL + "latestupdate" def _parse_version(v: str) -> tuple: """'2.1' → (2, 1) のようにタプルに変換して比較できるようにする""" try: return tuple(int(x) for x in str(v).strip().lstrip("v").split(".")) except Exception: return (0,) def check_for_update() -> Optional[dict]: """ UPDATE_CHECK_URL にGETを送り、新バージョンがあれば dict を返す。 なければ None。 サーバーが返すレスポンスは以下のどちらかに対応: JSON: {"version": "2.1", "url": "https://...mc_server_builder.py"} プレーンテキスト: バージョン番号のみ (ダウンロードURLは BASE_URL + "mc_server_builder.py") """ try: req = urllib.request.Request( UPDATE_CHECK_URL, headers={"User-Agent": "mc-network-builder/" + CURRENT_VERSION} ) with urllib.request.urlopen(req, timeout=10) as r: raw = r.read().decode("utf-8").strip() # JSON 判定 try: data = json.loads(raw) latest_ver = str(data.get("version", "")).strip() dl_url = data.get("url", UPDATE_BASE_URL + "mc_server_builder.py") except json.JSONDecodeError: # プレーンテキスト(バージョン番号のみ) latest_ver = raw dl_url = UPDATE_BASE_URL + "mc_server_builder.py" if not latest_ver: return None if _parse_version(latest_ver) > _parse_version(CURRENT_VERSION): return {"version": latest_ver, "url": dl_url} return None except Exception: return None def apply_update(new_url: str, log_cb=None) -> bool: """ 新バージョンの .py をダウンロードして自分自身を上書きし、 プロセスを再起動する。失敗したら False を返す。 exe ビルド版の場合は上書き非対応のため案内のみ行う。 """ script_path = Path(__file__).resolve() # exe ビルド版の場合はスクリプト更新不可 → 案内のみ if getattr(sys, "frozen", False): log("⚠ exe版は自動上書き非対応です。新しい exe を手動で置き換えてください。", log_cb) log(f" ダウンロード: {new_url}", log_cb) return False backup = script_path.with_suffix(".py.bak") try: log(f"📥 新バージョンをダウンロード中: {new_url}", log_cb) req = urllib.request.Request( new_url, headers={"User-Agent": "mc-network-builder/" + CURRENT_VERSION} ) with urllib.request.urlopen(req, timeout=60) as r: new_code = r.read() # バックアップ shutil.copy2(script_path, backup) log(f"💾 バックアップ: {backup}", log_cb) # 上書き script_path.write_bytes(new_code) log("✅ ファイル更新完了。再起動します...", log_cb) # 再起動 time.sleep(1) os.execv(sys.executable, [sys.executable] + sys.argv) return True # ここには到達しない except Exception as e: log(f"❌ 更新失敗: {e}", log_cb) # バックアップを戻す if backup.exists(): try: shutil.copy2(backup, script_path) log("↩ バックアップから復元しました", log_cb) except Exception: pass return False # ───────────────────────────────────────────── # 多重起動防止 # ───────────────────────────────────────────── def _acquire_single_instance_lock(): """既に起動中なら警告して終了。ロックオブジェクトを返す(GC防止用)""" if IS_WINDOWS: try: import ctypes mutex = ctypes.windll.kernel32.CreateMutexW(None, False, "MCNetworkBuilder_SingleInstance") last_err = ctypes.windll.kernel32.GetLastError() if last_err == 183: # ERROR_ALREADY_EXISTS try: import tkinter as tk from tkinter import messagebox r = tk.Tk(); r.withdraw() messagebox.showwarning("多重起動", "Minecraft Network Manager はすでに起動しています。") r.destroy() except Exception: print("すでに起動しています。") sys.exit(0) return mutex # 参照を保持しないとGCされるので返す except Exception: pass else: import fcntl lock_path = Path("/tmp/mc_network_builder.lock") try: lf = open(lock_path, "w") fcntl.flock(lf, fcntl.LOCK_EX | fcntl.LOCK_NB) return lf # 参照を保持 except OSError: print("すでに起動しています。") sys.exit(0) _instance_lock = _acquire_single_instance_lock() # winreg はWindows専用 if IS_WINDOWS: try: import winreg except ImportError: winreg = None else: winreg = None # ツール自身のconfig保存先 if IS_WINDOWS: _appdata = os.environ.get("APPDATA", str(Path.home())) else: _appdata = str(Path.home() / ".config") CONFIG_PATH = Path(_appdata) / "mc_network_builder" / "config.json" MEM_PATTERN = re.compile(r"^\d+[MmGg]$") # ───────────────────────────────────────────── # データクラス # ───────────────────────────────────────────── @dataclass class BackendServer: name: str port: int server_type: str = "paper" # paper / purpur / spigot mc_version: str = "latest" motd: str = "A Minecraft Server" max_players: int = 100 online_mode: bool = False gamemode: str = "survival" difficulty: str = "normal" mem_min: str = "512M" mem_max: str = "2G" rcon_password: str = "" # RCON パスワード(自動生成) @dataclass class NetworkConfig: velocity_port: int = 25565 forwarding_mode: str = "modern" forwarding_secret: str = "" backends: list = field(default_factory=list) base_dir: str = "mc_network" java_path: str = "java" velocity_mem_min: str = "512M" velocity_mem_max: str = "1G" autostart_enabled: bool = False autostart_servers: bool = False # 起動時に全サーバーを自動起動 auto_restart_enabled: bool = True watchdog_timeout: int = 120 # seconds of RCON failure before restart # ───────────────────────────────────────────── # バリデーション # ───────────────────────────────────────────── def validate_mem(val: str) -> bool: return bool(MEM_PATTERN.match(val.strip())) # ───────────────────────────────────────────── # config 保存/読み込み # ───────────────────────────────────────────── def save_config(cfg: NetworkConfig): CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) data = asdict(cfg) CONFIG_PATH.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") def load_config() -> Optional[NetworkConfig]: if not CONFIG_PATH.exists(): return None try: data = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) backends = [BackendServer(**{k: v for k, v in b.items() if k in BackendServer.__dataclass_fields__}) for b in data.get("backends", [])] data["backends"] = backends # 新フィールドがない古いconfigでも動くようにデフォルト補完 data.setdefault("auto_restart_enabled", True) data.setdefault("watchdog_timeout", 120) data.setdefault("autostart_servers", False) return NetworkConfig(**data) except Exception: return None # ───────────────────────────────────────────── # Windows 自動起動 (レジストリ) # ───────────────────────────────────────────── AUTOSTART_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run" AUTOSTART_NAME = "MinecraftNetworkBuilder" def _systemd_unit_path() -> Path: """Linux: ~/.config/systemd/user/mc-network-builder.service""" return Path.home() / ".config" / "systemd" / "user" / "mc-network-builder.service" def _systemd_unit_content(script_path: str) -> str: python_exe = sys.executable return f"""[Unit] Description=Minecraft Network Builder After=network.target [Service] Type=simple ExecStart={python_exe} {script_path} --autostart Restart=no Environment=DISPLAY=:0 [Install] WantedBy=default.target """ def set_autostart(enabled: bool, script_path: str = None): if IS_WINDOWS: if winreg is None: return try: key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, AUTOSTART_KEY, 0, winreg.KEY_SET_VALUE) if enabled and script_path: python_exe = sys.executable value = f'"{python_exe}" "{script_path}" --autostart' winreg.SetValueEx(key, AUTOSTART_NAME, 0, winreg.REG_SZ, value) else: try: winreg.DeleteValue(key, AUTOSTART_NAME) except FileNotFoundError: pass winreg.CloseKey(key) except Exception as e: print(f"自動起動設定エラー: {e}") else: # Linux: systemd ユーザーユニット unit_path = _systemd_unit_path() if enabled and script_path: unit_path.parent.mkdir(parents=True, exist_ok=True) unit_path.write_text(_systemd_unit_content(script_path), encoding="utf-8") try: subprocess.run(["systemctl", "--user", "enable", "mc-network-builder.service"], capture_output=True, timeout=10) subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True, timeout=10) except Exception as e: print(f"systemd enable エラー: {e}") else: try: subprocess.run(["systemctl", "--user", "disable", "mc-network-builder.service"], capture_output=True, timeout=10) except Exception: pass if unit_path.exists(): unit_path.unlink() def get_autostart() -> bool: if IS_WINDOWS: if winreg is None: return False try: key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, AUTOSTART_KEY, 0, winreg.KEY_READ) winreg.QueryValueEx(key, AUTOSTART_NAME) winreg.CloseKey(key) return True except FileNotFoundError: return False except Exception: return False else: # systemd ユニットが enabled かどうか確認 try: r = subprocess.run( ["systemctl", "--user", "is-enabled", "mc-network-builder.service"], capture_output=True, text=True, timeout=5 ) return r.stdout.strip() == "enabled" except Exception: return _systemd_unit_path().exists() # ───────────────────────────────────────────── # ユーティリティ # ───────────────────────────────────────────── def log(msg: str, callback=None): print(msg) if callback: callback(msg) def generate_secret(length=32) -> str: alphabet = string.ascii_letters + string.digits return ''.join(secrets.choice(alphabet) for _ in range(length)) def find_java() -> Optional[str]: java = shutil.which("java") if java: return java jh = os.environ.get("JAVA_HOME") if jh: j = Path(jh) / "bin" / ("java.exe" if IS_WINDOWS else "java") if j.exists(): return str(j) return None JAVA21_SEARCH_PATHS_WINDOWS = [ r"C:\Program Files\Eclipse Adoptium", r"C:\Program Files\Microsoft", r"C:\Program Files\Java", r"C:\Program Files\Amazon Corretto", r"C:\Program Files\Zulu", ] def _find_java21_or_install(log_cb=None) -> Optional[str]: """ Java 21以上のバイナリを探す。 1. PATH上の全 java を走査 2. Windows: 既知インストール先を再帰検索 3. Linux/Mac: /usr/lib/jvm 等を再帰検索 4. 見つからなければ install_java() を呼ぶ """ java_name = "java.exe" if IS_WINDOWS else "java" # 1. PATH上の全候補を走査 for path_dir in os.environ.get("PATH", "").split(os.pathsep): candidate = Path(path_dir) / java_name if candidate.exists(): major = get_java_major_version(str(candidate)) if major is not None and major >= 21: log(f" Java 21+ 発見 (PATH): {candidate}", log_cb) return str(candidate) # 2. OS別の固定パス検索 if IS_WINDOWS: for base_str in JAVA21_SEARCH_PATHS_WINDOWS: base_p = Path(base_str) if not base_p.exists(): continue for java_bin in base_p.rglob("bin/java.exe"): major = get_java_major_version(str(java_bin)) if major is not None and major >= 21: log(f" Java 21+ 発見 (固定パス): {java_bin}", log_cb) return str(java_bin) else: for base_str in ["/usr/lib/jvm", "/usr/local/lib/jvm", str(Path.home() / ".local/lib/jvm")]: base_p = Path(base_str) if not base_p.exists(): continue for java_bin in base_p.rglob("bin/java"): if java_bin.is_file(): major = get_java_major_version(str(java_bin)) if major is not None and major >= 21: log(f" Java 21+ 発見 (jvm dir): {java_bin}", log_cb) return str(java_bin) import platform as _platform if _platform.system() == "Darwin": mac_jvm = Path("/Library/Java/JavaVirtualMachines") if mac_jvm.exists(): for java_bin in mac_jvm.rglob("bin/java"): if java_bin.is_file(): major = get_java_major_version(str(java_bin)) if major is not None and major >= 21: log(f" Java 21+ 発見 (macOS): {java_bin}", log_cb) return str(java_bin) # 3. 自動インストール log("⚠ Java 21+ が見つかりません。自動インストールを試みます...", log_cb) installed = install_java(log_cb) if installed: major = get_java_major_version(installed) if major is not None and major >= 21: return installed log(f"⚠ インストール済みJavaが21未満 ({major})。手動インストールを推奨します。", log_cb) return None def get_java_version(java_path: str) -> Optional[str]: try: result = subprocess.run( [java_path, "-version"], capture_output=True, text=True, timeout=10 ) out = result.stderr or result.stdout m = re.search(r'version "([^"]+)"', out) return m.group(1) if m else out.strip().split("\n")[0] except Exception: return None def get_java_major_version(java_path: str) -> Optional[int]: ver = get_java_version(java_path) if not ver: return None # "21.0.3" or "1.8.0_xxx" m = re.match(r"(\d+)", ver) if not m: return None major = int(m.group(1)) if major == 1: # 旧形式 "1.8" → 8 m2 = re.match(r"1\.(\d+)", ver) return int(m2.group(1)) if m2 else 8 return major # ───────────────────────────────────────────── # Java インストール # ───────────────────────────────────────────── def install_java(log_cb=None) -> Optional[str]: system = platform.system() log("☕ Java をインストールします...", log_cb) if system == "Windows": try: subprocess.run( ["winget", "install", "--id", "Microsoft.OpenJDK.21", "--accept-source-agreements", "--accept-package-agreements", "-e"], check=True, timeout=300 ) log("✅ Java インストール完了 (winget)", log_cb) return find_java() except Exception as e: log(f"⚠ winget 失敗: {e}", log_cb) log("❌ Java の自動インストールに失敗しました。", log_cb) return None elif system == "Linux": for cmd, name in [ (["apt-get", "install", "-y", "openjdk-21-jdk"], "apt-get"), (["dnf", "install", "-y", "java-21-openjdk-devel"], "dnf"), (["pacman", "-S", "--noconfirm", "jdk21-openjdk"], "pacman"), ]: pkg_mgr = name.split("-")[0] if shutil.which(pkg_mgr): try: subprocess.run(["sudo"] + cmd, check=True, timeout=300) log(f"✅ Java インストール完了 ({name})", log_cb) return find_java() except Exception as e: log(f"⚠ {name} 失敗: {e}", log_cb) return None elif system == "Darwin": if shutil.which("brew"): try: subprocess.run(["brew", "install", "openjdk@21"], check=True, timeout=300) log("✅ Java インストール完了 (brew)", log_cb) return find_java() except Exception as e: log(f"⚠ brew 失敗: {e}", log_cb) return None return None # ───────────────────────────────────────────── # JAR ダウンロード # ───────────────────────────────────────────── PAPER_API = "https://api.papermc.io/v2/projects/paper" VELOCITY_API = "https://api.papermc.io/v2/projects/velocity" PURPUR_API = "https://api.purpurmc.org/v2/purpur" def _fetch_json(url: str) -> dict: req = urllib.request.Request(url, headers={"User-Agent": "mc-server-builder/2.0"}) with urllib.request.urlopen(req, timeout=30) as r: return json.loads(r.read()) def get_mc_versions_paper() -> list: try: data = _fetch_json(PAPER_API) return list(reversed(data["versions"])) except Exception: return ["1.21.4", "1.21.1", "1.20.4", "1.20.1"] def get_mc_versions_purpur() -> list: try: data = _fetch_json(PURPUR_API) return list(reversed(data["versions"])) except Exception: return ["1.21.4", "1.21.1", "1.20.4", "1.20.1"] def get_mc_versions_spigot() -> list: # Spigotはビルドツール経由のため、主要バージョンを返す return ["1.21.4", "1.21.1", "1.20.6", "1.20.4", "1.20.1", "1.19.4", "1.18.2", "1.17.1", "1.16.5"] def get_all_mc_versions() -> list: versions = get_mc_versions_paper() return versions if versions else ["1.21.4", "1.21.1", "1.20.4", "1.20.1"] def get_latest_paper_build(mc_version: str) -> tuple: data = _fetch_json(f"{PAPER_API}/versions/{mc_version}") build = data["builds"][-1] return mc_version, build def download_paper(dest_dir: Path, mc_version: str, log_cb=None) -> Path: mc_ver, build = get_latest_paper_build(mc_version) log(f"📥 Paper {mc_ver} build {build} をダウンロード中...", log_cb) filename = f"paper-{mc_ver}-{build}.jar" url = f"{PAPER_API}/versions/{mc_ver}/builds/{build}/downloads/{filename}" dest = dest_dir / "server.jar" _download_file(url, dest, log_cb) log(f"✅ Paper ダウンロード完了: {dest}", log_cb) return dest def get_latest_purpur_build(mc_version: str) -> tuple: data = _fetch_json(f"{PURPUR_API}/{mc_version}") build = data["builds"]["latest"] return mc_version, build def download_purpur(dest_dir: Path, mc_version: str, log_cb=None) -> Path: mc_ver, build = get_latest_purpur_build(mc_version) log(f"📥 Purpur {mc_ver} build {build} をダウンロード中...", log_cb) url = f"{PURPUR_API}/{mc_ver}/{build}/download" dest = dest_dir / "server.jar" _download_file(url, dest, log_cb) log(f"✅ Purpur ダウンロード完了: {dest}", log_cb) return dest def download_spigot(dest_dir: Path, mc_version: str, log_cb=None) -> Path: """BuildToolsを使ってSpigotをビルド""" log(f"📥 Spigot {mc_version} をビルド中 (BuildTools)...", log_cb) bt_url = "https://hub.spigotmc.org/jenkins/job/BuildTools/lastSuccessfulBuild/artifact/target/BuildTools.jar" bt_jar = dest_dir / "BuildTools.jar" _download_file(bt_url, bt_jar, log_cb) log("🔧 BuildTools 実行中(数分かかる場合があります)...", log_cb) result = subprocess.run( ["java", "-jar", str(bt_jar), "--rev", mc_version, "--nogui"], cwd=str(dest_dir), capture_output=True, text=True, timeout=600 ) if result.returncode != 0: raise RuntimeError(f"BuildTools 失敗:\n{result.stderr[-500:]}") # spigot-x.x.x.jar を探す jars = list(dest_dir.glob(f"spigot-{mc_version}*.jar")) if not jars: jars = list(dest_dir.glob("spigot-*.jar")) if not jars: raise RuntimeError("Spigot jar が見つかりません") dest = dest_dir / "server.jar" shutil.copy(jars[0], dest) log(f"✅ Spigot ビルド完了: {dest}", log_cb) return dest def download_server_jar(dest_dir: Path, backend: "BackendServer", log_cb=None) -> Path: mc_ver = backend.mc_version if mc_ver == "latest": mc_ver = get_all_mc_versions()[0] if backend.server_type == "purpur": return download_purpur(dest_dir, mc_ver, log_cb) elif backend.server_type == "spigot": return download_spigot(dest_dir, mc_ver, log_cb) else: return download_paper(dest_dir, mc_ver, log_cb) def get_latest_velocity_build() -> tuple: data = _fetch_json(VELOCITY_API) version = data["versions"][-1] builds_data = _fetch_json(f"{VELOCITY_API}/versions/{version}") build = builds_data["builds"][-1] return version, build def download_velocity(dest_dir: Path, log_cb=None) -> Path: ver, build = get_latest_velocity_build() log(f"📥 Velocity {ver} build {build} をダウンロード中...", log_cb) filename = f"velocity-{ver}-{build}.jar" url = f"{VELOCITY_API}/versions/{ver}/builds/{build}/downloads/{filename}" dest = dest_dir / "velocity.jar" _download_file(url, dest, log_cb) log(f"✅ Velocity ダウンロード完了: {dest}", log_cb) return dest def _download_file(url: str, dest: Path, log_cb=None): dest.parent.mkdir(parents=True, exist_ok=True) req = urllib.request.Request(url, headers={"User-Agent": "mc-server-builder/2.0"}) with urllib.request.urlopen(req, timeout=120) as response: total = int(response.headers.get("Content-Length", 0)) downloaded = 0 chunk = 65536 with open(dest, "wb") as f: while True: data = response.read(chunk) if not data: break f.write(data) downloaded += len(data) if total and log_cb: pct = downloaded * 100 // total log_cb(f" {pct}% ({downloaded // 1024} KB / {total // 1024} KB)") # ───────────────────────────────────────────── # プラグインダウンロード # ───────────────────────────────────────────── # 必須プラグイン(自動インストール) REQUIRED_PLUGINS = [] def get_modrinth_plugins(mc_version: str, query: str = "", limit: int = 20) -> list: """Modrinth APIからプラグインを検索""" facets = json.dumps([ ["project_type:plugin"], [f"versions:{mc_version}"], ["categories:bukkit"], ]) params = urllib.parse.urlencode({ "query": query, "facets": facets, "limit": limit, "index": "downloads", }) url = f"https://api.modrinth.com/v2/search?{params}" try: req = urllib.request.Request(url, headers={"User-Agent": "mc-server-builder/2.0"}) with urllib.request.urlopen(req, timeout=15) as r: data = json.loads(r.read()) results = [] for hit in data.get("hits", []): results.append({ "name": hit.get("title", ""), "desc": hit.get("description", ""), "downloads": hit.get("downloads", 0), "slug": hit.get("slug", ""), "source": "Modrinth", "project_id": hit.get("project_id", ""), "icon_url": hit.get("icon_url", ""), }) return results except Exception as e: print(f"Modrinth検索エラー: {e}") return [] def get_hangar_plugins(mc_version: str, query: str = "", limit: int = 20) -> list: """Hangar (PaperMC) APIからプラグインを検索""" params = urllib.parse.urlencode({ "query": query, "platform": "PAPER", "platformVersion": mc_version, "limit": limit, "sort": "DOWNLOADS", }) url = f"https://hangar.papermc.io/api/v1/projects?{params}" try: req = urllib.request.Request(url, headers={"User-Agent": "mc-server-builder/2.0"}) with urllib.request.urlopen(req, timeout=15) as r: data = json.loads(r.read()) results = [] for p in data.get("result", []): stats = p.get("stats", {}) results.append({ "name": p.get("name", ""), "desc": p.get("description", ""), "downloads": stats.get("downloads", 0), "slug": p.get("namespace", {}).get("slug", ""), "source": "Hangar", "project_id": p.get("namespace", {}).get("slug", ""), }) return results except Exception as e: print(f"Hangar検索エラー: {e}") return [] def get_modrinth_download_url(project_id: str, mc_version: str) -> tuple: """Modrinthのプロジェクトから最新バージョンのダウンロードURLとバージョン名を取得 Returns: (url, version_name) or (None, None) """ try: url = f"https://api.modrinth.com/v2/project/{project_id}/version" params = urllib.parse.urlencode({ "game_versions": json.dumps([mc_version]), "loaders": json.dumps(["bukkit", "spigot", "paper", "purpur"]), }) req = urllib.request.Request( f"{url}?{params}", headers={"User-Agent": "mc-server-builder/2.0"} ) with urllib.request.urlopen(req, timeout=15) as r: versions = json.loads(r.read()) if versions: ver = versions[0] version_name = ver.get("version_number", ver.get("name", "")) files = ver.get("files", []) primary = next((f for f in files if f.get("primary")), files[0] if files else None) dl_url = primary["url"] if primary else None return dl_url, version_name except Exception: pass return None, None def get_hangar_download_url(slug: str, mc_version: str) -> tuple: """HangarのプロジェクトからダウンロードURLとバージョン名を取得 Returns: (url, version_name) or (None, None) """ try: url = f"https://hangar.papermc.io/api/v1/projects/{slug}/latestrelease" req = urllib.request.Request(url, headers={"User-Agent": "mc-server-builder/2.0"}) with urllib.request.urlopen(req, timeout=15) as r: version = r.read().decode().strip().strip('"') dl_url = f"https://hangar.papermc.io/api/v1/projects/{slug}/versions/{version}/PAPER/download" return dl_url, version except Exception: pass return None, None def download_plugin(plugin_info: dict, dest_dir: Path, mc_version: str, log_cb=None) -> bool: """プラグインをダウンロードしてpluginsフォルダへ。バージョン情報をレジストリに保存。""" plugins_dir = dest_dir / "plugins" plugins_dir.mkdir(exist_ok=True) name = plugin_info["name"] source = plugin_info.get("source", "") project_id = plugin_info.get("project_id", "") try: if source == "Modrinth": url, version_name = get_modrinth_download_url(project_id, mc_version) elif source == "Hangar": url, version_name = get_hangar_download_url(project_id, mc_version) else: url = plugin_info.get("url") version_name = plugin_info.get("version", "") if not url: log(f"⚠ {name}: ダウンロードURL取得失敗", log_cb) return False log(f"📥 {name} {version_name} をダウンロード中...", log_cb) dest = plugins_dir / f"{name}.jar" _download_file(url, dest, log_cb) log(f"✅ {name} {version_name} インストール完了", log_cb) # プラグインレジストリに記録 save_plugin_registry_entry(dest_dir, { "name": name, "version": version_name, "source": source, "project_id": project_id, "mc_version": mc_version, "download_url": url, }) return True except Exception as e: log(f"❌ {name} ダウンロード失敗: {e}", log_cb) return False def download_required_plugins(dest_dir: Path, mc_version: str, log_cb=None): """ViaVersion, TABなど必須プラグインを自動インストール""" plugins_dir = dest_dir / "plugins" plugins_dir.mkdir(exist_ok=True) for plugin in REQUIRED_PLUGINS: name = plugin["name"] url = plugin["url"] dest = plugins_dir / f"{name}.jar" if dest.exists(): log(f"⏭ {name} は既にインストール済みです", log_cb) continue try: log(f"📥 必須プラグイン {name} をダウンロード中...", log_cb) _download_file(url, dest, log_cb) log(f"✅ {name} インストール完了", log_cb) except Exception as e: log(f"⚠ {name} ダウンロード失敗(手動インストールを推奨): {e}", log_cb) # ───────────────────────────────────────────── # プラグインレジストリ(インストール済みプラグイン管理) # ───────────────────────────────────────────── PLUGIN_REGISTRY_PATH = Path(os.environ.get("APPDATA", str(Path.home())) if IS_WINDOWS else str(Path.home() / ".config")) / "mc_network_builder" / "plugin_registry.json" def load_plugin_registry() -> dict: """レジストリ読み込み。形式: {server_dir: {plugin_name: {version, source, project_id, mc_version, download_url}}}""" if not PLUGIN_REGISTRY_PATH.exists(): return {} try: return json.loads(PLUGIN_REGISTRY_PATH.read_text(encoding="utf-8")) except Exception: return {} def save_plugin_registry(registry: dict): PLUGIN_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True) PLUGIN_REGISTRY_PATH.write_text(json.dumps(registry, ensure_ascii=False, indent=2), encoding="utf-8") def save_plugin_registry_entry(server_dir: Path, plugin_info: dict): """1つのプラグイン情報をレジストリに追加・更新""" registry = load_plugin_registry() key = str(server_dir.resolve()) if key not in registry: registry[key] = {} registry[key][plugin_info["name"]] = { "version": plugin_info.get("version", ""), "source": plugin_info.get("source", ""), "project_id": plugin_info.get("project_id", ""), "mc_version": plugin_info.get("mc_version", ""), "download_url": plugin_info.get("download_url", ""), } save_plugin_registry(registry) def check_plugin_updates(server_dir: Path, log_cb=None) -> list: """レジストリを参照して更新があるプラグインのリストを返す Returns: [{"name", "current_version", "new_version", "source", "project_id", "mc_version", "download_url"}, ...] """ registry = load_plugin_registry() key = str(server_dir.resolve()) plugins = registry.get(key, {}) updates = [] for name, info in plugins.items(): source = info.get("source", "") project_id = info.get("project_id", "") mc_version = info.get("mc_version", "1.21.4") current_ver = info.get("version", "") try: if source == "Modrinth": new_url, new_ver = get_modrinth_download_url(project_id, mc_version) elif source == "Hangar": new_url, new_ver = get_hangar_download_url(project_id, mc_version) else: continue if new_ver and new_url and new_ver != current_ver: updates.append({ "name": name, "current_version": current_ver, "new_version": new_ver, "source": source, "project_id": project_id, "mc_version": mc_version, "download_url": new_url, }) except Exception: continue return updates def apply_plugin_update(server_dir: Path, update_info: dict, log_cb=None) -> bool: """プラグインを新バージョンに更新してレジストリを更新""" name = update_info["name"] url = update_info["download_url"] new_ver = update_info["new_version"] plugins_dir = server_dir / "plugins" plugins_dir.mkdir(exist_ok=True) dest = plugins_dir / f"{name}.jar" try: log(f"📥 {name} を {update_info['current_version']} → {new_ver} に更新中...", log_cb) _download_file(url, dest, log_cb) log(f"✅ {name} {new_ver} 更新完了", log_cb) save_plugin_registry_entry(server_dir, { "name": name, "version": new_ver, "source": update_info["source"], "project_id": update_info["project_id"], "mc_version": update_info["mc_version"], "download_url": url, }) return True except Exception as e: log(f"❌ {name} 更新失敗: {e}", log_cb) return False # ───────────────────────────────────────────── # 設定ファイル生成 # ───────────────────────────────────────────── def write_server_properties(server_dir: Path, backend: BackendServer): rcon_port = backend.port + 10000 # ポートが被らないように +10000 rcon_pass = backend.rcon_password or generate_secret(16) backend.rcon_password = rcon_pass # dataclassに書き戻す content = f"""#Minecraft server properties enable-jmx-monitoring=false rcon.port={rcon_port} level-seed= gamemode={backend.gamemode} enable-command-block=false enable-query=false generator-settings={{}} enforce-secure-profile=false level-name=world motd={backend.motd} query.port={backend.port} pvp=true generate-structures=true max-chained-neighbor-updates=1000000 difficulty={backend.difficulty} network-compression-threshold=256 max-tick-time=60000 require-resource-pack=false use-native-transport=true max-players={backend.max_players} online-mode={str(backend.online_mode).lower()} enable-status=true allow-flight=false broadcast-rcon-to-ops=true view-distance=10 server-ip= allow-nether=true server-port={backend.port} enable-rcon=true rcon.password={rcon_pass} sync-chunk-writes=true op-permission-level=4 prevent-proxy-connections=false hide-online-players=false entity-broadcast-range-percentage=100 simulation-distance=10 player-idle-timeout=0 force-gamemode=false rate-limit=0 hardcore=false white-list=false broadcast-console-to-ops=true spawn-npcs=true spawn-animals=true log-ips=true function-permission-level=2 initial-enabled-packs=vanilla level-type=minecraft\\:normal spawn-monsters=true enforce-whitelist=false spawn-protection=16 max-world-size=29999984 """ (server_dir / "server.properties").write_text(content) def write_eula(server_dir: Path): (server_dir / "eula.txt").write_text("eula=true\n") def write_paper_global_yml(server_dir: Path, forwarding_mode: str, forwarding_secret: str): config_dir = server_dir / "config" config_dir.mkdir(exist_ok=True) content = f"""_version: 28 proxies: bungee-cord: online-mode: false proxy-protocol: false velocity: enabled: {"true" if forwarding_mode == "modern" else "false"} online-mode: true secret: '{forwarding_secret}' """ (config_dir / "paper-global.yml").write_text(content) def write_spigot_yml(server_dir: Path, backend: BackendServer = None): """spigot.ymlを書き出す。backendパラメータで設定を変化させる""" view_distance = 10 entity_activation = {"animals": 32, "monsters": 32, "raiders": 48, "misc": 16} content = f"""settings: bungeecord: false restart-on-crash: true restart-script: ./start.sh user-cache-size: 1000 save-user-cache-on-stop-only: false sample-count: 12 debug: false timeout: 60 late-bind: false log-villager-deaths: true log-named-deaths: true moved-wrongly-threshold: 0.0625 moved-too-quickly-multiplier: 10.0 item-dirty-ticks: 20 attribute: maxHealth: max: 2048.0 movementSpeed: max: 2048.0 attackDamage: max: 2048.0 netty-threads: 4 player-shuffle: 0 advancements: disable-saving: false disabled: - minecraft:story/disabled_example commands: replace-commands: - setblock - summon - testforblock - tellraw log: true tab-complete: 0 send-namespaced: true spam-exclusions: - /skill messages: whitelist: You are not whitelisted on this server! unknown-command: Unknown command. Type "/help" for help. server-full: The server is full! outdated-client: Please use {{0}} outdated-server: I'm still on {{0}} restart: Server is restarting stats: disable-saving: false forced-stats: {{}} world-settings: default: below-zero-generation-in-existing-chunks: true wither-struct-recipe: false end-portal-sound-radius: 0 verbose: false merge-radius: item: 2.5 exp: 3.0 entity-activation-range: animals: {entity_activation['animals']} monsters: {entity_activation['monsters']} raiders: {entity_activation['raiders']} misc: {entity_activation['misc']} water: 16 flying-monsters: 32 entity-tracking-range: players: 48 animals: 48 monsters: 48 misc: 32 display: 128 other: 64 ticks-per: hopper-transfer: 8 hopper-check: 1 hopper-amount: 1 hopper-can-load-chunks: false max-tnt-per-tick: 100 max-tick-time: tile: 50 entity: 50 """ (server_dir / "spigot.yml").write_text(content) def write_velocity_toml(velocity_dir: Path, cfg: NetworkConfig): backends_toml = "" for b in cfg.backends: backends_toml += f' {b.name} = "127.0.0.1:{b.port}"\n' try_servers = [cfg.backends[0].name] if cfg.backends else [] try_line = ", ".join(f'"{s}"' for s in try_servers) content = f"""# velocity.toml (自動生成) config-version = "2.7" bind = "0.0.0.0:{cfg.velocity_port}" motd = "<#09add3>Velocity Network" show-max-players = 500 online-mode = true force-key-authentication = true prevent-client-proxy-connections = false player-info-forwarding-mode = "{cfg.forwarding_mode.upper()}" forwarding-secret-file = "forwarding.secret" announce-forge = false kick-existing-players = false ping-passthrough = "DISABLED" enable-player-address-logging = true [servers] {backends_toml} try = [{try_line}] [forced-hosts] [advanced] compression-threshold = 256 compression-level = -1 login-ratelimit = 3000 connection-timeout = 5000 read-timeout = 30000 haproxy-protocol = false tcp-fast-open = false bungee-plugin-message-channel = true show-ping-requests = false failover-on-unexpected-server-disconnect = true announce-proxy-commands = true log-command-executions = false log-player-connections = true accepts-transfers = false [query] enabled = false port = 25577 [metrics] enabled = true id = "" log-failure = false """ (velocity_dir / "velocity.toml").write_text(content) (velocity_dir / "forwarding.secret").write_text(cfg.forwarding_secret) # ───────────────────────────────────────────── # メインセットアップ処理 # ───────────────────────────────────────────── def setup_network(cfg: NetworkConfig, log_cb=None): base = Path(cfg.base_dir).resolve() base.mkdir(parents=True, exist_ok=True) log(f"📁 ベースディレクトリ: {base}", log_cb) java = find_java() if java: ver = get_java_version(java) major = get_java_major_version(java) log(f"✅ Java 検出: {java} ({ver})", log_cb) if major is not None and major < 21: log(f"⚠ Java {major} が検出されました。Java 21以上を探します...", log_cb) java21 = _find_java21_or_install(log_cb) if java21: java = java21 ver = get_java_version(java) log(f"✅ Java 21+ に切り替えました: {java} ({ver})", log_cb) else: raise RuntimeError( f"Java 21以上が見つかりませんでした(現在: Java {major})。\n" "手動で Java 21以上をインストールして PATH に追加してください。\n" "https://adoptium.net/" ) else: log("⚠ Java が見つかりません。インストールを試みます...", log_cb) java = _find_java21_or_install(log_cb) if not java: raise RuntimeError( "Java 21以上のインストールに失敗しました。\n" "手動でインストールしてください: https://adoptium.net/" ) ver = get_java_version(java) log(f"✅ Java: {java} ({ver})", log_cb) cfg.java_path = java if not cfg.forwarding_secret: cfg.forwarding_secret = generate_secret() log(f"🔑 Forwarding Secret 生成: {cfg.forwarding_secret[:8]}...", log_cb) vel_dir = base / "velocity" vel_dir.mkdir(exist_ok=True) log("\n🚀 Velocity セットアップ中...", log_cb) download_velocity(vel_dir, log_cb) write_velocity_toml(vel_dir, cfg) # Velocityの必須プラグイン log("📦 Velocity プラグインをインストール中...", log_cb) download_required_plugins(vel_dir, "3", log_cb) # Velocity plugins log("✅ velocity.toml / forwarding.secret 生成完了", log_cb) log("\n📦 バックエンドサーバーをセットアップ中...", log_cb) for backend in cfg.backends: log(f"\n [{backend.name}] type={backend.server_type} version={backend.mc_version} port={backend.port}", log_cb) bd = base / backend.name bd.mkdir(exist_ok=True) download_server_jar(bd, backend, log_cb) write_eula(bd) write_server_properties(bd, backend) if backend.server_type in ("paper", "purpur"): write_paper_global_yml(bd, cfg.forwarding_mode, cfg.forwarding_secret) write_spigot_yml(bd, backend) # 必須プラグインのインストール mc_ver = backend.mc_version if backend.mc_version != "latest" else get_all_mc_versions()[0] log(f" 📦 {backend.name} の必須プラグインをインストール中...", log_cb) download_required_plugins(bd, mc_ver, log_cb) log(f" ✅ {backend.name} セットアップ完了", log_cb) log("\n" + "="*50, log_cb) log("🎉 セットアップ完了!", log_cb) log(f"📂 ディレクトリ: {base}", log_cb) log(f"🌐 Velocity (proxy): localhost:{cfg.velocity_port}", log_cb) for b in cfg.backends: log(f" └ {b.name} ({b.server_type}): localhost:{b.port}", log_cb) log("="*50, log_cb) save_config(cfg) log("💾 設定を保存しました", log_cb) # ───────────────────────────────────────────── # サーバープロセス管理 # ───────────────────────────────────────────── class ServerProcess: """1つのサーバープロセスを管理するクラス(自動再起動・Watchdog付き)""" def __init__(self, name: str, work_dir: Path, java: str, jar: str, mem_min: str, mem_max: str, extra_flags: list = None, output_callback=None, auto_restart: bool = False, watchdog_timeout: int = 120, rcon_port: int = 0, rcon_password: str = ""): self.name = name self.work_dir = work_dir self.java = java self.jar = jar self.mem_min = mem_min self.mem_max = mem_max self.extra_flags = extra_flags or [] self.output_callback = output_callback self.auto_restart = auto_restart self.watchdog_timeout = watchdog_timeout self.rcon_port = rcon_port # 0 = RCON無効(Velocity等) self.rcon_password = rcon_password self.process: Optional[subprocess.Popen] = None self._reader_thread: Optional[threading.Thread] = None self._watchdog_thread: Optional[threading.Thread] = None self._last_output_time: float = 0.0 self._stopping: bool = False self.restart_count: int = 0 # 常時接続RCON self._rcon_sock = None # socket.socket | None self._rcon_lock = threading.Lock() def is_running(self) -> bool: return self.process is not None and self.process.poll() is None def build_command(self) -> list: is_velocity = (self.jar == "velocity.jar") if is_velocity: cmd = [ self.java, f"-Xms{self.mem_min}", f"-Xmx{self.mem_max}", "-XX:+UseG1GC", "-XX:G1HeapRegionSize=4M", "-XX:+UnlockExperimentalVMOptions", "-XX:+ParallelRefProcEnabled", "-XX:MaxGCPauseMillis=200", "-XX:InitiatingHeapOccupancyPercent=35", ] else: cmd = [ self.java, f"-Xms{self.mem_min}", f"-Xmx{self.mem_max}", "-XX:+UseG1GC", "-XX:+ParallelRefProcEnabled", "-XX:MaxGCPauseMillis=200", "-XX:+UnlockExperimentalVMOptions", "-XX:+DisableExplicitGC", "-XX:G1NewSizePercent=30", "-XX:G1MaxNewSizePercent=40", "-XX:G1HeapRegionSize=8M", "-XX:G1ReservePercent=20", "-XX:G1HeapWastePercent=5", "-XX:G1MixedGCCountTarget=4", "-XX:InitiatingHeapOccupancyPercent=15", "-XX:G1MixedGCLiveThresholdPercent=90", "-XX:SurvivorRatio=32", "-XX:+PerfDisableSharedMem", "-XX:MaxTenuringThreshold=1", "-Dusing.aikars.flags=https://mcflags.emc.gs", "-Daikars.new.flags=true", ] cmd += self.extra_flags cmd += ["-jar", self.jar, "nogui"] return cmd def _spawn(self): """内部: プロセスを生成してリーダースレッドを立ち上げる""" cmd = self.build_command() self._last_output_time = time.time() create_flags = subprocess.CREATE_NO_WINDOW if IS_WINDOWS else 0 self.process = subprocess.Popen( cmd, cwd=str(self.work_dir), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", bufsize=1, creationflags=create_flags, ) self._reader_thread = threading.Thread(target=self._read_output, daemon=True) self._reader_thread.start() def start(self): if self.is_running(): return self._stopping = False self._spawn() if self.output_callback: self.output_callback(f"▶ {self.name} 起動しました (PID: {self.process.pid})") # Watchdog スレッド (初回起動時のみ開始) if self.auto_restart and ( self._watchdog_thread is None or not self._watchdog_thread.is_alive() ): self._watchdog_thread = threading.Thread( target=self._watchdog_loop, daemon=True ) self._watchdog_thread.start() def _read_output(self): try: for line in self.process.stdout: line = line.rstrip() self._last_output_time = time.time() if line and self.output_callback: self.output_callback(line) except Exception: pass if self.output_callback: self.output_callback(f"■ {self.name} プロセス終了") # ── 常時接続 RCON ────────────────────────────────────── @staticmethod def _rcon_pack(req_id: int, pkt_type: int, body: str) -> bytes: import struct body_enc = body.encode("utf-8") + b"\x00\x00" length = 4 + 4 + len(body_enc) return struct.pack(" bool: """常時接続RCONソケットを(再)確立して認証する。成功→True""" import socket self._rcon_close() if not self.rcon_port or not self.rcon_password: return False try: s = socket.create_connection(("127.0.0.1", self.rcon_port), timeout=5) s.settimeout(5) s.sendall(self._rcon_pack(1, 3, self.rcon_password)) # SERVERDATA_AUTH raw = s.recv(4096) auth_id, _, _ = self._rcon_unpack(raw) if auth_id == -1: s.close() return False self._rcon_sock = s return True except Exception: return False def _rcon_close(self): """RCONソケットを閉じる""" with self._rcon_lock: if self._rcon_sock: try: self._rcon_sock.close() except Exception: pass self._rcon_sock = None def _rcon_ping(self) -> bool: """ 常時接続RCONで 'list' を送信してサーバー応答を確認する。 接続が切れていれば再接続を試みる。 RCON未設定なら常にTrue。 """ if not self.rcon_port or not self.rcon_password: return True with self._rcon_lock: # ソケットがなければ接続 if self._rcon_sock is None: if not self._rcon_connect(): return False try: self._rcon_sock.sendall(self._rcon_pack(2, 2, "list")) # SERVERDATA_EXECCOMMAND raw = self._rcon_sock.recv(4096) rid, _, _ = self._rcon_unpack(raw) return rid == 2 except Exception: # 切断 → 再接続して再試行 self._rcon_sock = None if self._rcon_connect(): try: self._rcon_sock.sendall(self._rcon_pack(2, 2, "list")) raw = self._rcon_sock.recv(4096) rid, _, _ = self._rcon_unpack(raw) return rid == 2 except Exception: self._rcon_sock = None return False def _watchdog_loop(self): """ プロセスの死亡を監視しつつ、バックエンドサーバーは RCON ping で 実際の応答を確認して自動再起動する。 ・RCON有効(バックエンドサーバー): プロセス死亡 or RCON失敗が watchdog_timeout 秒間連続したら再起動。 サーバー起動直後は RCON が受け付けるまで待つため、 最初の ping 成功まではタイムアウト判定しない。 ・RCON無効(Velocity等): プロセス死亡のみ監視。 """ POLL_INTERVAL = 10 # 監視間隔(秒) STARTUP_GRACE = 120 # 初回RCON接続成功までの猶予(秒) use_rcon = bool(self.rcon_port and self.rcon_password) rcon_ever_connected = not use_rcon # RCON未使用なら常にTrue扱い rcon_fail_since: float = 0.0 # 連続失敗開始時刻 while not self._stopping: time.sleep(POLL_INTERVAL) if self._stopping: break if not self.auto_restart: continue process_dead = not self.is_running() if process_dead: # プロセスが死んでいる → 即再起動 should_restart = True reason = "プロセス終了" elif use_rcon: # RCON で死活確認 ok = self._rcon_ping() now = time.time() if ok: rcon_ever_connected = True rcon_fail_since = 0.0 should_restart = False reason = "" else: if not rcon_ever_connected: # まだ一度も繋がっていない(起動中)→ 猶予期間内ならスキップ elapsed_since_spawn = now - self._last_output_time if elapsed_since_spawn < STARTUP_GRACE: should_restart = False reason = "" else: # 猶予超過: 起動に失敗している should_restart = True reason = f"起動タイムアウト ({STARTUP_GRACE}秒以内にRCON応答なし)" else: # 一度繋がった後の失敗 → タイムアウト判定 if rcon_fail_since == 0.0: rcon_fail_since = now elapsed = now - rcon_fail_since if elapsed >= self.watchdog_timeout: should_restart = True reason = f"RCON無応答 {int(elapsed)}秒" else: should_restart = False reason = "" else: # RCON無効 → プロセス死亡のみ(ここには process_dead=False で来る) should_restart = False reason = "" if should_restart and not self._stopping: self.restart_count += 1 if self.output_callback: self.output_callback( f"⚠ [{self.name}] {reason} → 自動再起動 #{self.restart_count}" ) try: if self.process and self.process.poll() is None: self.process.kill() self.process.wait(timeout=5) except Exception: pass time.sleep(3) if not self._stopping: # 再起動後は状態をリセット rcon_ever_connected = not use_rcon rcon_fail_since = 0.0 self._last_output_time = time.time() self._rcon_close() # 古いソケットを破棄 self._spawn() if self.output_callback: self.output_callback( f"▶ [{self.name}] 再起動完了 (PID: {self.process.pid})" ) def send_command(self, cmd: str): if self.is_running() and self.process.stdin: try: self.process.stdin.write(cmd + "\n") self.process.stdin.flush() except Exception: pass def stop(self): self._stopping = True self.auto_restart = False # watchdog が再起動しないように self._rcon_close() if not self.is_running(): return stop_cmd = "shutdown" if self.jar == "velocity.jar" else "stop" self.send_command(stop_cmd) try: self.process.wait(timeout=15) except subprocess.TimeoutExpired: self.process.kill() def kill(self): self._stopping = True if self.process: self.process.kill() # ───────────────────────────────────────────── # CUI インターフェース(起動管理付き) # ───────────────────────────────────────────── def cui_main(): print("="*60) print(" 🎮 Minecraft ネットワーク自動セットアップ (CUI)") print("="*60) saved = load_config() if saved: print(f"\n💾 前回の設定が見つかりました (ベース: {saved.base_dir})") choice = input(" [1] この設定を使う [2] 新規セットアップ: ").strip() if choice == "1": cui_manage(saved) return base_dir = input("\nインストール先ディレクトリ [./mc_network]: ").strip() or "./mc_network" vel_in = input("Velocity ポート [25565]: ").strip() velocity_port = int(vel_in) if vel_in.isdigit() else 25565 print("フォワーディングモード: modern / legacy / none") fwd_mode = input(" モード [modern]: ").strip().lower() or "modern" if fwd_mode not in ("modern", "legacy", "none"): fwd_mode = "modern" print("\n利用可能なMCバージョン取得中...") versions = get_all_mc_versions() print(f" 例: {', '.join(versions[:5])}") backends = [] print("\nバックエンドサーバーを追加します(名前を空エンターで終了)") next_port = 25566 while True: print(f"\n --- サーバー {len(backends)+1} ---") name = input(" サーバー名 [空で終了]: ").strip() if not name: break port_in = input(f" ポート [{next_port}]: ").strip() port = int(port_in) if port_in.isdigit() else next_port print(" サーバータイプ: paper / purpur / spigot") stype = input(" タイプ [paper]: ").strip().lower() or "paper" if stype not in ("paper", "purpur", "spigot"): stype = "paper" mc_ver = input(f" MCバージョン [latest ({versions[0]})]: ").strip() or "latest" motd = input(" MOTD [A Minecraft Server]: ").strip() or "A Minecraft Server" gamemode = input(" ゲームモード [survival]: ").strip() or "survival" difficulty = input(" 難易度 [normal]: ").strip() or "normal" max_p_in = input(" 最大プレイヤー数 [100]: ").strip() max_players = int(max_p_in) if max_p_in.isdigit() else 100 backends.append(BackendServer( name=name, port=port, server_type=stype, mc_version=mc_ver, motd=motd, gamemode=gamemode, difficulty=difficulty, max_players=max_players, )) next_port = port + 1 if not backends: backends = [BackendServer(name="lobby", port=25566, motd="Lobby Server")] cfg = NetworkConfig( velocity_port=velocity_port, forwarding_mode=fwd_mode, backends=backends, base_dir=base_dir, ) print("\nセットアップを開始します...") try: setup_network(cfg) except Exception as e: print(f"❌ エラー: {e}") sys.exit(1) cui_manage(cfg) def cui_manage(cfg: NetworkConfig): """CUIのサーバー起動管理メニュー""" base = Path(cfg.base_dir).resolve() processes = {} servers = [("velocity", base / "velocity", "velocity.jar", cfg.velocity_mem_min, cfg.velocity_mem_max, 0, "")] for b in cfg.backends: servers.append((b.name, base / b.name, "server.jar", b.mem_min, b.mem_max, b.port + 10000, b.rcon_password)) # ── 起動時プラグイン更新チェック ── print("\n🔌 プラグイン更新チェック中...") server_dirs = [("velocity", base / "velocity")] for b in cfg.backends: server_dirs.append((b.name, base / b.name)) all_updates = {} for srv_name, srv_dir in server_dirs: updates = check_plugin_updates(srv_dir) if updates: all_updates[srv_name] = updates if all_updates: print("\n" + "="*40) print(" 🔔 プラグイン更新があります") print("="*40) for srv_name, updates in all_updates.items(): print(f"\n [{srv_name}]") for u in updates: print(f" {u['name']} {u['current_version']} → {u['new_version']}") print() ans = input("すべて更新しますか? (y/n): ").strip().lower() if ans == "y": for srv_name, updates in all_updates.items(): srv_dir = dict(server_dirs).get(srv_name) if not srv_dir: continue for u in updates: yn = input(f" {u['name']} {u['current_version']} → {u['new_version']} を更新しますか? (y/n): ").strip().lower() if yn == "y": apply_plugin_update(srv_dir, u) print("✅ 更新完了") else: print("⏭ プラグイン更新をスキップしました") else: print("✅ すべてのプラグインは最新です") def start_server(name, wdir, jar, mn, mx, rcon_port=0, rcon_pass=""): if name in processes and processes[name].is_running(): print(f"⚠ {name} は起動済みです") return proc = ServerProcess( name=name, work_dir=wdir, java=cfg.java_path, jar=jar, mem_min=mn, mem_max=mx, output_callback=lambda msg: print(f"[{name}] {msg}"), auto_restart=cfg.auto_restart_enabled, watchdog_timeout=cfg.watchdog_timeout, rcon_port=rcon_port, rcon_password=rcon_pass, ) proc.start() processes[name] = proc # autostart_servers が有効なら即全起動 if cfg.autostart_servers or "--autostart" in sys.argv: print("\n🚀 全サーバーを自動起動中...") for name, wdir, jar, mn, mx, rp, rpw in servers: start_server(name, wdir, jar, mn, mx, rp, rpw) while True: print("\n" + "="*40) print(" サーバー管理") print("="*40) for name, wdir, jar, mn, mx, rp, rpw in servers: running = name in processes and processes[name].is_running() status = "● 起動中" if running else "○ 停止中" print(f" {name:15s} {status}") print("\n[s] 全起動 [S] 全停止 [q] 終了") print("[番号] 個別操作(1=velocity, 2=最初のbackend...)") cmd = input("> ").strip().lower() if cmd == "q": print("全サーバーを停止します...") for p in processes.values(): if p.is_running(): p.stop() break elif cmd == "s": for name, wdir, jar, mn, mx, rp, rpw in servers: start_server(name, wdir, jar, mn, mx, rp, rpw) elif cmd == "S": for p in processes.values(): if p.is_running(): threading.Thread(target=p.stop, daemon=True).start() elif cmd.isdigit(): idx = int(cmd) - 1 if 0 <= idx < len(servers): name, wdir, jar, mn, mx, rp, rpw = servers[idx] running = name in processes and processes[name].is_running() if running: print(f"[t] 停止 [c] コマンド送信") sub = input("> ").strip().lower() if sub == "t": threading.Thread(target=processes[name].stop, daemon=True).start() elif sub == "c": c = input("コマンド: ").strip() processes[name].send_command(c) else: start_server(name, wdir, jar, mn, mx, rp, rpw) # ───────────────────────────────────────────── # GUI インターフェース # ───────────────────────────────────────────── def gui_main(): import tkinter as tk from tkinter import ttk, filedialog, messagebox, scrolledtext # ══════════════════════════════════════════ # カラーパレット(ダークネイビー × エメラルド) # ══════════════════════════════════════════ BG = "#0D1117" # ページ背景 ディープブラック BG2 = "#161B22" # カード背景 BG3 = "#1C2333" # セクション背景 BG4 = "#0D1117" # ヘッダー帯 ACCENT = "#00D4AA" # エメラルドグリーン ACCENT2 = "#00F5C3" # ライトグリーン ACCENT3 = "#00B894" # ダークグリーン FG = "#E6EDF3" # メインテキスト FG_DIM = "#8B949E" # サブテキスト FG_MUTED= "#484F58" # ミュートテキスト SUCCESS = "#3FB950" # 緑 WARNING = "#D29922" # 黄 DANGER = "#F85149" # 赤 BORDER = "#30363D" # ボーダー CONSOLE_BG = "#010409" # コンソール背景 CONSOLE_FG = "#00D4AA" # コンソールテキスト # ── ルートウィンドウ ── root = tk.Tk() root.title("⛏ Minecraft Network Manager") root.geometry("1160x860") root.minsize(940, 680) root.configure(bg=BG) # ── スタイル ── style = ttk.Style() style.theme_use("clam") style.configure("TFrame", background=BG3) style.configure("Card.TFrame", background=BG2, relief="flat") style.configure("TLabel", background=BG3, foreground=FG, font=("Segoe UI", 10)) style.configure("Title.TLabel", background=BG3, foreground=ACCENT, font=("Segoe UI", 13, "bold")) style.configure("Dim.TLabel", background=BG3, foreground=FG_DIM, font=("Segoe UI", 9)) style.configure("Card.TLabel", background=BG2, foreground=FG, font=("Segoe UI", 10)) style.configure("TNotebook", background=BG, borderwidth=0, tabmargins=[0, 0, 0, 0]) style.configure("TNotebook.Tab", background=BG3, foreground=FG_DIM, font=("Segoe UI", 10, "bold"), padding=(18, 8)) style.map("TNotebook.Tab", background=[("selected", BG2)], foreground=[("selected", ACCENT)]) style.configure("TButton", font=("Segoe UI", 10), padding=5, background=BG3, foreground=FG, borderwidth=1, relief="flat") style.map("TButton", background=[("active", BORDER)]) style.configure("Run.TButton", background=ACCENT3, foreground="#FFFFFF", font=("Segoe UI", 11, "bold"), padding=(14, 8), borderwidth=0, relief="flat") style.map("Run.TButton", background=[("active", ACCENT), ("disabled", FG_MUTED)]) style.configure("Start.TButton", background=SUCCESS, foreground="#FFFFFF", font=("Segoe UI", 10, "bold"), padding=(10, 6), borderwidth=0, relief="flat") style.map("Start.TButton", background=[("active", "#2EA043"), ("disabled", FG_MUTED)]) style.configure("Stop.TButton", background=DANGER, foreground="#FFFFFF", font=("Segoe UI", 10, "bold"), padding=(10, 6), borderwidth=0, relief="flat") style.map("Stop.TButton", background=[("active", "#DA3633")]) style.configure("Add.TButton", background=BG3, foreground=ACCENT, font=("Segoe UI", 9, "bold"), padding=(8, 4), borderwidth=1, relief="flat") style.map("Add.TButton", background=[("active", BORDER)]) style.configure("Del.TButton", background=BG3, foreground=DANGER, font=("Segoe UI", 9, "bold"), padding=(4, 2), borderwidth=1, relief="flat") style.map("Del.TButton", background=[("active", "#2D1416")]) style.configure("Plugin.TButton", background=BG3, foreground=ACCENT, font=("Segoe UI", 9, "bold"), padding=(8, 4), borderwidth=1, relief="flat") style.map("Plugin.TButton", background=[("active", BORDER)]) style.configure("TEntry", fieldbackground=BG2, foreground=FG, insertcolor=ACCENT, font=("Segoe UI", 10), borderwidth=1, relief="solid", bordercolor=BORDER, selectbackground=ACCENT3) style.configure("TCombobox", fieldbackground=BG2, foreground=FG, font=("Segoe UI", 10), selectbackground=ACCENT3, borderwidth=1, relief="solid") style.map("TCombobox", fieldbackground=[("readonly", BG2)], foreground=[("readonly", FG)]) style.configure("TLabelframe", background=BG3, foreground=ACCENT, borderwidth=1, relief="solid", bordercolor=BORDER) style.configure("TLabelframe.Label", background=BG3, foreground=ACCENT, font=("Segoe UI", 10, "bold")) style.configure("TCheckbutton", background=BG3, foreground=FG, font=("Segoe UI", 10)) style.map("TCheckbutton", background=[("active", BG3)], foreground=[("active", ACCENT)]) style.configure("TScrollbar", background=BG3, troughcolor=BG, borderwidth=0, arrowcolor=FG_DIM, gripcount=0) style.map("TScrollbar", background=[("active", BORDER)]) style.configure("Horizontal.TProgressbar", background=ACCENT, troughcolor=BG3, borderwidth=0) # ── ヘッダー ── hdr = tk.Frame(root, bg=BG2, height=64) hdr.pack(fill="x") hdr.pack_propagate(False) # ヘッダー左側アクセントライン accent_bar = tk.Frame(hdr, bg=ACCENT, width=4) accent_bar.pack(side="left", fill="y") tk.Label(hdr, text="⛏", bg=BG2, fg=ACCENT, font=("Segoe UI", 20)).pack(side="left", padx=(16, 6), pady=10) tk.Label(hdr, text="Minecraft Network Manager", bg=BG2, fg=FG, font=("Segoe UI", 16, "bold")).pack(side="left", pady=10) tk.Label(hdr, text=" Velocity + Paper / Purpur / Spigot", bg=BG2, fg=FG_DIM, font=("Segoe UI", 9)).pack(side="left", pady=(20, 0)) # バージョンバッジ + 更新ボタンエリア hdr_right = tk.Frame(hdr, bg=BG2) hdr_right.pack(side="right", padx=16, pady=0) ver_lbl = tk.Label(hdr_right, text=f" v{CURRENT_VERSION} ", bg=ACCENT3, fg="#FFFFFF", font=("Segoe UI", 8, "bold")) ver_lbl.pack(side="top", pady=(14, 2)) update_btn = tk.Label(hdr_right, text="", bg=BG2, fg=WARNING, font=("Segoe UI", 8, "bold"), cursor="hand2") update_btn.pack(side="top") # 最新バージョン情報を保持 _update_info: list = [None] # [0] = {"version": ..., "url": ...} or None def _do_apply_update(): info = _update_info[0] if not info: return from tkinter import messagebox as mb if not mb.askyesno("アップデート", f"v{CURRENT_VERSION} → v{info['version']} にアップデートします。\n" "現在のファイルはバックアップされます。続行しますか?"): return # セットアップログに出力 def _log(msg): root.after(0, lambda m=msg: setup_log(m)) threading.Thread( target=lambda: apply_update(info["url"], _log), daemon=True ).start() def _show_update_badge(info: dict): """更新があるとき黄色バッジとボタンを表示する""" _update_info[0] = info update_btn.configure( text=f"🔔 v{info['version']} 利用可能", bg=BG2, fg=WARNING ) update_btn.bind("", lambda e: _do_apply_update()) def _check_update_bg(): """バックグラウンドで更新チェックして結果をUIに反映""" info = check_for_update() if info: root.after(0, lambda: _show_update_badge(info)) threading.Thread(target=_check_update_bg, daemon=True).start() # ── タブノートブック ── notebook = ttk.Notebook(root) notebook.pack(fill="both", expand=True, padx=12, pady=(8, 8)) # ═══════════════════════════════════════════════ # タブ1: セットアップ # ═══════════════════════════════════════════════ tab_setup = ttk.Frame(notebook) notebook.add(tab_setup, text=" ⚙ セットアップ ") # スクロール可能エリア setup_canvas = tk.Canvas(tab_setup, bg=BG3, highlightthickness=0) setup_scroll = ttk.Scrollbar(tab_setup, orient="vertical", command=setup_canvas.yview) setup_inner = ttk.Frame(setup_canvas) setup_inner.bind("", lambda e: setup_canvas.configure(scrollregion=setup_canvas.bbox("all"))) setup_canvas.create_window((0, 0), window=setup_inner, anchor="nw") setup_canvas.configure(yscrollcommand=setup_scroll.set) setup_canvas.pack(side="left", fill="both", expand=True) setup_scroll.pack(side="right", fill="y") def _on_canvas_mousewheel(event): setup_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") setup_canvas.bind("", _on_canvas_mousewheel) setup_inner.bind("", _on_canvas_mousewheel) pad = {"padx": 10, "pady": 5} # メモリ入力バリデーション (数字 + M/G のみ許可) — frm_vel_mem より前に定義 def _mem_vcmd(action, new_val): """validatecommand: 数字と M/m/G/g だけ許可 (空文字もOK)""" if action == "0": # 削除は常にOK return True return bool(re.fullmatch(r"[0-9]*[MmGg]?", new_val)) vcmd = (root.register(_mem_vcmd), "%d", "%P") # MCバージョンリストを非同期で取得 mc_versions_cache = [] def _load_mc_versions(): versions = get_all_mc_versions() mc_versions_cache.extend(versions) threading.Thread(target=_load_mc_versions, daemon=True).start() # ── 基本設定 ── frm_base = ttk.LabelFrame(setup_inner, text=" 📁 基本設定") frm_base.pack(fill="x", padx=16, pady=8) ttk.Label(frm_base, text="インストール先:").grid(row=0, column=0, sticky="w", **pad) var_dir = tk.StringVar(value="./mc_network") ttk.Entry(frm_base, textvariable=var_dir, width=42).grid(row=0, column=1, sticky="ew", **pad) def browse(): d = filedialog.askdirectory() if d: var_dir.set(d) ttk.Button(frm_base, text="参照...", command=browse).grid(row=0, column=2, **pad) ttk.Label(frm_base, text="Velocity ポート:").grid(row=1, column=0, sticky="w", **pad) var_vel_port = tk.StringVar(value="25565") ttk.Entry(frm_base, textvariable=var_vel_port, width=10).grid(row=1, column=1, sticky="w", **pad) ttk.Label(frm_base, text="フォワーディング:").grid(row=2, column=0, sticky="w", **pad) var_fwd = tk.StringVar(value="modern") ttk.Combobox(frm_base, textvariable=var_fwd, values=["modern", "legacy", "none"], state="readonly", width=14).grid(row=2, column=1, sticky="w", **pad) frm_base.columnconfigure(1, weight=1) # ── Velocity メモリ ── frm_vel_mem = ttk.LabelFrame(setup_inner, text=" 🚀 Velocity メモリ設定") frm_vel_mem.pack(fill="x", padx=16, pady=4) ttk.Label(frm_vel_mem, text="最小 (Xms):").grid(row=0, column=0, sticky="w", **pad) var_vel_xms = tk.StringVar(value="512M") ttk.Entry(frm_vel_mem, textvariable=var_vel_xms, width=10, validate="key", validatecommand=vcmd).grid(row=0, column=1, sticky="w", **pad) ttk.Label(frm_vel_mem, text="最大 (Xmx):").grid(row=0, column=2, sticky="w", **pad) var_vel_xmx = tk.StringVar(value="1G") ttk.Entry(frm_vel_mem, textvariable=var_vel_xmx, width=10, validate="key", validatecommand=vcmd).grid(row=0, column=3, sticky="w", **pad) ttk.Label(frm_vel_mem, text="例: 512M, 1G, 2G", style="Dim.TLabel").grid( row=0, column=4, sticky="w", **pad) # ── バックエンドサーバー ── frm_backends = ttk.LabelFrame(setup_inner, text=" 🖥 バックエンドサーバー") frm_backends.pack(fill="x", padx=16, pady=8) # カラム定義: (header_text, widget_minwidth_px) COLS = [ ("名前", 90), ("ポート", 58), ("MOTD", 148), ("モード", 90), ("タイプ", 78), ("MCバージョン", 84), ("Xms", 56), ("Xmx", 56), ("", 26), # 削除ボタン ] # グリッドコンテナ (ヘッダー + 行を同じgridに入れる) grid_frame = tk.Frame(frm_backends, bg=BG3) grid_frame.pack(fill="x", padx=4, pady=(4, 0)) for col_idx, (text, min_w) in enumerate(COLS): grid_frame.columnconfigure(col_idx, minsize=min_w) if text: tk.Label(grid_frame, text=text, bg=BG3, fg=FG_DIM, font=("Segoe UI", 8, "bold"), anchor="w").grid( row=0, column=col_idx, sticky="ew", padx=2, pady=(2, 0)) frm_backend_btn = ttk.Frame(frm_backends) frm_backend_btn.pack(fill="x") backend_rows = [] _row_counter = [1] # grid の行番号 (0行目はヘッダー) def add_backend_row(name="", port="", motd="A Minecraft Server", gm="survival", server_type="paper", mc_version="latest", mem_min="512M", mem_max="2G"): r = _row_counter[0] _row_counter[0] += 1 v_name = tk.StringVar(value=name) v_port = tk.StringVar(value=port) v_motd = tk.StringVar(value=motd) v_gm = tk.StringVar(value=gm) v_type = tk.StringVar(value=server_type) v_ver = tk.StringVar(value=mc_version) v_mem_min = tk.StringVar(value=mem_min) v_mem_max = tk.StringVar(value=mem_max) widgets = [] def _w(widget): widgets.append(widget) return widget _w(ttk.Entry(grid_frame, textvariable=v_name, width=9) ).grid(row=r, column=0, sticky="ew", padx=2, pady=2) _w(ttk.Entry(grid_frame, textvariable=v_port, width=6) ).grid(row=r, column=1, sticky="ew", padx=2, pady=2) _w(ttk.Entry(grid_frame, textvariable=v_motd, width=16) ).grid(row=r, column=2, sticky="ew", padx=2, pady=2) _w(ttk.Combobox(grid_frame, textvariable=v_gm, values=["survival","creative","adventure","spectator"], state="readonly", width=9) ).grid(row=r, column=3, sticky="ew", padx=2, pady=2) _w(ttk.Combobox(grid_frame, textvariable=v_type, values=["paper","purpur","spigot"], state="readonly", width=8) ).grid(row=r, column=4, sticky="ew", padx=2, pady=2) ver_cb = ttk.Combobox(grid_frame, textvariable=v_ver, values=["latest"] + (mc_versions_cache or ["1.21.4","1.21.1","1.20.4"]), width=9) ver_cb.grid(row=r, column=5, sticky="ew", padx=2, pady=2) widgets.append(ver_cb) def _update_versions(event, cb=ver_cb): if mc_versions_cache: cb["values"] = ["latest"] + mc_versions_cache ver_cb.bind("", _update_versions) _w(ttk.Entry(grid_frame, textvariable=v_mem_min, width=6, validate="key", validatecommand=vcmd) ).grid(row=r, column=6, sticky="ew", padx=2, pady=2) _w(ttk.Entry(grid_frame, textvariable=v_mem_max, width=6, validate="key", validatecommand=vcmd) ).grid(row=r, column=7, sticky="ew", padx=2, pady=2) entry = { "name": v_name, "port": v_port, "motd": v_motd, "gm": v_gm, "type": v_type, "ver": v_ver, "mem_min": v_mem_min, "mem_max": v_mem_max, "widgets": widgets, } def remove(e=entry, ws=widgets): for w in ws: w.grid_forget() backend_rows.remove(e) del_btn = ttk.Button(grid_frame, text="✕", command=remove, style="Del.TButton", width=2) del_btn.grid(row=r, column=8, padx=2, pady=2) widgets.append(del_btn) backend_rows.append(entry) add_backend_row("lobby", "25566", "Lobby Server", "survival", "paper") add_backend_row("game1", "25567", "Game Server 1", "survival", "paper") ttk.Button(frm_backend_btn, text="+ サーバーを追加", style="Add.TButton", command=lambda: add_backend_row("", str(25566 + len(backend_rows)))).pack( pady=8, padx=8, anchor="w") # ── 自動起動設定 ── _os_label = "OS 起動時" if not IS_WINDOWS else "Windows 起動時" frm_startup = ttk.LabelFrame(setup_inner, text=" 🔄 自動起動") frm_startup.pack(fill="x", padx=16, pady=4) var_autostart = tk.BooleanVar(value=get_autostart()) ttk.Checkbutton(frm_startup, text=f"{_os_label}にこのツールを自動起動する", variable=var_autostart).pack(anchor="w", padx=12, pady=(8, 2)) if not IS_WINDOWS: ttk.Label(frm_startup, text=" ※ Linux: ~/.config/systemd/user/ にサービスを登録します", style="Dim.TLabel").pack(anchor="w", padx=12, pady=(0, 2)) var_autostart_servers = tk.BooleanVar(value=False) ttk.Checkbutton(frm_startup, text="起動時に全サーバーを自動起動する", variable=var_autostart_servers).pack(anchor="w", padx=12, pady=(2, 8)) # ── セットアップ ログ ── frm_setup_log = ttk.LabelFrame(setup_inner, text=" 📋 セットアップログ") frm_setup_log.pack(fill="both", expand=True, padx=16, pady=8) setup_log_text = scrolledtext.ScrolledText( frm_setup_log, height=10, bg=CONSOLE_BG, fg=CONSOLE_FG, font=("Consolas", 9), state="disabled", wrap="word", insertbackground=ACCENT, selectbackground=ACCENT) setup_log_text.pack(fill="both", expand=True, padx=6, pady=6) def setup_log(msg: str): setup_log_text.configure(state="normal") setup_log_text.insert("end", msg + "\n") setup_log_text.see("end") setup_log_text.configure(state="disabled") root.update_idletasks() # ── セットアップ実行ボタン ── btn_setup_frame = ttk.Frame(setup_inner) btn_setup_frame.pack(fill="x", padx=16, pady=10) var_setup_running = tk.BooleanVar(value=False) setup_done = tk.BooleanVar(value=False) last_cfg = [None] # セットアップ後のcfgを保持 def on_setup(): if var_setup_running.get(): return # セットアップ完了後はサーバー管理タブへ if setup_done.get(): notebook.select(tab_manage) return # バリデーション try: vel_port = int(var_vel_port.get()) except ValueError: messagebox.showerror("エラー", "Velocity ポートは数値で入力してください") return if not validate_mem(var_vel_xms.get()): messagebox.showerror("エラー", f"Velocity Xms の形式が不正です: {var_vel_xms.get()}\n例: 512M, 1G") return if not validate_mem(var_vel_xmx.get()): messagebox.showerror("エラー", f"Velocity Xmx の形式が不正です: {var_vel_xmx.get()}\n例: 512M, 1G") return backends = [] ports_used = set() for i, row in enumerate(backend_rows): name = row["name"].get().strip() port_s = row["port"].get().strip() if not name or not port_s: messagebox.showerror("エラー", f"行 {i+1}: 名前とポートは必須です") return try: port = int(port_s) except ValueError: messagebox.showerror("エラー", f"行 {i+1}: ポートは数値で入力してください") return if port in ports_used or port == vel_port: messagebox.showerror("エラー", f"ポート {port} が重複しています") return ports_used.add(port) if not validate_mem(row["mem_min"].get()): messagebox.showerror("エラー", f"行 {i+1} Xms の形式が不正: {row['mem_min'].get()}") return if not validate_mem(row["mem_max"].get()): messagebox.showerror("エラー", f"行 {i+1} Xmx の形式が不正: {row['mem_max'].get()}") return backends.append(BackendServer( name=name, port=port, server_type=row["type"].get(), mc_version=row["ver"].get(), motd=row["motd"].get(), gamemode=row["gm"].get(), mem_min=row["mem_min"].get(), mem_max=row["mem_max"].get(), )) cfg = NetworkConfig( velocity_port=vel_port, forwarding_mode=var_fwd.get(), backends=backends, base_dir=var_dir.get(), velocity_mem_min=var_vel_xms.get(), velocity_mem_max=var_vel_xmx.get(), autostart_enabled=var_autostart.get(), autostart_servers=var_autostart_servers.get(), auto_restart_enabled=True, watchdog_timeout=120, ) set_autostart(var_autostart.get(), str(Path(__file__).resolve())) var_setup_running.set(True) btn_setup.configure(state="disabled", text="⏳ セットアップ中...") def worker(): try: setup_network(cfg, setup_log) last_cfg[0] = cfg root.after(0, lambda: refresh_server_panel(cfg)) root.after(0, lambda: refresh_settings_panel(cfg)) root.after(0, lambda: setup_done.set(True)) root.after(0, lambda: btn_setup.configure( state="normal", text="🖥 サーバー管理タブへ →")) root.after(0, lambda: messagebox.showinfo( "完了", "セットアップ完了!\n「サーバー管理タブへ」ボタンで管理画面に移動できます。")) except Exception as e: root.after(0, lambda: messagebox.showerror("エラー", str(e))) root.after(0, lambda: btn_setup.configure(state="normal", text="▶ セットアップ開始")) finally: root.after(0, lambda: var_setup_running.set(False)) threading.Thread(target=worker, daemon=True).start() btn_setup = ttk.Button(btn_setup_frame, text="▶ セットアップ開始", command=on_setup, style="Run.TButton") btn_setup.pack(side="right", padx=4) # ═══════════════════════════════════════════════ # タブ2: サーバー管理 # ═══════════════════════════════════════════════ tab_manage = ttk.Frame(notebook) notebook.add(tab_manage, text=" 🖥 サーバー管理 ") server_processes: dict = {} console_notebook = ttk.Notebook(tab_manage) frm_cards = tk.Frame(tab_manage, bg=BG3) frm_cards.pack(fill="x", padx=8, pady=6) tk.Label(frm_cards, text="サーバー一覧", bg=BG3, fg=ACCENT, font=("Segoe UI", 12, "bold")).pack(anchor="w", padx=10, pady=(8, 2)) cards_inner = tk.Frame(frm_cards, bg=BG3) cards_inner.pack(fill="x", padx=4) btn_all_frame = tk.Frame(frm_cards, bg=BG3) btn_all_frame.pack(fill="x", padx=4, pady=6) def start_all(): cfg = last_cfg[0] if not cfg: return for name, (wdir, jar, mn, mx, rcon_port, rcon_pass) in _server_start_info.items(): proc = server_processes.get(name) if proc and proc.is_running(): continue new_proc = ServerProcess( name=name, work_dir=wdir, java=cfg.java_path, jar=jar, mem_min=mn, mem_max=mx, output_callback=lambda msg, n=name: root.after(0, lambda m=msg: _console_log(n, m)), auto_restart=cfg.auto_restart_enabled, watchdog_timeout=cfg.watchdog_timeout, rcon_port=rcon_port, rcon_password=rcon_pass, ) server_processes[name] = new_proc new_proc.start() root.after(500, lambda n=name: update_card_status(n)) def stop_all(): for name, proc in server_processes.items(): if proc.is_running(): threading.Thread(target=proc.stop, daemon=True).start() root.after(2500, lambda: [update_card_status(n) for n in server_processes]) ttk.Button(btn_all_frame, text="▶ 全サーバー起動", style="Start.TButton", command=start_all).pack(side="left", padx=4) ttk.Button(btn_all_frame, text="⏹ 全サーバー停止", style="Stop.TButton", command=stop_all).pack(side="left", padx=4) tk.Frame(tab_manage, bg=BORDER, height=1).pack(fill="x", padx=8, pady=2) console_notebook.pack(fill="both", expand=True, padx=8, pady=4) server_widgets: dict = {} # start_all が使うためのサーバー起動情報を保持する辞書 # key: サーバー名, value: (work_dir, jar, mem_min, mem_max, rcon_port, rcon_pass) _server_start_info: dict = {} def update_card_status(name: str): if name not in server_widgets: return proc = server_processes.get(name) running = proc.is_running() if proc else False w = server_widgets[name] restarts = proc.restart_count if proc else 0 restart_text = f" (再起動 {restarts}回)" if restarts > 0 else "" w["status_label"].configure( text=f"▶ 起動中{restart_text}" if running else "⏹ 停止中", fg=SUCCESS if running else FG_MUTED ) w["start_btn"].configure(state="disabled" if running else "normal") w["stop_btn"].configure(state="normal" if running else "disabled") def _console_log(name: str, msg: str): if name not in server_widgets: return w = server_widgets[name] widget = w["console_text"] widget.configure(state="normal") widget.insert("end", msg + "\n") widget.see("end") widget.configure(state="disabled") root.after(100, lambda: update_card_status(name)) def refresh_server_panel(cfg: NetworkConfig): for w in cards_inner.winfo_children(): w.destroy() for tab_id in console_notebook.tabs(): console_notebook.forget(tab_id) server_widgets.clear() _server_start_info.clear() base = Path(cfg.base_dir).resolve() # (name, work_dir, jar, mem_min, mem_max, srv_type, rcon_port, rcon_pass) servers = [("velocity", base / "velocity", "velocity.jar", cfg.velocity_mem_min, cfg.velocity_mem_max, "Velocity", 0, "")] for b in cfg.backends: rcon_port = b.port + 10000 servers.append((b.name, base / b.name, "server.jar", b.mem_min, b.mem_max, b.server_type.capitalize(), rcon_port, b.rcon_password)) for srv_name, work_dir, jar, mem_min, mem_max, srv_type, rcon_port, rcon_pass in servers: # 起動情報を登録(start_all用) _server_start_info[srv_name] = (work_dir, jar, mem_min, mem_max, rcon_port, rcon_pass) # サーバーカード card = tk.Frame(cards_inner, bg=BG2, relief="flat", bd=0, highlightbackground=BORDER, highlightthickness=1) card.pack(side="left", padx=6, pady=4, ipadx=12, ipady=10) # カードヘッダーアクセント card_hdr = tk.Frame(card, bg=ACCENT, height=3) card_hdr.pack(fill="x") # サーバー名 name_row = tk.Frame(card, bg=BG2) name_row.pack(fill="x", padx=8, pady=(6, 0)) # タイプによってアイコンを変える icon = "🚀" if srv_type == "Velocity" else "📦" tk.Label(name_row, text=icon, bg=BG2, fg=ACCENT, font=("Segoe UI", 14)).pack(side="left", padx=(0, 4)) tk.Label(name_row, text=srv_name, bg=BG2, fg=FG, font=("Segoe UI", 12, "bold")).pack(side="left", anchor="w") tk.Label(card, text=f"{srv_type} · {mem_min} ~ {mem_max}", bg=BG2, fg=FG_DIM, font=("Segoe UI", 8)).pack(anchor="w", padx=8, pady=(2, 0)) # 区切り線 tk.Frame(card, bg=BORDER, height=1).pack(fill="x", padx=8, pady=6) status_lbl = tk.Label(card, text="⏹ 停止中", bg=BG2, fg=FG_MUTED, font=("Segoe UI", 9, "bold")) status_lbl.pack(anchor="w", padx=8, pady=(0, 8)) btn_row = tk.Frame(card, bg=BG2) btn_row.pack(padx=4) _name = srv_name def make_start(name, wdir, j, mn, mx, rp, rpw): def _start(): proc = server_processes.get(name) if proc and proc.is_running(): return new_proc = ServerProcess( name=name, work_dir=wdir, java=cfg.java_path, jar=j, mem_min=mn, mem_max=mx, output_callback=lambda msg, n=name: root.after(0, lambda m=msg: _console_log(n, m)), auto_restart=cfg.auto_restart_enabled, watchdog_timeout=cfg.watchdog_timeout, rcon_port=rp, rcon_password=rpw, ) server_processes[name] = new_proc new_proc.start() root.after(500, lambda: update_card_status(name)) return _start def make_stop(name): def _stop(): proc = server_processes.get(name) if proc: threading.Thread(target=proc.stop, daemon=True).start() root.after(2000, lambda: update_card_status(name)) return _stop start_btn = ttk.Button(btn_row, text="▶ 起動", style="Start.TButton", command=make_start(_name, work_dir, jar, mem_min, mem_max, rcon_port, rcon_pass)) start_btn.pack(side="left", padx=2) stop_btn = ttk.Button(btn_row, text="■ 停止", style="Stop.TButton", command=make_stop(_name)) stop_btn.configure(state="disabled") stop_btn.pack(side="left", padx=2) # コンソールタブ con_frame = ttk.Frame(console_notebook) console_notebook.add(con_frame, text=f" {srv_name} ") con_text = scrolledtext.ScrolledText( con_frame, bg=CONSOLE_BG, fg=CONSOLE_FG, font=("Consolas", 9), state="disabled", wrap="word", insertbackground=ACCENT, selectbackground=ACCENT) con_text.pack(fill="both", expand=True, padx=4, pady=(4, 0)) # コマンド入力 con_input_frame = tk.Frame(con_frame, bg=BG2) con_input_frame.pack(fill="x", padx=4, pady=4) con_input = tk.Entry(con_input_frame, bg=BG3, fg=ACCENT, insertbackground=ACCENT, font=("Consolas", 10), relief="flat", bd=0) con_input.pack(side="left", fill="x", expand=True, padx=(4, 4), ipady=4) def make_send(name, entry): def _send(event=None): cmd = entry.get().strip() if not cmd: return proc = server_processes.get(name) if proc and proc.is_running(): proc.send_command(cmd) _console_log(name, f"> {cmd}") else: _console_log(name, "⚠ サーバーが起動していません") entry.delete(0, "end") return _send _send_fn = make_send(_name, con_input) con_input.bind("", _send_fn) ttk.Button(con_input_frame, text="送信", command=_send_fn).pack(side="left") server_widgets[srv_name] = { "card": card, "status_label": status_lbl, "start_btn": start_btn, "stop_btn": stop_btn, "console_text": con_text, } # ═══════════════════════════════════════════════ # タブ3: プラグイン管理 # ═══════════════════════════════════════════════ tab_plugins = ttk.Frame(notebook) notebook.add(tab_plugins, text=" 🔌 プラグイン ") plg_top = ttk.Frame(tab_plugins) plg_top.pack(fill="x", padx=12, pady=8) ttk.Label(plg_top, text="対象サーバー:").pack(side="left", padx=(0, 4)) var_target_server = tk.StringVar() cb_target_server = ttk.Combobox(plg_top, textvariable=var_target_server, state="readonly", width=14) cb_target_server.pack(side="left", padx=4) ttk.Label(plg_top, text="ソース:").pack(side="left", padx=(16, 4)) var_plg_source = tk.StringVar(value="Modrinth") ttk.Combobox(plg_top, textvariable=var_plg_source, values=["Modrinth", "Hangar (PaperMC)"], state="readonly", width=16).pack(side="left", padx=4) ttk.Label(plg_top, text="検索:").pack(side="left", padx=(16, 4)) var_plg_query = tk.StringVar() plg_search_entry = ttk.Entry(plg_top, textvariable=var_plg_query, width=20) plg_search_entry.pack(side="left", padx=4) plg_results = [] plg_selected = {} # slug -> bool # 結果フレーム plg_result_outer = ttk.Frame(tab_plugins) plg_result_outer.pack(fill="both", expand=True, padx=12, pady=(0, 8)) plg_canvas = tk.Canvas(plg_result_outer, bg=BG3, highlightthickness=0) plg_scroll = ttk.Scrollbar(plg_result_outer, orient="vertical", command=plg_canvas.yview) plg_inner = ttk.Frame(plg_canvas) plg_inner.bind("", lambda e: plg_canvas.configure(scrollregion=plg_canvas.bbox("all"))) plg_canvas.create_window((0, 0), window=plg_inner, anchor="nw") plg_canvas.configure(yscrollcommand=plg_scroll.set) plg_canvas.pack(side="left", fill="both", expand=True) plg_scroll.pack(side="right", fill="y") plg_canvas.bind("", lambda e: plg_canvas.yview_scroll(int(-1*(e.delta/120)), "units")) plg_inner.bind("", lambda e: plg_canvas.yview_scroll(int(-1*(e.delta/120)), "units")) plg_status_lbl = tk.Label(tab_plugins, text="", bg=BG3, fg=FG_DIM, font=("Segoe UI", 9)) plg_status_lbl.pack(pady=2) def _plg_status(msg): plg_status_lbl.configure(text=msg) root.update_idletasks() def search_plugins(): for w in plg_inner.winfo_children(): w.destroy() plg_selected.clear() _plg_status("🔍 検索中...") source = var_plg_source.get() query = var_plg_query.get().strip() # 対象サーバーのMCバージョンを取得 mc_ver = "1.21.4" cfg = last_cfg[0] if cfg: target = var_target_server.get() for b in cfg.backends: if b.name == target: mc_ver = b.mc_version if b.mc_version != "latest" else get_all_mc_versions()[0] break def _worker(): if source == "Modrinth": results = get_modrinth_plugins(mc_ver, query) else: results = get_hangar_plugins(mc_ver, query) root.after(0, lambda r=results: _show_results(r)) threading.Thread(target=_worker, daemon=True).start() def _show_results(results): for w in plg_inner.winfo_children(): w.destroy() plg_selected.clear() plg_results.clear() plg_results.extend(results) if not results: tk.Label(plg_inner, text="結果なし", bg=BG3, fg=FG_DIM, font=("Segoe UI", 10)).pack(pady=20) _plg_status("") return for plugin in results: slug = plugin.get("slug", plugin["name"]) var = tk.BooleanVar() plg_selected[slug] = var row = tk.Frame(plg_inner, bg=BG2, relief="solid", bd=1) row.pack(fill="x", padx=4, pady=2, ipady=4) tk.Checkbutton(row, variable=var, bg=BG2, activebackground=BG2).pack(side="left", padx=6) # アイコン画像エリア img_label = tk.Label(row, bg=BG2, width=4) img_label.pack(side="left", padx=(0, 6)) def _load_icon(url, lbl): try: req = urllib.request.Request(url, headers={"User-Agent": "mc-server-builder/2.0"}) with urllib.request.urlopen(req, timeout=5) as r: data = r.read() import io photo = None # Pillow が使えれば JPEG/WebP/PNG すべて対応 try: from PIL import Image, ImageTk img = Image.open(io.BytesIO(data)).convert("RGBA").resize((48, 48), Image.LANCZOS) photo = ImageTk.PhotoImage(img) except ImportError: # Pillow なし → tkinter ネイティブ PhotoImage (PNG のみ対応) try: photo = tk.PhotoImage(data=data) # サイズを 48x48 相当に縮小(subsample で近似) w, h = photo.width(), photo.height() if w > 0 and h > 0: sx = max(1, w // 48) sy = max(1, h // 48) photo = photo.subsample(sx, sy) except Exception: photo = None if photo: root.after(0, lambda p=photo, l=lbl: ( l.configure(image=p, width=48, height=48), setattr(l, "_img", p) )) except Exception: pass # 画像取得失敗は無視 icon_url = plugin.get("icon_url") or plugin.get("icon") if icon_url: threading.Thread(target=_load_icon, args=(icon_url, img_label), daemon=True).start() info = tk.Frame(row, bg=BG2) info.pack(side="left", fill="x", expand=True) tk.Label(info, text=plugin["name"], bg=BG2, fg=ACCENT3, font=("Segoe UI", 10, "bold")).pack(anchor="w") desc = plugin.get("desc", "")[:100] tk.Label(info, text=desc, bg=BG2, fg=FG_DIM, font=("Segoe UI", 8), wraplength=500, justify="left").pack(anchor="w") dl_str = f"{plugin.get('downloads', 0):,} DL" tk.Label(row, text=dl_str, bg=BG2, fg=FG_MUTED, font=("Segoe UI", 8)).pack(side="right", padx=8) _plg_status(f"{len(results)} 件の結果") def install_selected(): cfg = last_cfg[0] if not cfg: messagebox.showwarning("警告", "先にセットアップを完了してください") return to_install = [] for slug, var in plg_selected.items(): if var.get(): for p in plg_results: if p.get("slug") == slug: to_install.append(p) break if not to_install: messagebox.showinfo("情報", "プラグインが選択されていません") return # 鯖選択ダイアログ dlg = tk.Toplevel(root) dlg.title("インストール先サーバーを選択") dlg.configure(bg=BG3) dlg.grab_set() tk.Label(dlg, text="インストール先の鯖を選んでください(複数可)", bg=BG3, fg=FG, font=("Segoe UI", 10, "bold")).pack(padx=16, pady=(12, 6)) base = Path(cfg.base_dir).resolve() server_list = [("velocity", base / "velocity", "3")] for b in cfg.backends: mc_ver = b.mc_version if b.mc_version != "latest" else get_all_mc_versions()[0] server_list.append((b.name, base / b.name, mc_ver)) srv_vars = {} for srv_name, _, _ in server_list: v = tk.BooleanVar() srv_vars[srv_name] = v tk.Checkbutton(dlg, text=srv_name, variable=v, bg=BG3, fg=FG, activebackground=BG3, selectcolor=BG2, font=("Segoe UI", 10)).pack(anchor="w", padx=24, pady=2) def _do_install(): targets = [(n, d, mv) for n, d, mv in server_list if srv_vars[n].get()] if not targets: messagebox.showwarning("警告", "鯖が1つも選ばれていません", parent=dlg) return dlg.destroy() _plg_status(f"📥 {len(to_install)} 個 × {len(targets)} 鯖 インストール中...") def _worker(): for srv_name, dest_dir, mc_ver in targets: for plugin in to_install: download_plugin(plugin, dest_dir, mc_ver, lambda msg: root.after(0, lambda m=msg: setup_log(m))) root.after(0, lambda: _plg_status("✅ インストール完了")) root.after(0, lambda: messagebox.showinfo( "完了", f"{len(to_install)} 個のプラグインを {len(targets)} 鯖にインストールしました")) threading.Thread(target=_worker, daemon=True).start() ttk.Button(dlg, text="📥 インストール", style="Run.TButton", command=_do_install).pack(pady=12) # ─── 検索ボタン(search_plugins定義後に配置)── ttk.Button(plg_top, text="🔍 検索", style="Run.TButton", command=search_plugins).pack(side="left", padx=4) plg_search_entry.bind("", lambda e: search_plugins()) # ─── インストールボタン ────────────────────── plg_btn_frame = ttk.Frame(tab_plugins) plg_btn_frame.pack(fill="x", padx=12, pady=(0, 4)) ttk.Button(plg_btn_frame, text="📥 選択したプラグインをインストール", style="Run.TButton", command=install_selected).pack(side="left", padx=4) # ─── 必須プラグイン案内 ────────────────────── frm_required = ttk.LabelFrame(tab_plugins, text=" ✅ 必須プラグイン(セットアップ時に自動インストール)") frm_required.pack(fill="x", padx=12, pady=(0, 6)) for rp in REQUIRED_PLUGINS: tk.Label(frm_required, text=f" • {rp['name']} — {rp['desc']}", bg=BG3, fg=FG, font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=1) # ═══════════════════════════════════════════════ # タブ4: 設定変更 # ═══════════════════════════════════════════════ tab_settings = ttk.Frame(notebook) notebook.add(tab_settings, text=" 🔧 設定変更 ") settings_canvas = tk.Canvas(tab_settings, bg=BG3, highlightthickness=0) settings_scroll = ttk.Scrollbar(tab_settings, orient="vertical", command=settings_canvas.yview) settings_inner = ttk.Frame(settings_canvas) settings_inner.bind("", lambda e: settings_canvas.configure(scrollregion=settings_canvas.bbox("all"))) settings_canvas.create_window((0, 0), window=settings_inner, anchor="nw") settings_canvas.configure(yscrollcommand=settings_scroll.set) settings_canvas.pack(side="left", fill="both", expand=True) settings_scroll.pack(side="right", fill="y") settings_canvas.bind("", lambda e: settings_canvas.yview_scroll(int(-1*(e.delta/120)), "units")) settings_inner.bind("", lambda e: settings_canvas.yview_scroll(int(-1*(e.delta/120)), "units")) settings_vars: dict = {} def refresh_settings_panel(cfg: NetworkConfig): for w in settings_inner.winfo_children(): w.destroy() settings_vars.clear() # サーバータイプ・バージョン変更 frm_sv = ttk.LabelFrame(settings_inner, text=" 🖥 サーバータイプ・バージョン") frm_sv.pack(fill="x", padx=14, pady=8) sv_vars = {} for b in cfg.backends: frm_row = ttk.Frame(frm_sv) frm_row.pack(fill="x", padx=6, pady=3) tk.Label(frm_row, text=f"{b.name}:", bg=BG3, fg=ACCENT3, font=("Segoe UI", 10, "bold"), width=14, anchor="w").pack(side="left") ttk.Label(frm_row, text="タイプ:").pack(side="left", padx=(8, 2)) v_type = tk.StringVar(value=b.server_type) ttk.Combobox(frm_row, textvariable=v_type, values=["paper", "purpur", "spigot"], state="readonly", width=8).pack(side="left", padx=2) ttk.Label(frm_row, text="バージョン:").pack(side="left", padx=(8, 2)) v_ver = tk.StringVar(value=b.mc_version) ver_cb2 = ttk.Combobox(frm_row, textvariable=v_ver, values=["latest"] + (mc_versions_cache or ["1.21.4"]), width=10) ver_cb2.pack(side="left", padx=2) ver_cb2.bind("", lambda e, cb=ver_cb2: cb.configure( values=["latest"] + mc_versions_cache) if mc_versions_cache else None) sv_vars[b.name] = {"type": v_type, "ver": v_ver} settings_vars["_sv"] = sv_vars # Velocityメモリ frm_v = ttk.LabelFrame(settings_inner, text=" 🚀 Velocity メモリ") frm_v.pack(fill="x", padx=14, pady=4) v_xms = tk.StringVar(value=cfg.velocity_mem_min) v_xmx = tk.StringVar(value=cfg.velocity_mem_max) ttk.Label(frm_v, text="Xms:").grid(row=0, column=0, sticky="w", **pad) ttk.Entry(frm_v, textvariable=v_xms, width=10, validate="key", validatecommand=vcmd).grid(row=0, column=1, sticky="w", **pad) ttk.Label(frm_v, text="Xmx:").grid(row=0, column=2, sticky="w", **pad) ttk.Entry(frm_v, textvariable=v_xmx, width=10, validate="key", validatecommand=vcmd).grid(row=0, column=3, sticky="w", **pad) settings_vars["velocity"] = {"xms": v_xms, "xmx": v_xmx} # バックエンドメモリ for b in cfg.backends: frm_b = ttk.LabelFrame(settings_inner, text=f" 📦 {b.name} メモリ") frm_b.pack(fill="x", padx=14, pady=4) b_xms = tk.StringVar(value=b.mem_min) b_xmx = tk.StringVar(value=b.mem_max) ttk.Label(frm_b, text="Xms:").grid(row=0, column=0, sticky="w", **pad) ttk.Entry(frm_b, textvariable=b_xms, width=10, validate="key", validatecommand=vcmd).grid(row=0, column=1, sticky="w", **pad) ttk.Label(frm_b, text="Xmx:").grid(row=0, column=2, sticky="w", **pad) ttk.Entry(frm_b, textvariable=b_xmx, width=10, validate="key", validatecommand=vcmd).grid(row=0, column=3, sticky="w", **pad) settings_vars[b.name] = {"xms": b_xms, "xmx": b_xmx} # 自動起動 _os_label = "OS 起動時" if not IS_WINDOWS else "Windows 起動時" frm_st = ttk.LabelFrame(settings_inner, text=" 🔄 自動起動") frm_st.pack(fill="x", padx=14, pady=4) var_as = tk.BooleanVar(value=get_autostart()) settings_vars["_autostart"] = var_as ttk.Checkbutton(frm_st, text=f"{_os_label}にこのツールを自動起動する", variable=var_as).pack(anchor="w", padx=12, pady=(8, 2)) if not IS_WINDOWS: ttk.Label(frm_st, text=" ※ Linux: ~/.config/systemd/user/ にサービスを登録します", style="Dim.TLabel").pack(anchor="w", padx=12, pady=(0, 2)) var_as_srv = tk.BooleanVar(value=cfg.autostart_servers) settings_vars["_autostart_servers"] = var_as_srv ttk.Checkbutton(frm_st, text="起動時に全サーバーを自動起動する", variable=var_as_srv).pack(anchor="w", padx=12, pady=(2, 8)) # 自動再起動 (Watchdog) frm_wr = ttk.LabelFrame(settings_inner, text=" 🛡 自動再起動 (Watchdog)") frm_wr.pack(fill="x", padx=14, pady=4) var_ar = tk.BooleanVar(value=cfg.auto_restart_enabled) var_wt = tk.StringVar(value=str(cfg.watchdog_timeout)) settings_vars["_auto_restart"] = var_ar settings_vars["_watchdog_timeout"] = var_wt ttk.Checkbutton(frm_wr, text="サーバーがクラッシュ / 応答なしで自動再起動する", variable=var_ar).pack(anchor="w", padx=12, pady=(8, 2)) wt_row = ttk.Frame(frm_wr) wt_row.pack(anchor="w", padx=12, pady=(0, 8)) ttk.Label(wt_row, text="無応答タイムアウト:").pack(side="left") ttk.Entry(wt_row, textvariable=var_wt, width=6).pack(side="left", padx=6) ttk.Label(wt_row, text="秒 (デフォルト: 120)", style="Dim.TLabel").pack(side="left") def save_settings(): # バリデーション if not validate_mem(settings_vars["velocity"]["xms"].get()): messagebox.showerror("エラー", "Velocity Xms の形式が不正です") return if not validate_mem(settings_vars["velocity"]["xmx"].get()): messagebox.showerror("エラー", "Velocity Xmx の形式が不正です") return for b in cfg.backends: if b.name in settings_vars: if not validate_mem(settings_vars[b.name]["xms"].get()): messagebox.showerror("エラー", f"{b.name} Xms の形式が不正です") return if not validate_mem(settings_vars[b.name]["xmx"].get()): messagebox.showerror("エラー", f"{b.name} Xmx の形式が不正です") return cfg.velocity_mem_min = settings_vars["velocity"]["xms"].get() cfg.velocity_mem_max = settings_vars["velocity"]["xmx"].get() sv_v = settings_vars.get("_sv", {}) for b in cfg.backends: if b.name in settings_vars: b.mem_min = settings_vars[b.name]["xms"].get() b.mem_max = settings_vars[b.name]["xmx"].get() if b.name in sv_v: b.server_type = sv_v[b.name]["type"].get() b.mc_version = sv_v[b.name]["ver"].get() cfg.autostart_enabled = settings_vars["_autostart"].get() cfg.autostart_servers = settings_vars["_autostart_servers"].get() cfg.auto_restart_enabled = settings_vars["_auto_restart"].get() try: cfg.watchdog_timeout = int(settings_vars["_watchdog_timeout"].get()) except ValueError: cfg.watchdog_timeout = 120 set_autostart(cfg.autostart_enabled, str(Path(__file__).resolve())) save_config(cfg) messagebox.showinfo("保存", "設定を保存しました。\n次回起動時から新しい設定が適用されます。") ttk.Button(settings_inner, text="💾 設定を保存", style="Run.TButton", command=save_settings).pack(anchor="e", padx=14, pady=12) # ═══════════════════════════════════════════════ # 起動時: configがあれば読み込む # ═══════════════════════════════════════════════ saved_cfg = load_config() if saved_cfg: last_cfg[0] = saved_cfg var_dir.set(saved_cfg.base_dir) var_vel_port.set(str(saved_cfg.velocity_port)) var_fwd.set(saved_cfg.forwarding_mode) var_vel_xms.set(saved_cfg.velocity_mem_min) var_vel_xmx.set(saved_cfg.velocity_mem_max) var_autostart.set(saved_cfg.autostart_enabled) var_autostart_servers.set(saved_cfg.autostart_servers) for w in list(backend_rows): for widget in w["widgets"]: widget.grid_forget() backend_rows.clear() for b in saved_cfg.backends: add_backend_row(b.name, str(b.port), b.motd, b.gamemode, b.server_type, b.mc_version, b.mem_min, b.mem_max) refresh_server_panel(saved_cfg) refresh_settings_panel(saved_cfg) # プラグインタブのサーバー選択を更新 cb_target_server["values"] = ( ["velocity"] + [b.name for b in saved_cfg.backends] ) if saved_cfg.backends: var_target_server.set(saved_cfg.backends[0].name) setup_done.set(True) btn_setup.configure(text="🖥 サーバー管理タブへ →") setup_log(f"💾 前回の設定を読み込みました: {CONFIG_PATH}") setup_log(f" ベースディレクトリ: {saved_cfg.base_dir}") setup_log(f" サーバー: velocity + {', '.join(b.name for b in saved_cfg.backends)}") # autostart_servers が有効なら全サーバーを自動起動 if saved_cfg.autostart_servers or "--autostart" in sys.argv: setup_log("🚀 全サーバーを自動起動中...") notebook.select(tab_manage) root.after(500, start_all) # バックグラウンドでプラグイン更新チェック def _check_plugin_updates_bg(): cfg = saved_cfg base = Path(cfg.base_dir).resolve() all_updates = {} # {server_name: [update_info, ...]} server_dirs = [("velocity", base / "velocity")] for b in cfg.backends: server_dirs.append((b.name, base / b.name)) for srv_name, srv_dir in server_dirs: updates = check_plugin_updates(srv_dir) if updates: all_updates[srv_name] = updates if all_updates: root.after(0, lambda: _show_plugin_update_dialog(all_updates)) def _show_plugin_update_dialog(all_updates: dict): total = sum(len(v) for v in all_updates.values()) dlg = tk.Toplevel(root) dlg.title("🔌 プラグイン更新あり") dlg.configure(bg=BG3) dlg.grab_set() dlg.resizable(False, False) tk.Label(dlg, text=f"🔌 {total} 個のプラグインに更新があります", bg=BG3, fg=WARNING, font=("Segoe UI", 11, "bold")).pack(padx=20, pady=(14, 6)) frm = tk.Frame(dlg, bg=BG2, relief="solid", bd=1) frm.pack(fill="x", padx=16, pady=6) check_vars = {} # (srv_name, plugin_name) -> BooleanVar for srv_name, updates in all_updates.items(): tk.Label(frm, text=f" [{srv_name}]", bg=BG2, fg=ACCENT, font=("Segoe UI", 9, "bold")).pack(anchor="w", padx=8, pady=(6, 2)) for u in updates: var = tk.BooleanVar(value=True) check_vars[(srv_name, u["name"])] = (var, u) tk.Checkbutton( frm, text=f" {u['name']} {u['current_version']} → {u['new_version']}", variable=var, bg=BG2, fg=FG, activebackground=BG2, selectcolor=BG3, font=("Segoe UI", 9) ).pack(anchor="w", padx=16, pady=1) def _do_update(): dlg.destroy() base = Path(saved_cfg.base_dir).resolve() server_dir_map = {"velocity": base / "velocity"} for b in saved_cfg.backends: server_dir_map[b.name] = base / b.name def _worker(): for (srv_name, _), (var, u) in check_vars.items(): if var.get(): srv_dir = server_dir_map.get(srv_name) if srv_dir: apply_plugin_update(srv_dir, u, log_cb=lambda m: root.after(0, lambda msg=m: setup_log(msg))) root.after(0, lambda: messagebox.showinfo("完了", "プラグインの更新が完了しました")) threading.Thread(target=_worker, daemon=True).start() btn_row = tk.Frame(dlg, bg=BG3) btn_row.pack(pady=12) ttk.Button(btn_row, text="✅ 選択したものを更新", style="Run.TButton", command=_do_update).pack(side="left", padx=6) ttk.Button(btn_row, text="スキップ", command=dlg.destroy).pack(side="left", padx=6) threading.Thread(target=_check_plugin_updates_bg, daemon=True).start() else: setup_log("🆕 初回起動です。セットアップを行ってください。") # セットアップ完了後にプラグインタブを更新するフック def _on_setup_done(*args): cfg = last_cfg[0] if cfg: cb_target_server["values"] = ( ["velocity"] + [b.name for b in cfg.backends] ) if cfg.backends: var_target_server.set(cfg.backends[0].name) setup_done.trace_add("write", _on_setup_done) # ウィンドウを閉じる時 def on_close(): running = [n for n, p in server_processes.items() if p.is_running()] if running: if messagebox.askyesno("確認", f"起動中のサーバーがあります:\n{', '.join(running)}\n\nすべて停止して終了しますか?"): for proc in server_processes.values(): if proc.is_running(): proc.stop() root.destroy() root.protocol("WM_DELETE_WINDOW", on_close) root.mainloop() # ───────────────────────────────────────────── # エントリーポイント # ───────────────────────────────────────────── if __name__ == "__main__": if IS_WINDOWS or "--gui" in sys.argv: gui_main() elif "--autostart" in sys.argv: # 自動起動モード: configがあれば全サーバーを起動してCUI管理へ saved = load_config() if saved and saved.autostart_servers: print("🚀 自動起動モード: 全サーバーを起動します...") cui_manage(saved) elif saved: print("ℹ 自動起動モード: サーバー自動起動は無効です (設定で有効化できます)") cui_manage(saved) else: print("⚠ 設定ファイルが見つかりません。通常モードで起動します。") cui_main() else: cui_main()