diff --git a/README.md b/README.md index becb973..1865e17 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ scripts/build_installer_nuitka.bat ## 資料儲存位置 - 使用者設定檔:`%LOCALAPPDATA%\Programs\MinecraftServerManager\user_settings.json` +- 日誌檔案:`%LOCALAPPDATA%\Programs\MinecraftServerManager\log\`(自動管理,超過 10MB 時會刪除相當於 8MB 的舊日誌) - 伺服器資料夾:由使用者選擇「主資料夾」後,程式會在該資料夾內建立 `servers` 子資料夾並存放所有伺服器資料。 ## 貢獻與回饋 diff --git a/docs/TECHNICAL_OVERVIEW.md b/docs/TECHNICAL_OVERVIEW.md index c1c7630..e1f3ffa 100644 --- a/docs/TECHNICAL_OVERVIEW.md +++ b/docs/TECHNICAL_OVERVIEW.md @@ -26,7 +26,7 @@ ### 3. 工具層 (Utils Layer) - src/utils/ 提供跨模組共用的通用功能與輔助函式。 - **JavaUtils / JavaDownloader**: Java 環境的偵測、驗證與自動下載。 -- **LogUtils**: 統一的日誌記錄系統,支援多級別日誌輸出。 +- **Logger (基於 loguru)**: 統一的日誌記錄系統,支援多級別日誌輸出與自動日誌管理。 - **SettingsManager**: 應用程式設定的持久化存儲與讀取。 - **UIUtils**: 通用的 UI 輔助函式,如對話框顯示、字體管理等。 @@ -107,6 +107,9 @@ MinecraftServerManger/ ## 使用者資料與伺服器資料路徑 - 使用者設定檔固定存放於:`%LOCALAPPDATA%\Programs\MinecraftServerManager\user_settings.json` +- 日誌檔案存放於:`%LOCALAPPDATA%\Programs\MinecraftServerManager\log\` + - 日誌檔案命名格式:`YYYY-MM-DD-HH-mm.log` + - 自動清理機制:當日誌資料夾超過 10MB 時,會自動刪除相當於 8MB 的舊日誌 - `user_settings.json` 會記錄「使用者選擇的伺服器主資料夾」(base dir),實際伺服器資料會放在該資料夾內的 `servers` 子資料夾。 ## 技術堆疊 (Tech Stack) @@ -121,6 +124,7 @@ MinecraftServerManger/ - **psutil**: 跨平台系統監控,用於獲取 CPU 與記憶體使用率。 - **lxml**: 高效能 XML 解析,用於處理 Maven Metadata。 - **toml**: 解析 TOML 設定檔 (如 Fabric/Forge 配置)。 +- **loguru**: 現代化的日誌記錄函式庫,提供彩色輸出、自動日誌輪轉與執行緒安全等功能。 ## 安全性與合規性 diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 8c36506..388cfd3 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -21,6 +21,10 @@ 使用者設定會儲存在:`%LOCALAPPDATA%\Programs\MinecraftServerManager\user_settings.json` +日誌檔案會自動記錄於:`%LOCALAPPDATA%\Programs\MinecraftServerManager\log\` +- 每次啟動程式會建立新的日誌檔案(格式:年-月-日-時-分.log) +- 程式會自動清理舊日誌:當日誌資料夾超過 10MB 時,會刪除相當於 8MB 的舊日誌 + ### 查看目前安裝的套件(開發者/進階) 在專案根目錄執行: diff --git a/pyproject.toml b/pyproject.toml index 51ec442..237f6c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "nuitka>=2.8.9", "zstandard>=0.15", "uv>=0.6.0", + "loguru>=0.7.3", ] [project.scripts] diff --git a/src/core/loader_manager.py b/src/core/loader_manager.py index 6690e58..84285b8 100644 --- a/src/core/loader_manager.py +++ b/src/core/loader_manager.py @@ -18,7 +18,10 @@ # ====== 專案內部模組 ====== from src.models import LoaderVersion from src.utils import java_utils -from src.utils import HTTPUtils, LogUtils, UIUtils, ensure_dir, get_cache_dir +from src.utils import HTTPUtils, UIUtils, ensure_dir, get_cache_dir +from src.utils.logger import get_logger + +logger = get_logger().bind(component="LoaderManager") class LoaderManager: """ @@ -57,14 +60,14 @@ def clear_cache_file(self): # 清除記憶體快取 self._version_cache.clear() except PermissionError as e: - LogUtils.error_exc(f"清除快取檔案失敗: {e}", "LoaderManager", e) + logger.exception(f"清除快取檔案失敗: {e}") UIUtils.show_error( "清除快取檔案失敗", f"無法刪除快取檔案: {cache_file}\n權限不足\n{e}", topmost=True, ) except Exception as e: - LogUtils.error_exc(f"清除快取檔案失敗: {e}", "LoaderManager", e) + logger.exception(f"清除快取檔案失敗: {e}") UIUtils.show_error( "清除快取檔案失敗", f"無法刪除快取檔案: {cache_file}\n{e}", topmost=True ) @@ -185,7 +188,7 @@ def preload_loader_versions(self): self._preload_forge_versions() def _preload_fabric_versions(self): - LogUtils.debug("預先抓取 Fabric 載入器版本...", "LoaderManager") + logger.debug("預先抓取 Fabric 載入器版本...", "LoaderManager") fabric_url = "https://meta.fabricmc.net/v2/versions/loader" try: data = HTTPUtils.get_json(fabric_url, timeout=15) @@ -193,7 +196,7 @@ def _preload_fabric_versions(self): with open(self.fabric_cache_file, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) except Exception as e: - LogUtils.error_exc(f"載入 Fabric 版本失敗: {e}", "LoaderManager", e) + logger.exception(f"載入 Fabric 版本失敗: {e}") UIUtils.show_error( "載入 Fabric 版本失敗", f"無法從 API 獲取 Fabric 版本:{e}", @@ -201,13 +204,13 @@ def _preload_fabric_versions(self): ) def _preload_forge_versions(self): - LogUtils.debug("預先抓取 Forge 載入器版本...", "LoaderManager") + logger.debug("預先抓取 Forge 載入器版本...", "LoaderManager") try: forge_url = "https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml" response = HTTPUtils.get_content(forge_url, timeout=15) if response and response.status_code == 200: - LogUtils.debug("成功獲取 Forge XML 數據", "LoaderManager") + logger.debug("成功獲取 Forge XML 數據", "LoaderManager") root = etree.fromstring(response.content) versions = [] @@ -244,7 +247,7 @@ def _preload_forge_versions(self): version_dict[mc_version] = [] version_dict[mc_version].append(version) except Exception as e: - LogUtils.debug(f"解析 Forge 版本字串失敗 '{version}': {e}", "LoaderManager") + logger.debug(f"解析 Forge 版本字串失敗 '{version}': {e}", "LoaderManager") continue # 對每個 MC 版本的 Forge 版本進行排序(最新在前) @@ -259,7 +262,7 @@ def _preload_forge_versions(self): return except Exception as e: - LogUtils.error_exc(f"Maven metadata API 方法失敗: {e}", "LoaderManager", e) + logger.exception(f"Maven metadata API 方法失敗: {e}") UIUtils.show_error( "載入 Forge 版本失敗", f"無法從 Maven metadata API 獲取 Forge 版本:{e}", @@ -316,9 +319,7 @@ def get_compatible_loader_versions( self._version_cache[cache_key] = result return result except Exception as e: - LogUtils.error_exc( - f"獲取 Fabric 版本時發生錯誤: {e}", "LoaderManager", e - ) + logger.exception(f"獲取 Fabric 版本時發生錯誤: {e}") return [] # Forge elif loader_type.lower() == "forge": @@ -341,9 +342,7 @@ def get_compatible_loader_versions( self._version_cache[cache_key] = result return result except Exception as e: - LogUtils.error_exc( - f"獲取 Forge 版本時發生錯誤: {e}", "LoaderManager", e - ) + logger.exception(f"獲取 Forge 版本時發生錯誤: {e}") return [] def _is_fabric_compatible_version(self, mc_version: str) -> bool: @@ -377,7 +376,7 @@ def _is_fabric_compatible_version(self, mc_version: str) -> bool: else: return False except Exception as e: - LogUtils.error_exc(f"檢查 Fabric 相容性時發生錯誤: {e}", "LoaderManager", e) + logger.exception(f"檢查 Fabric 相容性時發生錯誤: {e}") return False def _parse_mc_version(self, version_str: str) -> list: @@ -396,7 +395,7 @@ def _parse_mc_version(self, version_str: str) -> list: matches = re.findall(r"\d+", version_str) return [int(x) for x in matches] if matches else [] except Exception as e: - LogUtils.error_exc(f"解析 MC 版本時發生錯誤: {e}", "LoaderManager", e) + logger.exception(f"解析 MC 版本時發生錯誤: {e}") return [] # ---------------------- 私有輔助方法 ---------------------- # === 下載 × 執行安裝器 === @@ -511,16 +510,14 @@ def _download_and_run_installer( progress_callback(install_start, f"處理中: {line[:40]}...") if process.returncode != 0: - LogUtils.error( - f"安裝器執行失敗 (Code {process.returncode})", "LoaderManager" - ) + logger.error(f"安裝器執行失敗 (Code {process.returncode})") return self._fail( progress_callback, f"安裝器執行失敗 (Code {process.returncode})", debug=f"[DEBUG] cmd: {' '.join(cmd)}", ) except Exception as e: - LogUtils.error_exc(f"執行安裝器時發生錯誤: {e}", "LoaderManager", e) + logger.exception(f"執行安裝器時發生錯誤: {e}") return self._fail( progress_callback, f"執行安裝器時發生錯誤: {e}", @@ -551,7 +548,7 @@ def _download_and_run_installer( if java_line and "nogui" not in java_line.lower(): java_line += " nogui" except Exception as e: - LogUtils.warning(f"無法讀取 run.bat: {e}") + logger.warning(f"無法讀取 run.bat: {e}") UIUtils.show_warning( "讀取失敗", f"無法讀取 run.bat:{e}", parent=parent_window ) @@ -576,9 +573,7 @@ def _download_and_run_installer( with start_server_path.open("w", encoding="utf-8") as f: f.writelines(new_lines) except Exception as e: - LogUtils.error_exc( - f"修改 start_server.bat 失敗: {e}", "LoaderManager", e - ) + logger.exception(f"修改 start_server.bat 失敗: {e}") UIUtils.show_warning( "修改失敗", f"無法修改 start_server.bat:{e}", @@ -598,9 +593,7 @@ def _download_and_run_installer( with suppress(FileNotFoundError): file_path.unlink() except Exception as e: - LogUtils.error_exc( - f"清理安裝檔失敗: {installer_path}: {e}", "LoaderManager", e - ) + logger.exception(f"清理安裝檔失敗: {installer_path}: {e}") UIUtils.show_warning( "清理失敗", f"安裝完成,但無法清理安裝器檔案:{installer_path}\n可手動刪除。", @@ -608,7 +601,7 @@ def _download_and_run_installer( ) except Exception as e: - LogUtils.error_exc(f"安裝過程中發生錯誤: {e}", "LoaderManager", e) + logger.exception(f"安裝過程中發生錯誤: {e}") UIUtils.show_error( "安裝失敗", f"安裝過程中發生錯誤:{e}", parent=parent_window ) @@ -714,7 +707,7 @@ def _download_file_with_progress( ) return True except Exception as e: - LogUtils.error_exc(f"下載失敗: {e}", "LoaderManager", e) + logger.exception(f"下載失敗: {e}") return self._fail( progress_callback, f"下載失敗: {e}", @@ -747,9 +740,7 @@ def _get_minecraft_server_url(self, mc_version: str) -> Optional[str]: return None return ver_data["downloads"]["server"]["url"] except Exception as e: - LogUtils.error_exc( - f"獲取 Minecraft 伺服器 URL 失敗: {e}", "LoaderManager", e - ) + logger.exception(f"獲取 Minecraft 伺服器 URL 失敗: {e}") return None def _standardize_loader_type(self, lt: str, loader_version: str) -> str: @@ -788,7 +779,7 @@ def _fail(self, progress_callback, user_msg: str, debug: str = "") -> bool: 是否成功 """ if debug: - LogUtils.debug(debug) + logger.debug(debug) if progress_callback: progress_callback(100, user_msg) return False diff --git a/src/core/mod_manager.py b/src/core/mod_manager.py index 990c7ae..5fc75a2 100644 --- a/src/core/mod_manager.py +++ b/src/core/mod_manager.py @@ -19,9 +19,12 @@ import toml import zipfile # ====== 專案內部模組 ====== -from ..utils import HTTPUtils, LogUtils, UIUtils +from ..utils import HTTPUtils, UIUtils +from ..utils.logger import get_logger from ..version_info import APP_VERSION, GITHUB_OWNER, GITHUB_REPO +logger = get_logger().bind(component="ModManager") + # ====== 模組狀態與平台定義 ====== class ModStatus(Enum): """ @@ -186,7 +189,7 @@ def create_mod_info_from_file(self, file_path: Path) -> Optional[LocalModInfo]: file_size=file_path.stat().st_size, ) except Exception as e: - LogUtils.error(f"解析模組檔案失敗 {file_path}: {e}", "ModManager") + logger.error(f"解析模組檔案失敗 {file_path}: {e}", "ModManager") return None def _parse_file_info(self, file_path: Path) -> tuple[str, bool, str]: @@ -229,7 +232,7 @@ def _get_manifest_version(self, jar) -> Optional[str]: if v and v != "${projectversion}": return v except Exception as e: - LogUtils.error_exc(f"讀取 MANIFEST.MF 版本資訊失敗: {e}", "ModManager", e) + logger.exception(f"讀取 MANIFEST.MF 版本資訊失敗: {e}") return None def _extract_metadata_from_jar(self, file_path: Path, mod_data: dict) -> None: @@ -256,9 +259,7 @@ def _extract_metadata_from_jar(self, file_path: Path, mod_data: dict) -> None: extractor(jar, mod_data) break # 找到第一個匹配的元資料檔案即停止 except Exception as e: - LogUtils.error_exc( - f"從 JAR 提取元資料失敗 {file_path}: {e}", "ModManager", e - ) + logger.exception(f"從 JAR 提取元資料失敗 {file_path}: {e}") def _extract_fabric_metadata(self, jar, mod_data: dict) -> None: """ @@ -285,7 +286,7 @@ def _extract_fabric_metadata(self, jar, mod_data: dict) -> None: mc_version = depends.get("minecraft", mod_data["mc_version"]) mod_data["mc_version"] = self._normalize_mc_version(mc_version) except (TypeError, Exception) as e: - LogUtils.error(f"無法從 JAR 檔案提取 Fabric 元資料: {e}", "ModManager") + logger.error(f"無法從 JAR 檔案提取 Fabric 元資料: {e}", "ModManager") def _extract_forge_metadata(self, jar, mod_data: dict) -> None: """ @@ -331,7 +332,7 @@ def _extract_forge_metadata(self, jar, mod_data: dict) -> None: ) break except Exception as e: - LogUtils.error_exc(f"解析 Forge 元資料失敗: {e}", "ModManager", e) + logger.exception(f"解析 Forge 元資料失敗: {e}") def _extract_legacy_forge_metadata(self, jar, mod_data: dict) -> None: """ @@ -364,9 +365,7 @@ def _extract_legacy_forge_metadata(self, jar, mod_data: dict) -> None: mod_data["mc_version"] = info.get("mcversion", mod_data["mc_version"]) mod_data["loader_type"] = "Forge" except Exception as e: - LogUtils.error_exc( - f"解析 legacy Forge mcmod.info 失敗: {e}", "ModManager", e - ) + logger.exception(f"解析 legacy Forge mcmod.info 失敗: {e}") def _resolve_version(self, jar, version: str) -> str: """ @@ -621,9 +620,7 @@ def _detect_platform_info( if platform_id: platform = ModPlatform.MODRINTH except Exception as e: - LogUtils.error_exc( - f"從 JAR 偵測平台 ID 失敗 {file_path}: {e}", "ModManager", e - ) + logger.exception(f"從 JAR 偵測平台 ID 失敗 {file_path}: {e}") # Fallback: search on Modrinth API if platform == ModPlatform.LOCAL or not platform_id: @@ -646,9 +643,7 @@ def _extract_platform_id_from_fabric(self, jar) -> str: meta = json.load(f) return meta.get("id", "") except Exception as e: - LogUtils.error_exc( - f"解析 fabric.mod.json 取得平台 ID 失敗: {e}", "ModManager", e - ) + logger.exception(f"解析 fabric.mod.json 取得平台 ID 失敗: {e}") return "" def _extract_platform_id_from_forge(self, jar) -> str: @@ -674,7 +669,7 @@ def _extract_platform_id_from_forge(self, jar) -> str: if m: return m.group(2) except Exception as e: - LogUtils.error_exc(f"解析 mods.toml 取得平台 ID 失敗: {e}", "ModManager", e) + logger.exception(f"解析 mods.toml 取得平台 ID 失敗: {e}") return "" def _search_on_modrinth( @@ -712,7 +707,7 @@ def _search_on_modrinth( hit = data["hits"][0] return ModPlatform.MODRINTH, hit.get("slug", "") except Exception as e: - LogUtils.error_exc(f"Modrinth 搜尋失敗: {e}", "ModManager", e) + logger.exception(f"Modrinth 搜尋失敗: {e}") return ModPlatform.LOCAL, "" @@ -804,7 +799,7 @@ def enable_mod(self, mod_id: str) -> bool: return True return False except Exception as e: - LogUtils.error(f"啟用模組失敗: {e}", "ModManager") + logger.error(f"啟用模組失敗: {e}", "ModManager") if threading.current_thread() is threading.main_thread(): UIUtils.show_error("啟用失敗", f"啟用模組失敗: {e}") return False @@ -856,7 +851,7 @@ def disable_mod(self, mod_id: str) -> bool: return True return False except Exception as e: - LogUtils.error(f"停用模組失敗: {e}", "ModManager") + logger.error(f"停用模組失敗: {e}", "ModManager") if threading.current_thread() is threading.main_thread(): UIUtils.show_error("停用失敗", f"停用模組失敗: {e}") return False diff --git a/src/core/server_manager.py b/src/core/server_manager.py index f21e4a9..c107382 100644 --- a/src/core/server_manager.py +++ b/src/core/server_manager.py @@ -18,10 +18,10 @@ import threading import time import psutil -import queue # ====== 專案內部模組 ====== from ..models import ServerConfig -from ..utils import LogUtils, UIUtils +from ..utils import UIUtils +from ..utils.logger import get_logger from ..utils import ( ServerCommands, MemoryUtils, @@ -29,6 +29,8 @@ ServerDetectionUtils, ) +logger = get_logger().bind(component="ServerManager") + class ServerManager: """ @@ -40,7 +42,8 @@ class ServerManager: STARTUP_CHECK_DELAY = 0.1 # 伺服器啟動檢查延遲(秒) # 伺服器停止檢查常數 STOP_CHECK_INTERVAL = 0.1 # 停止檢查間隔(秒) - MAX_STOP_CHECKS = 50 # 最大停止檢查次數(總計 5 秒) + STOP_TIMEOUT_SECONDS = 5 # 停止超時時間(秒) + MAX_STOP_CHECKS = int(STOP_TIMEOUT_SECONDS / STOP_CHECK_INTERVAL) # 最大停止檢查次數 # 輸出佇列大小限制 OUTPUT_QUEUE_MAX_SIZE = 1000 # 輸出佇列最大容量 @@ -128,7 +131,7 @@ def create_server( f"偵測失敗:loader_version 無法判斷,name={config.name}, path={config.path}, loader_type={config.loader_type}, minecraft_version={config.minecraft_version}, loader_version={config.loader_version}" ) except Exception as e: - LogUtils.error(f"自動偵測伺服器類型失敗: {e}", "ServerManager") + logger.error(f"自動偵測伺服器類型失敗: {e}") raise # 儲存配置 self.servers[config.name] = config @@ -151,7 +154,7 @@ def create_server( self.create_launch_script(config) return True except Exception as e: - LogUtils.error_exc(f"建立伺服器失敗: {e}", "ServerManager", e) + logger.exception(f"建立伺服器失敗: {e}") return False def _create_eula_file(self, server_path: Path) -> None: @@ -210,8 +213,8 @@ def create_launch_script(self, config: ServerConfig) -> None: java_command_str = ServerCommands.build_java_command(config, return_list=False) # 調試信息 - LogUtils.debug(f"Java 命令: {java_command_str}", "ServerManager") - LogUtils.debug(f"記憶體參數: {memory_args}", "ServerManager") + logger.debug(f"Java 命令: {java_command_str}") + logger.debug(f"記憶體參數: {memory_args}") # Windows 批次檔 bat_lines = [ @@ -256,17 +259,15 @@ def update_server_properties( if not config: return False # 取得 server_path - server_path = getattr(config, "path", None) - if not server_path: - server_path = getattr(config, "server_path", None) + server_path = getattr(config, "path", None) or getattr(config, "server_path", None) if not server_path: - LogUtils.error( + logger.error( f"找不到伺服器路徑,無法儲存 server.properties。config={config}" ) return False - properties_path = os.path.join(server_path, "server.properties") + properties_path = Path(server_path) / "server.properties" # 讀取原本的 server.properties - if os.path.exists(properties_path): + if properties_path.exists(): with open(properties_path, "r", encoding="utf-8") as f: lines = f.readlines() original = {} @@ -278,9 +279,7 @@ def update_server_properties( else: original = {} # 合併:只覆蓋有變動的欄位 - merged = dict(original) - for k, v in properties.items(): - merged[k] = v + merged = {**original, **properties} # 寫回 with open(properties_path, "w", encoding="utf-8") as f: f.write("# Minecraft server properties\n") @@ -289,9 +288,7 @@ def update_server_properties( f.write(f"{k}={v}\n") return True except Exception as e: - LogUtils.error_exc( - f"update_server_properties 儲存失敗: {e}", "ServerManager", e - ) + logger.exception(f"update_server_properties 儲存失敗: {e}") return False def start_server(self, server_name: str, parent=None) -> bool: @@ -328,7 +325,7 @@ def start_server(self, server_name: str, parent=None) -> bool: script_path = ServerDetectionUtils.find_startup_script(server_path) if script_path: - LogUtils.info(f"找到啟動腳本: {script_path}", "ServerManager") + logger.info(f"找到啟動腳本: {script_path}") else: UIUtils.show_error( "啟動腳本未找到", @@ -338,9 +335,9 @@ def start_server(self, server_name: str, parent=None) -> bool: return False # 增加調試信息 - LogUtils.debug(f"準備啟動伺服器: {server_name}", "ServerManager") - LogUtils.debug(f"腳本路徑: {script_path}", "ServerManager") - LogUtils.debug(f"工作目錄: {server_path}", "ServerManager") + logger.debug(f"準備啟動伺服器: {server_name}") + logger.debug(f"腳本路徑: {script_path}") + logger.debug(f"工作目錄: {server_path}") # 啟動伺服器 (Windows) try: @@ -350,8 +347,8 @@ def start_server(self, server_name: str, parent=None) -> bool: # 建構正確的命令 cmd = [str(abs_script_path)] - LogUtils.debug(f"執行命令: {cmd}", "ServerManager") - LogUtils.debug(f"工作目錄: {abs_server_path}", "ServerManager") + logger.debug(f"執行命令: {cmd}") + logger.debug(f"工作目錄: {abs_server_path}") # 在伺服器目錄中執行,支援標準輸入/輸出管道 process = subprocess.Popen( @@ -374,17 +371,15 @@ def start_server(self, server_name: str, parent=None) -> bool: time.sleep(self.STARTUP_CHECK_DELAY) # 等待進程啟動 poll_result = process.poll() if poll_result is not None: - LogUtils.error( - f"進程立即結束,返回碼: {poll_result}", "ServerManager" - ) + logger.error(f"進程立即結束,返回碼: {poll_result}") # 嘗試讀取錯誤信息 try: stdout, stderr = process.communicate(timeout=1) - LogUtils.error(f"標準輸出: {stdout}", "ServerManager") + logger.error(f"標準輸出: {stdout}") if stderr: - LogUtils.error(f"標準錯誤: {stderr}", "ServerManager") + logger.error(f"標準錯誤: {stderr}") except Exception as e: - LogUtils.error_exc(f"無法讀取錯誤信息: {e}", "ServerManager", e) + logger.exception(f"無法讀取錯誤信息: {e}") UIUtils.show_error( "啟動失敗", f"伺服器進程立即結束,返回碼: {poll_result}", @@ -412,7 +407,7 @@ def _output_reader(proc, output_data, name): raw = proc.stdout.buffer.readline() line = raw.decode("utf-8", errors="ignore") except Exception as e2: - LogUtils.error_exc( + logger.exception( f"{name} 嚴重編碼錯誤: {e2}", "output_reader", e2, @@ -426,7 +421,7 @@ def _output_reader(proc, output_data, name): if proc.poll() is not None: break except Exception as e: - LogUtils.error_exc(f"{name} 讀取錯誤: {e}", "output_reader", e) + get_logger().bind(component="output_reader").exception(f"{name} 讀取錯誤: {e}") t = threading.Thread( target=_output_reader, @@ -436,18 +431,17 @@ def _output_reader(proc, output_data, name): t.start() self.output_threads[server_name] = t - LogUtils.info( - f"伺服器 {server_name} 啟動成功,PID: {process.pid}", - "ServerManager", + logger.info( + f"伺服器 {server_name} 啟動成功,PID: {process.pid}" ) return True except FileNotFoundError as e: - LogUtils.error_exc(f"檔案路徑錯誤: {e}", "ServerManager", e) + logger.exception(f"檔案路徑錯誤: {e}") return False except Exception as e: - LogUtils.error_exc(f"啟動伺服器失敗: {e}", "ServerManager", e) + logger.exception(f"啟動伺服器失敗: {e}") UIUtils.show_error("啟動失敗", f"無法啟動伺服器 {server_name}。錯誤: {e}") return False @@ -477,7 +471,7 @@ def delete_server(self, server_name: str) -> bool: return True except Exception as e: - LogUtils.error_exc(f"刪除伺服器失敗: {e}", "ServerManager", e) + logger.exception(f"刪除伺服器失敗: {e}") UIUtils.show_error("刪除失敗", f"無法刪除伺服器 {server_name}。錯誤: {e}") return False @@ -497,7 +491,7 @@ def load_servers_config(self) -> None: for name, config_data in data.items(): self.servers[name] = ServerConfig(**config_data) except Exception as e: - LogUtils.error_exc(f"載入配置失敗: {e}", "ServerManager", e) + logger.exception(f"載入配置失敗: {e}") def save_servers_config(self) -> None: """ @@ -515,7 +509,7 @@ def save_servers_config(self) -> None: with open(self.config_file, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) except Exception as e: - LogUtils.error_exc(f"儲存配置失敗: {e}", "ServerManager", e) + logger.exception(f"儲存配置失敗: {e}") def get_default_server_properties(self) -> Dict[str, str]: """ @@ -609,7 +603,7 @@ def add_server(self, config: ServerConfig) -> bool: self.save_servers_config() return True except Exception as e: - LogUtils.error_exc(f"添加伺服器失敗: {e}", "ServerManager", e) + logger.exception(f"添加伺服器失敗: {e}") return False def load_server_properties(self, server_name: str) -> Dict[str, str]: @@ -641,7 +635,7 @@ def load_server_properties(self, server_name: str) -> Dict[str, str]: return properties except Exception as e: - LogUtils.error_exc(f"讀取 server.properties 失敗: {e}", "ServerManager", e) + logger.exception(f"讀取 server.properties 失敗: {e}") return {} def is_server_running(self, server_name: str) -> bool: @@ -676,7 +670,7 @@ def stop_server(self, server_name: str) -> bool: """ try: if server_name not in self.running_servers: - LogUtils.info(f"伺服器 {server_name} 未在運行", "ServerManager") + logger.info(f"伺服器 {server_name} 未在運行") return False process = self.running_servers[server_name] @@ -697,7 +691,7 @@ def stop_server(self, server_name: str) -> bool: process.kill() process.wait() - LogUtils.info(f"伺服器 {server_name} 已停止", "ServerManager") + logger.info(f"伺服器 {server_name} 已停止") # 清理所有相關資源 del self.running_servers[server_name] @@ -711,7 +705,7 @@ def stop_server(self, server_name: str) -> bool: return True except Exception as e: - LogUtils.error_exc(f"停止伺服器失敗: {e}", "ServerManager", e) + logger.exception(f"停止伺服器失敗: {e}") # 即使出現錯誤,也要清理記錄 if server_name in self.running_servers: del self.running_servers[server_name] @@ -769,9 +763,7 @@ def get_server_info(self, server_name: str) -> Optional[Dict]: elif "version" in properties: info["version"] = str(properties["version"]) except Exception as e: - LogUtils.error_exc( - f"讀取 server.properties 失敗: {e}", "ServerManager", e - ) + logger.exception(f"讀取 server.properties 失敗: {e}") if self.is_server_running(server_name): process = self.running_servers[server_name] @@ -785,7 +777,7 @@ def get_server_info(self, server_name: str) -> Optional[Dict]: if ps_process.name().lower().startswith("java"): all_candidates.append(ps_process) except Exception as e: - LogUtils.error_exc( + logger.exception( f"取得進程名稱失敗 pid={process.pid}: {e}", "ServerManager", e, @@ -799,7 +791,7 @@ def get_server_info(self, server_name: str) -> Optional[Dict]: ] ) except Exception as e: - LogUtils.error_exc( + logger.exception( f"取得子進程清單失敗 pid={process.pid}: {e}", "ServerManager", e, @@ -845,9 +837,7 @@ def is_server_java(proc): seconds = uptime_seconds % 60 info["uptime"] = f"{hours:02d}:{minutes:02d}:{seconds:02d}" except (psutil.NoSuchProcess, psutil.AccessDenied, Exception) as e: - LogUtils.warning( - f"無法獲取程序資訊,可能已停止: {e}", "ServerManager" - ) + logger.warning(f"無法獲取程序資訊,可能已停止: {e}") if server_name in self.running_servers: if self.running_servers[server_name].poll() is not None: del self.running_servers[server_name] @@ -855,7 +845,7 @@ def is_server_java(proc): return info except Exception as e: - LogUtils.error_exc(f"獲取伺服器資訊失敗: {e}", "ServerManager", e) + logger.exception(f"獲取伺服器資訊失敗: {e}") return None def send_command(self, server_name: str, command: str) -> bool: @@ -872,22 +862,20 @@ def send_command(self, server_name: str, command: str) -> bool: """ try: if server_name not in self.running_servers: - LogUtils.info(f"伺服器 {server_name} 未在運行", "ServerManager") + logger.info(f"伺服器 {server_name} 未在運行") return False process = self.running_servers[server_name] if process.poll() is not None: # 程序已結束 del self.running_servers[server_name] - LogUtils.info(f"伺服器 {server_name} 程序已結束", "ServerManager") + logger.info(f"伺服器 {server_name} 程序已結束") return False # 發送命令 if process.stdin: process.stdin.write(command + "\n") process.stdin.flush() - LogUtils.debug( - f"已向伺服器 {server_name} 發送命令: {command}", "ServerManager" - ) + logger.debug(f"已向伺服器 {server_name} 發送命令: {command}") # 如果是停止命令,啟動更頻繁的檢查(優化等待邏輯) if command.lower() == "stop": @@ -900,7 +888,7 @@ def check_stop(): # 程序已停止 if server_name in self.running_servers: del self.running_servers[server_name] - LogUtils.info( + logger.info( f"伺服器 {server_name} 已確認停止", "ServerManager", ) @@ -911,14 +899,14 @@ def check_stop(): return True else: - LogUtils.error( + logger.error( f"無法向伺服器 {server_name} 發送命令:stdin 不可用", "ServerManager", ) return False except Exception as e: - LogUtils.error_exc(f"發送命令失敗: {e}", "ServerManager", e) + logger.exception(f"發送命令失敗: {e}") return False def read_server_output(self, server_name: str, timeout: float = 0.1) -> List[str]: @@ -957,7 +945,7 @@ def read_server_output(self, server_name: str, timeout: float = 0.1) -> List[str return output_lines except Exception as e: - LogUtils.error_exc(f"讀取伺服器輸出失敗: {e}", "ServerManager", e) + logger.exception(f"讀取伺服器輸出失敗: {e}") return [] def get_server_log_file(self, server_name: str) -> Optional[Path]: @@ -992,5 +980,5 @@ def get_server_log_file(self, server_name: str) -> Optional[Path]: return None except Exception as e: - LogUtils.error_exc(f"獲取伺服器日誌檔案失敗: {e}", "ServerManager", e) + logger.exception(f"獲取伺服器日誌檔案失敗: {e}") return None diff --git a/src/core/version_manager.py b/src/core/version_manager.py index 234d054..73e6f2d 100644 --- a/src/core/version_manager.py +++ b/src/core/version_manager.py @@ -12,7 +12,10 @@ import concurrent.futures import os # ====== 專案內部模組 ====== -from src.utils import HTTPUtils, LogUtils, UIUtils, ensure_dir, get_cache_dir +from src.utils import HTTPUtils, UIUtils, ensure_dir, get_cache_dir +from src.utils.logger import get_logger + +logger = get_logger().bind(component="VersionManager") class MinecraftVersionManager: """ @@ -50,7 +53,7 @@ def _save_local_cache(self, versions: list) -> None: with open(self.cache_file, "w", encoding="utf-8") as f: json.dump(versions, f, ensure_ascii=False, indent=2) except Exception as e: - LogUtils.error_exc(f"寫入版本快取失敗: {e}", "VersionManager", e) + logger.exception(f"寫入版本快取失敗: {e}") # ====== 版本資料獲取 ====== # 從官方 API 獲取版本列表 @@ -102,7 +105,7 @@ def fetch_versions(self, max_workers: int = 8) -> list: self._save_local_cache(versions) return versions except Exception as e: - LogUtils.error_exc(f"無法取得版本資訊: {e}", "VersionManager", e) + logger.exception(f"無法取得版本資訊: {e}") UIUtils.show_error("取得版本失敗", f"無法從官方 API 獲取版本資訊: {e}") return [] @@ -128,6 +131,6 @@ def get_versions(self) -> list: # 直接回傳版本列表,快取中已只包含正式發布版本 return versions except Exception as e: - LogUtils.error_exc(f"獲取版本時發生錯誤: {e}", "VersionManager", e) + logger.exception(f"獲取版本時發生錯誤: {e}") UIUtils.show_error("獲取版本失敗", f"無法從快取獲取版本資訊: {e}") return [] diff --git a/src/main.py b/src/main.py index 0a3d4f8..69650c9 100644 --- a/src/main.py +++ b/src/main.py @@ -26,6 +26,10 @@ from src.core import LoaderManager, MinecraftVersionManager from src.ui import MinecraftServerManager from src.utils import UIUtils, get_settings_manager, set_ui_scale_factor +from src.utils.logger import get_logger + +# 初始化 logger +logger = get_logger().bind(component="Main") # ====== 訊息顯示工具 ====== @@ -39,9 +43,12 @@ def show_message(title, message, message_type="error"): UIUtils.show_info(title, message, topmost=True) return True except Exception: - icon = "錯誤" if message_type == "error" else "資訊" + # 退回到 logger 輸出 try: - print(f"[{icon}] {title}: {message}") + if message_type == "error": + logger.error(f"{title}: {message}") + else: + logger.info(f"{title}: {message}") except Exception: pass return False diff --git a/src/ui/create_server_frame.py b/src/ui/create_server_frame.py index 43f48fd..abc71f4 100644 --- a/src/ui/create_server_frame.py +++ b/src/ui/create_server_frame.py @@ -22,9 +22,12 @@ from ..core import LoaderManager, ServerManager, MinecraftVersionManager from ..models import ServerConfig from ..utils import java_utils -from ..utils import LogUtils, ProgressDialog, UIUtils, font_manager, get_font +from ..utils import ProgressDialog, UIUtils, font_manager, get_font +from ..utils.logger import get_logger from . import CustomDropdown +logger = get_logger().bind(component="CreateServerFrame") + # ====== 主要 UI Frame 類別 ====== class CreateServerFrame(ctk.CTkFrame): """ @@ -41,7 +44,7 @@ def get_system_memory_mb() -> int: try: return psutil.virtual_memory().total // (1024**2) except Exception as e: - LogUtils.error( + logger.bind(component="").error( f"無法獲取系統記憶體資訊: {e}\n{traceback.format_exc()}", "CreateServerFrame", ) @@ -100,7 +103,7 @@ def update_memory_warning(self) -> None: text="⚠️ 警告:記憶體設定必須為有效的整數", text_color=("red", "red") ) except Exception as e: - LogUtils.error( + logger.bind(component="").error( f"更新記憶體警告失敗: {e}\n{traceback.format_exc()}", "CreateServerFrame", ) @@ -482,7 +485,7 @@ def update_mc(): try: self.loader_manager.preload_loader_versions() except Exception as e: - LogUtils.error( + logger.bind(component="").error( f"預載入載入器版本失敗: {e}\n{traceback.format_exc()}", "CreateServerFrame", ) @@ -654,9 +657,7 @@ def reset_form(self): self.max_memory_var.set("2048") UIUtils.show_info("重設完成", "表單已重設為預設值", self.winfo_toplevel()) except Exception as e: - LogUtils.error( - f"重設表單失敗: {e}\n{traceback.format_exc()}", "CreateServerFrame" - ) + logger.error(f"重設表單失敗: {e}\n{traceback.format_exc()}") UIUtils.show_error( "重設失敗", f"重設表單時發生錯誤:\n{str(e)}", self.winfo_toplevel() ) @@ -832,7 +833,7 @@ def update_ui(): if hasattr(self, "_loading_key"): delattr(self, "_loading_key") except Exception as e: - LogUtils.error( + logger.bind(component="").error( f"更新載入器版本 UI 失敗: {e}\n{traceback.format_exc()}", "CreateServerFrame", ) @@ -841,7 +842,7 @@ def update_ui(): self.ui_queue.put(update_ui) except Exception as e: - LogUtils.error( + logger.bind(component="").error( f"載入載入器版本失敗: {e}\n{traceback.format_exc()}", "CreateServerFrame", ) @@ -856,8 +857,8 @@ def handle_error(): "disabled", ) except Exception as e2: - LogUtils.error_exc( - f"更新載入器版本失敗狀態 UI 失敗: {e2}", "CreateServerFrame", e2 + logger.exception( + f"更新載入器版本失敗狀態 UI 失敗: {e2}" ) if hasattr(self, "_loading_key"): delattr(self, "_loading_key") @@ -1012,9 +1013,7 @@ def create_progress(): return success = self.server_manager.create_server(config) if not success: - LogUtils.error( - f"建立伺服器基礎結構失敗 config: {config}", "CreateServerFrame" - ) + logger.error(f"建立伺服器基礎結構失敗 config: {config}") progress_dialog.close() raise Exception("建立伺服器基礎結構失敗") # 檢查 config 欄位 @@ -1040,7 +1039,7 @@ def create_progress(): # 下載成功後重建啟動腳本,確保 server.jar 存在 self.server_manager.create_launch_script(config) except Exception as e: - LogUtils.error( + logger.bind(component="").error( f"下載伺服器檔案失敗: {e}\n{traceback.format_exc()}", "CreateServerFrame", ) @@ -1059,7 +1058,7 @@ def on_success(): # 給使用者短暫時間看到 100% 再關閉(不阻塞背景執行緒) self.after(1000, on_success) except Exception as error: - LogUtils.error( + logger.bind(component="").error( f"建立伺服器時發生錯誤: {error}\n{traceback.format_exc()}", "CreateServerFrame", ) @@ -1135,7 +1134,7 @@ def do_download(): f"user_java_path: {getattr(self, 'java_path_var', None) and self.java_path_var.get()}\n" ) UIUtils.show_error("下載失敗", msg, topmost=True) - LogUtils.error( + logger.bind(component="").error( f"server_path: {server_path}\nconfig: {config}\n{traceback.format_exc()}", "CreateServerFrame", ) diff --git a/src/ui/custom_dropdown.py b/src/ui/custom_dropdown.py index a59d1cf..2be0f0a 100644 --- a/src/ui/custom_dropdown.py +++ b/src/ui/custom_dropdown.py @@ -13,7 +13,10 @@ import customtkinter as ctk # ====== 專案內部模組 ====== from ..utils import font_manager, get_font -from ..utils import UIUtils, LogUtils +from ..utils import UIUtils +from ..utils.logger import get_logger + +logger = get_logger().bind(component="CustomDropdown") class CustomDropdown(ctk.CTkFrame): """ @@ -329,9 +332,7 @@ def _select_option(self, value: str) -> None: try: self.command(value) except Exception as e: - LogUtils.error( - f"下拉選單回調錯誤: {e}\n{traceback.format_exc()}", "CustomDropdown" - ) + logger.error(f"下拉選單回調錯誤: {e}\n{traceback.format_exc()}") UIUtils.show_error( "錯誤", f"下拉選單回調錯誤: {e}", self.winfo_toplevel() ) @@ -372,7 +373,7 @@ def on_mouse_wheel(event): try: self.command(new_value) except Exception as e: - LogUtils.error( + logger.bind(component="").error( f"滾輪事件回調錯誤: {e}\n{traceback.format_exc()}", "CustomDropdown", ) @@ -421,7 +422,7 @@ def on_global_click(event): if not (in_dropdown or in_button): self._close_dropdown() except Exception as e: - LogUtils.error_exc(f"全域點擊處理失敗: {e}", "CustomDropdown", e) + logger.exception(f"全域點擊處理失敗: {e}") # 綁定到頂層視窗 toplevel = self.winfo_toplevel() @@ -435,7 +436,7 @@ def _unbind_global_click(self) -> None: toplevel = self.winfo_toplevel() toplevel.unbind("") except Exception as e: - LogUtils.error_exc(f"移除全域點擊綁定失敗: {e}", "CustomDropdown", e) + logger.exception(f"移除全域點擊綁定失敗: {e}") # 公共方法,模擬 CTkComboBox/CTkOptionMenu 的介面 def get(self) -> str: diff --git a/src/ui/main_window.py b/src/ui/main_window.py index 44cc6d2..b1a588b 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -34,7 +34,8 @@ get_dpi_scaled_size, get_font, ) -from ..utils import get_settings_manager, UIUtils, LogUtils +from ..utils import get_settings_manager, UIUtils +from ..utils.logger import get_logger from ..version_info import APP_VERSION, GITHUB_OWNER, GITHUB_REPO from . import ( CreateServerFrame, @@ -43,6 +44,8 @@ WindowPreferencesDialog, ) +logger = get_logger().bind(component="MainWindow") + class MinecraftServerManager: """ @@ -76,9 +79,7 @@ def _ensure_directory_exists(path: Path): try: path.mkdir(parents=True, exist_ok=True) except Exception as e: - LogUtils.error( - f"無法建立資料夾: {e}\n{traceback.format_exc()}", "MainWindow" - ) + logger.error(f"無法建立資料夾: {e}\n{traceback.format_exc()}") _fail_exit(f"無法建立資料夾: {e}") def _normalize_base_dir(path_str: str) -> str: @@ -93,7 +94,7 @@ def _normalize_base_dir(path_str: str) -> str: if parent: return parent except Exception as e: - LogUtils.debug(f"路徑正規化輕微錯誤 (base dir check): {e}", "MainWindow") + logger.debug(f"路徑正規化輕微錯誤 (base dir check): {e}", "MainWindow") return norm def _servers_dir_from_base(base_dir: str) -> str: @@ -125,9 +126,7 @@ def _prompt_for_directory() -> str: try: settings.set_servers_root(base_dir) except Exception as e: - LogUtils.error( - f"無法寫入設定: {e}\n{traceback.format_exc()}", "MainWindow" - ) + logger.error(f"無法寫入設定: {e}\n{traceback.format_exc()}") UIUtils.show_error("設定錯誤", f"無法寫入設定: {e}", self.root) else: stored = settings.get_servers_root() @@ -138,9 +137,7 @@ def _prompt_for_directory() -> str: try: settings.set_servers_root(base_dir) except Exception as e: - LogUtils.error( - f"無法寫入設定: {e}\n{traceback.format_exc()}", "MainWindow" - ) + logger.error(f"無法寫入設定: {e}\n{traceback.format_exc()}") UIUtils.show_error("設定錯誤", f"無法寫入設定: {e}", self.root) # 向後相容:若舊設定直接存的是 ...\servers,這裡會自動轉成 base_dir 並回寫 @@ -151,7 +148,7 @@ def _prompt_for_directory() -> str: ): settings.set_servers_root(base_dir) except Exception as e: - LogUtils.debug(f"向後相容性路徑檢查失敗: {e}", "MainWindow") + logger.debug(f"向後相容性路徑檢查失敗: {e}", "MainWindow") pass servers_root = _servers_dir_from_base(base_dir) @@ -174,15 +171,15 @@ def on_closing(self) -> None: Returns: None """ - LogUtils.debug("程式即將關閉!", "MainWindow") + logger.debug("程式即將關閉!", "MainWindow") try: # 儲存視窗狀態 - LogUtils.debug_window_state("儲存視窗狀態...") + get_logger().bind(component="WindowState").debug("儲存視窗狀態...") WindowManager.save_main_window_state(self.root) # 清理字體快取,避免銷毀時的錯誤 - LogUtils.debug("清理字體快取...", "MainWindow") + logger.debug("清理字體快取...", "MainWindow") cleanup_fonts() # 清理可能的子視窗 @@ -191,23 +188,18 @@ def on_closing(self) -> None: if isinstance(widget, (tk.Toplevel, ctk.CTkToplevel)): widget.destroy() except Exception as e: - LogUtils.error( - f"清理子視窗時發生錯誤: {e}\n{traceback.format_exc()}", - "MainWindow", + logger.error( + f"清理子視窗時發生錯誤: {e}\n{traceback.format_exc()}" ) except Exception as e: - LogUtils.error( - f"清理資源時發生錯誤: {e}\n{traceback.format_exc()}", "MainWindow" - ) + logger.error(f"清理資源時發生錯誤: {e}\n{traceback.format_exc()}") finally: # 最後銷毀主視窗 try: self.root.destroy() except Exception as e: - LogUtils.error( - f"銷毀主視窗時發生錯誤: {e}\n{traceback.format_exc()}", "MainWindow" - ) + logger.error(f"銷毀主視窗時發生錯誤: {e}\n{traceback.format_exc()}") # 強制退出 sys.exit(0) @@ -296,12 +288,12 @@ def preload_all_versions(self) -> None: """ def fetch_all(): - LogUtils.debug("預先抓取 Minecraft 所有版本...", "MainWindow") + logger.debug("預先抓取 Minecraft 所有版本...", "MainWindow") self.version_manager.fetch_versions() - LogUtils.debug("Minecraft 所有版本載入完成", "MainWindow") - LogUtils.debug("預先抓取所有載入器版本...", "MainWindow") + logger.debug("Minecraft 所有版本載入完成", "MainWindow") + logger.debug("預先抓取所有載入器版本...", "MainWindow") self.loader_manager.preload_loader_versions() - LogUtils.debug("所有載入器版本載入完成", "MainWindow") + logger.debug("所有載入器版本載入完成", "MainWindow") threading.Thread(target=fetch_all, daemon=True).start() @@ -326,7 +318,7 @@ def load_versions(): ) except Exception as e: error_msg = f"載入版本資訊失敗: {e}\n{traceback.format_exc()}" - self.ui_queue.put(lambda: LogUtils.error(error_msg, "MainWindow")) + self.ui_queue.put(lambda: logger.error(error_msg)) threading.Thread(target=load_versions, daemon=True).start() @@ -456,9 +448,7 @@ def _check_for_updates(self, show_msg: bool = True) -> None: parent=self.root, ) except Exception as e: - LogUtils.error( - f"自動更新檢查失敗: {e}\n{traceback.format_exc()}", "MainWindow" - ) + logger.error(f"自動更新檢查失敗: {e}\n{traceback.format_exc()}") if show_msg: UIUtils.show_error("更新檢查失敗", f"無法檢查更新:{e}", self.root) @@ -608,7 +598,7 @@ def create_main_content(self) -> None: self.nav_container.grid_propagate(False) self.nav_container.configure(width=int(self._nav_full_width)) except Exception as e: - LogUtils.debug(f"設定導航欄寬度失敗: {e}", "MainWindow") + logger.debug(f"設定導航欄寬度失敗: {e}", "MainWindow") # 右側內容容器 self.content_container = ctk.CTkFrame(main_container, fg_color="transparent") @@ -639,7 +629,7 @@ def create_main_content(self) -> None: try: self.create_server_frame.grid(row=0, column=0, sticky="nsew") except Exception as e: - LogUtils.debug(f"CreateServerFrame grid 設置失敗: {e}", "MainWindow") + logger.debug(f"CreateServerFrame grid 設置失敗: {e}", "MainWindow") # 延後建立,首次切換頁面時才初始化 self.manage_server_frame = None @@ -716,7 +706,7 @@ def _create_sidebar_footer(self, parent, *, mini: bool) -> None: ) version_label.pack(anchor="w") except Exception as e: - LogUtils.error_exc(f"建立側邊欄底部資訊失敗: {e}", "MainWindow", e) + logger.exception(f"建立側邊欄底部資訊失敗: {e}") def create_nav_button( self, parent, icon, title, description, command @@ -801,7 +791,7 @@ def configure_button_colors(btn_widget: ctk.CTkButton, colors) -> None: fg_color=colors["fg"], hover_color=colors["hover"] ) except Exception as e: - LogUtils.error_exc(f"設定導航按鈕顏色失敗: {e}", "MainWindow", e) + logger.exception(f"設定導航按鈕顏色失敗: {e}") # 只重置前一個 + 設定新的,避免每次遍歷所有導航按鈕造成撕裂 prev_title = getattr(self, "active_nav_title", None) @@ -829,9 +819,9 @@ def toggle_sidebar(self) -> None: try: self.root.after_cancel(job) except Exception as e: - LogUtils.debug(f"取消 toggle_sidebar job 失敗: {e}", "MainWindow") + logger.debug(f"取消 toggle_sidebar job 失敗: {e}", "MainWindow") except Exception as e: - LogUtils.debug(f"toggle_sidebar 發生錯誤: {e}", "MainWindow") + logger.debug(f"toggle_sidebar 發生錯誤: {e}", "MainWindow") self._sidebar_toggle_job = self.root.after_idle(self._apply_sidebar_visibility) def _apply_sidebar_visibility(self) -> None: @@ -850,14 +840,14 @@ def _apply_sidebar_visibility(self) -> None: if nav is not None: nav.configure(width=int(self._nav_mini_width)) except Exception as e: - LogUtils.debug(f"設定 Nav 寬度 (Mini) 失敗: {e}", "MainWindow") + logger.debug(f"設定 Nav 寬度 (Mini) 失敗: {e}", "MainWindow") pass if hasattr(self, "sidebar") and self.sidebar: try: self.sidebar.grid_remove() except Exception as e: - LogUtils.debug(f"隱藏 sidebar 失敗: {e}", "MainWindow") + logger.debug(f"隱藏 sidebar 失敗: {e}", "MainWindow") pass self.create_mini_sidebar() else: @@ -873,25 +863,23 @@ def _apply_sidebar_visibility(self) -> None: if nav is not None: nav.configure(width=int(self._nav_full_width)) except Exception as e: - LogUtils.debug(f"設定 Nav 寬度 (Full) 失敗: {e}", "MainWindow") + logger.debug(f"設定 Nav 寬度 (Full) 失敗: {e}", "MainWindow") pass if hasattr(self, "mini_sidebar") and self.mini_sidebar: try: self.mini_sidebar.grid_remove() except Exception as e: - LogUtils.debug(f"隱藏 mini_sidebar 失敗: {e}", "MainWindow") + logger.debug(f"隱藏 mini_sidebar 失敗: {e}", "MainWindow") pass if hasattr(self, "sidebar") and self.sidebar: try: self.sidebar.grid() except Exception as e: - LogUtils.debug(f"顯示 sidebar 失敗: {e}", "MainWindow") + logger.debug(f"顯示 sidebar 失敗: {e}", "MainWindow") pass except Exception as e: - LogUtils.error( - f"切換側邊欄失敗: {e}\n{traceback.format_exc()}", "MainWindow" - ) + logger.error(f"切換側邊欄失敗: {e}\n{traceback.format_exc()}") def create_mini_sidebar(self) -> None: """ @@ -905,11 +893,11 @@ def create_mini_sidebar(self) -> None: try: self.mini_sidebar.grid(row=0, column=0, sticky="nsew") except Exception as e: - LogUtils.debug(f"重顯示 mini_sidebar 失敗: {e}", "MainWindow") + logger.debug(f"重顯示 mini_sidebar 失敗: {e}", "MainWindow") pass return except Exception as e: - LogUtils.debug(f"檢查 mini_sidebar 失敗: {e}", "MainWindow") + logger.debug(f"檢查 mini_sidebar 失敗: {e}", "MainWindow") pass # 使用簡化的迷你側邊欄 @@ -1036,9 +1024,8 @@ def _refresh_and_optionally_select() -> None: self.manage_server_frame.update_selection() break except Exception as e: - LogUtils.error( - f"切換到管理伺服器頁面後刷新失敗: {e}\n{traceback.format_exc()}", - "MainWindow", + logger.error( + f"切換到管理伺服器頁面後刷新失敗: {e}\n{traceback.format_exc()}" ) # coalesce:快速連點切換時取消舊的 refresh job @@ -1047,7 +1034,7 @@ def _refresh_and_optionally_select() -> None: if old_job: self.root.after_cancel(old_job) except Exception as e: - LogUtils.debug(f"取消 _nav_refresh_job 失敗: {e}", "MainWindow") + logger.debug(f"取消 _nav_refresh_job 失敗: {e}", "MainWindow") pass self._nav_refresh_job = self.root.after(0, _refresh_and_optionally_select) @@ -1169,7 +1156,7 @@ def _handle_import_choice(self, choice_type) -> None: if server_name: self._finalize_import(path, server_name) except Exception as e: - LogUtils.error(f"匯入錯誤: {e}\n{traceback.format_exc()}", "MainWindow") + logger.error(f"匯入錯誤: {e}\n{traceback.format_exc()}", "MainWindow") UIUtils.show_error("匯入錯誤", str(e), self.root) def _select_server_folder(self) -> Optional[Path]: @@ -1337,7 +1324,7 @@ def _finalize_import(self, source_path: Path, server_name: str) -> None: self.show_manage_server(auto_select=server_name) except Exception as e: - LogUtils.error(f"匯入失敗: {e}\n{traceback.format_exc()}", "MainWindow") + logger.error(f"匯入失敗: {e}\n{traceback.format_exc()}", "MainWindow") UIUtils.show_error( "匯入失敗", f"伺服器 '{server_name}' 匯入失敗: {e}", self.root ) @@ -1354,7 +1341,7 @@ def hide_all_frames(self) -> None: try: self.create_server_frame.pack_forget() except Exception as e: - LogUtils.debug(f"隱藏 create_server_frame 失敗: {e}", "MainWindow") + logger.debug(f"隱藏 create_server_frame 失敗: {e}", "MainWindow") if getattr(self, "manage_server_frame", None) is not None: try: @@ -1363,7 +1350,7 @@ def hide_all_frames(self) -> None: try: self.manage_server_frame.pack_forget() except Exception as e: - LogUtils.debug(f"隱藏 manage_server_frame 失敗: {e}", "MainWindow") + logger.debug(f"隱藏 manage_server_frame 失敗: {e}", "MainWindow") # 隱藏模組管理頁面 if getattr(self, "mod_frame", None) is not None: @@ -1374,7 +1361,7 @@ def hide_all_frames(self) -> None: except Exception: frame.pack_forget() except Exception as e: - LogUtils.debug(f"隱藏 mod_frame 失敗: {e}", "MainWindow") + logger.debug(f"隱藏 mod_frame 失敗: {e}", "MainWindow") def open_servers_folder(self) -> None: """ @@ -1389,7 +1376,7 @@ def open_servers_folder(self) -> None: try: os.startfile(str(folder_path)) except Exception as e: - LogUtils.error(f"無法開啟路徑: {e}\n{traceback.format_exc()}", "MainWindow") + logger.error(f"無法開啟路徑: {e}\n{traceback.format_exc()}", "MainWindow") UIUtils.show_error("錯誤", f"無法開啟路徑: {e}", self.root) def show_about(self) -> None: @@ -1578,7 +1565,7 @@ def _show_window_preferences(self) -> None: def on_settings_changed(): """設定變更回調""" # 可以在這裡添加設定變更後的處理邏輯 - LogUtils.debug("視窗偏好設定已變更", "MainWindow") + logger.debug("視窗偏好設定已變更", "MainWindow") # 顯示視窗偏好設定對話框 WindowPreferencesDialog(self.root, on_settings_changed) @@ -1621,7 +1608,7 @@ def on_server_selected(self, server_name: str) -> None: """ # 目前僅作為記錄用途,未來可擴展為狀態同步等功能 # Currently used only for logging, can be extended for state synchronization in the future - LogUtils.info(f"選中伺服器: {server_name}") + logger.info(f"選中伺服器: {server_name}") def complete_initialization(self, server_config: ServerConfig, init_dialog) -> None: """ @@ -1645,9 +1632,8 @@ def complete_initialization(self, server_config: ServerConfig, init_dialog) -> N properties = ServerPropertiesHelper.load_properties(properties_file) server_config.properties = properties except Exception as e: - LogUtils.error( - f"初始化後讀取 server.properties 失敗: {e}\n{traceback.format_exc()}", - "MainWindow", + logger.error( + f"初始化後讀取 server.properties 失敗: {e}\n{traceback.format_exc()}" ) # 直接提示初始化完成,並自動跳轉到管理伺服器頁面 @@ -1693,7 +1679,7 @@ def _enqueue_console(self, text: str) -> None: try: self._console_queue.put_nowait(text) except Exception as e: - LogUtils.error_exc(f"加入 console queue 失敗: {e}", "InitServerDialog", e) + get_logger().bind(component="InitServerDialog").exception(f"加入 console queue 失敗: {e}") def _start_console_pump(self) -> None: if self._console_pump_job is not None: @@ -1856,12 +1842,10 @@ def _terminate_server_process(self) -> None: try: self.server_process.wait(timeout=5) except Exception as e: - LogUtils.error_exc( - f"等待程序終止逾時/失敗,改用 kill: {e}", "InitServerDialog", e - ) + logger.exception(f"等待程序終止逾時/失敗,改用 kill: {e}") self.server_process.kill() except Exception as e: - LogUtils.error_exc(f"終止伺服器程序失敗: {e}", "InitServerDialog", e) + get_logger().bind(component="InitServerDialog").exception(f"終止伺服器程序失敗: {e}") def _timeout_force_close(self) -> None: """超時強制關閉""" @@ -1917,9 +1901,8 @@ def _run_server(self) -> None: self._handle_server_completion() except Exception as e: - LogUtils.error( - f"伺服器啟動失敗: {e}\n{traceback.format_exc()}", - "ServerInitializationDialog", + get_logger().bind(component="ServerInitializationDialog").error( + f"伺服器啟動失敗: {e}\n{traceback.format_exc()}" ) self._handle_server_error(str(e)) @@ -1972,15 +1955,12 @@ def _extract_java_command_from_bat(self, start_bat: Path) -> Optional[List[str]] # 去除尾端的 %* 或其他 shell 變數符號 cleaned = re.sub(r"\s*[%$]\*?$", "", line.strip()) java_cmd = cleaned.split() - LogUtils.debug( - f"forge_java_command: {java_cmd}", - "ServerInitializationDialog", + get_logger().bind(component="ServerInitializationDialog").debug( + f"forge_java_command: {java_cmd}" ) return java_cmd except Exception as e: - LogUtils.error_exc( - f"提取 Java 命令失敗: {e}", "ServerInitializationDialog", e - ) + logger.exception(f"提取 Java 命令失敗: {e}") return None def _monitor_server_output(self) -> None: diff --git a/src/ui/manage_server_frame.py b/src/ui/manage_server_frame.py index 7863c73..2f4db15 100644 --- a/src/ui/manage_server_frame.py +++ b/src/ui/manage_server_frame.py @@ -22,9 +22,12 @@ # ====== 專案內部模組 ====== from ..core import ServerConfig, ServerManager from ..utils import MemoryUtils, ServerDetectionUtils, ServerOperations, get_font -from ..utils import LogUtils, UIUtils +from ..utils import UIUtils +from ..utils.logger import get_logger from . import ServerMonitorWindow, ServerPropertiesDialog +logger = get_logger().bind(component="ManageServerFrame") + class ManageServerFrame(ctk.CTkFrame): """ 管理伺服器頁面 @@ -79,10 +82,8 @@ def _schedule_post_action_updates( try: self.after_cancel(job_id) except Exception as e: - LogUtils.error_exc( - f"取消排程失敗 {attr_name}={job_id}: {e}", - "ManageServerFrame", - e, + logger.exception( + f"取消排程失敗 {attr_name}={job_id}: {e}" ) setattr(self, attr_name, None) @@ -99,9 +100,7 @@ def _schedule_refresh(self, delay_ms: int) -> None: try: self.after_cancel(job_id) except Exception as e: - LogUtils.error_exc( - f"取消刷新排程失敗 job={job_id}: {e}", "ManageServerFrame", e - ) + logger.exception(f"取消刷新排程失敗 job={job_id}: {e}") self._delayed_refresh_job = self.after(delay_ms, self.refresh_servers) def create_widgets(self) -> None: @@ -367,7 +366,7 @@ def reset_backup_path(self) -> None: try: os.makedirs(new_backup_path, exist_ok=True) except Exception as e: - LogUtils.error( + logger.bind(component="").error( f"無法建立備份資料夾: {e}\n{traceback.format_exc()}", "ManageServerFrame", ) @@ -436,7 +435,7 @@ def open_backup_folder(self) -> None: try: os.startfile(config.backup_path) except Exception as e: - LogUtils.error( + logger.bind(component="").error( f"無法開啟備份資料夾: {e}\n{traceback.format_exc()}", "ManageServerFrame", ) @@ -498,9 +497,7 @@ def get_backup_status(self, server_name: str) -> str: return "📁 已設定路徑" except Exception as e: - LogUtils.error( - f"檢查備份狀態失敗: {e}\n{traceback.format_exc()}", "ManageServerFrame" - ) + logger.error(f"檢查備份狀態失敗: {e}\n{traceback.format_exc()}") return "❓ 檢查失敗" def create_actions(self, parent) -> None: @@ -572,7 +569,7 @@ def browse_path(self) -> None: try: servers_root = self.set_servers_root(base_dir) except Exception as e: - LogUtils.error( + logger.bind(component="").error( f"寫入伺服器路徑設定失敗: {e}\n{traceback.format_exc()}", "ManageServerFrame", ) @@ -589,7 +586,7 @@ def browse_path(self) -> None: try: servers_root_path.mkdir(parents=True, exist_ok=True) except Exception as e: - LogUtils.error( + logger.bind(component="").error( f"無法建立 servers 資料夾: {e}\n{traceback.format_exc()}", "ManageServerFrame", ) @@ -626,9 +623,7 @@ def task(): lambda: self._detect_servers_callback(count, show_message) ) except Exception as error: - LogUtils.error( - f"偵測失敗: {error}\n{traceback.format_exc()}", "ManageServerFrame" - ) + logger.error(f"偵測失敗: {error}\n{traceback.format_exc()}") self.ui_queue.put( lambda: UIUtils.show_error( "錯誤", f"偵測失敗: {error}", self.winfo_toplevel() @@ -760,7 +755,7 @@ def task(): server_data = self._refresh_servers_task() self.ui_queue.put(lambda: self._refresh_servers_callback(server_data)) except Exception as e: - LogUtils.error( + logger.bind(component="").error( f"重新整理伺服器列表失敗: {e}\n{traceback.format_exc()}", "ManageServerFrame", ) @@ -1072,9 +1067,7 @@ def open_server_folder(self) -> None: try: os.startfile(path) except Exception as e: - LogUtils.error( - f"無法開啟資料夾: {e}\n{traceback.format_exc()}", "ManageServerFrame" - ) + logger.error(f"無法開啟資料夾: {e}\n{traceback.format_exc()}") UIUtils.show_error("錯誤", f"無法開啟資料夾: {e}", self.winfo_toplevel()) def delete_server(self) -> None: @@ -1137,7 +1130,7 @@ def delete_server(self) -> None: self.winfo_toplevel(), ) except Exception as e: - LogUtils.error( + logger.bind(component="").error( f"刪除備份失敗: {e}\n{traceback.format_exc()}", "ManageServerFrame", ) @@ -1217,7 +1210,7 @@ def backup_server(self) -> None: try: os.makedirs(backup_location, exist_ok=True) except Exception as e: - LogUtils.error( + logger.bind(component="").error( f"無法建立備份資料夾: {e}\n{traceback.format_exc()}", "ManageServerFrame", ) @@ -1332,7 +1325,7 @@ def backup_server(self) -> None: self._schedule_refresh(5000) except Exception as e: - LogUtils.error( + logger.bind(component="").error( f"執行備份批次檔失敗: {e}\n{traceback.format_exc()}", "ManageServerFrame", ) @@ -1341,7 +1334,7 @@ def backup_server(self) -> None: ) except Exception as e: - LogUtils.error( + logger.bind(component="").error( f"建立備份批次檔失敗: {e}\n{traceback.format_exc()}", "ManageServerFrame", ) diff --git a/src/ui/mod_management.py b/src/ui/mod_management.py index 343464c..beaa24a 100644 --- a/src/ui/mod_management.py +++ b/src/ui/mod_management.py @@ -24,10 +24,13 @@ # ====== 專案內部模組 ====== from . import CustomDropdown from ..core import MinecraftVersionManager, ModManager, ModStatus -from ..utils import HTTPUtils, LogUtils, UIUtils, get_settings_manager +from ..utils import HTTPUtils, UIUtils, get_settings_manager +from ..utils.logger import get_logger from ..utils import font_manager, get_dpi_scaled_size, get_font from ..version_info import APP_VERSION, GITHUB_OWNER, GITHUB_REPO +logger = get_logger().bind(component="ModManagement") + # 提供同步查詢的 search_mods_online 及 enhance_local_mod 包裝 def search_mods_online( query, minecraft_version=None, loader=None, categories=None, sort_by="relevance" @@ -66,7 +69,7 @@ def search_mods_online( full_url = url + "?" + urllib.parse.urlencode(params) response = HTTPUtils.get_json(url=full_url, headers=headers, timeout=10) if not response: - LogUtils.error("Modrinth API request failed") + logger.error("Modrinth API request failed") return [] hits = response.get("hits", []) mods = [] @@ -194,9 +197,7 @@ def update_status(self, message: str) -> None: else: self.status_label.configure(text=message) except Exception as e: - LogUtils.error( - f"更新狀態失敗: {e}\n{traceback.format_exc()}", "ModManagementFrame" - ) + logger.error(f"更新狀態失敗: {e}\n{traceback.format_exc()}") def update_status_safe(self, message: str) -> None: """ @@ -344,9 +345,8 @@ def on_tab_changed(self, event=None) -> None: elif current_tab == 1: # 線上瀏覽頁面 pass # 線上頁面不需要自動重新整理 except Exception as e: - LogUtils.error( - f"處理頁籤切換事件失敗: {e}\n{traceback.format_exc()}", - "ModManagementFrame", + logger.error( + f"處理頁籤切換事件失敗: {e}\n{traceback.format_exc()}" ) def create_local_toolbar(self) -> None: @@ -510,7 +510,7 @@ def load_thread(): self.enhance_local_mods() self.update_status_safe(f"找到 {len(mods)} 個本地模組 (已重新整理)") except Exception as e: - LogUtils.error( + logger.bind(component="").error( f"強制掃描失敗: {e}\n{traceback.format_exc()}", "ModManagementFrame", ) @@ -720,7 +720,7 @@ def do_save(): if result: os.startfile(file_path) except Exception as e: - LogUtils.error( + logger.bind(component="").error( f"開啟檔案失敗: {e}\n{traceback.format_exc()}", "ModManagementFrame", ) @@ -754,9 +754,7 @@ def do_save(): dialog.bind("", lambda e: dialog.destroy()) except Exception as e: - LogUtils.error( - f"匯出對話框錯誤: {e}\n{traceback.format_exc()}", "ModManagementFrame" - ) + logger.error(f"匯出對話框錯誤: {e}\n{traceback.format_exc()}") UIUtils.show_error("匯出對話框錯誤", str(e), self.parent) def create_status_bar(self) -> None: @@ -814,7 +812,7 @@ def load_servers(self) -> None: self.on_server_changed() except Exception as e: - LogUtils.error( + logger.bind(component="").error( f"載入伺服器列表失敗: {e}\n{traceback.format_exc()}", "ModManagementFrame", ) @@ -852,9 +850,7 @@ def on_server_changed(self, event=None) -> None: self.on_server_selected(server_name) except Exception as e: - LogUtils.error( - f"切換伺服器失敗: {e}\n{traceback.format_exc()}", "ModManagementFrame" - ) + logger.error(f"切換伺服器失敗: {e}\n{traceback.format_exc()}") UIUtils.show_error("錯誤", f"切換伺服器失敗: {e}", self.parent) def load_local_mods(self) -> None: @@ -888,9 +884,7 @@ def load_thread(): self.enhance_local_mods() self.update_status_safe(f"找到 {len(mods)} 個本地模組") except Exception as e: - LogUtils.error( - f"掃描失敗: {e}\n{traceback.format_exc()}", "ModManagementFrame" - ) + logger.error(f"掃描失敗: {e}\n{traceback.format_exc()}") self.update_progress_safe(0) self.update_status_safe(f"掃描失敗: {e}") @@ -904,7 +898,7 @@ def enhance_single(mod): if enhanced: self.enhanced_mods_cache[mod.filename] = enhanced except Exception as e: - LogUtils.error( + logger.bind(component="").error( f"模組 {mod.filename} 資訊失敗: {e}\n{traceback.format_exc()}", "ModManagementFrame", ) @@ -1134,12 +1128,12 @@ def _set_controls_enabled(enabled: bool) -> None: if hasattr(self, "select_all_btn") and self.select_all_btn: self.select_all_btn.configure(state=state) except Exception as e: - LogUtils.debug(f"設定全選按鈕狀態失敗: {e}", "ModManagement") + logger.debug(f"設定全選按鈕狀態失敗: {e}", "ModManagement") try: if hasattr(self, "batch_toggle_btn") and self.batch_toggle_btn: self.batch_toggle_btn.configure(state=state) except Exception as e: - LogUtils.debug(f"設定批量切換按鈕狀態失敗: {e}", "ModManagement") + logger.debug(f"設定批量切換按鈕狀態失敗: {e}", "ModManagement") # 切換狀態(背景執行 rename),成功後僅更新該列顯示 def do_toggle() -> None: @@ -1222,9 +1216,7 @@ def apply_ui_update() -> None: except Exception as e: if hasattr(self, "status_label") and self.status_label.winfo_exists(): self.update_status(f"操作失敗: {e}") - LogUtils.error( - f"切換模組狀態錯誤: {e}\n{traceback.format_exc()}", "ModManagementFrame" - ) + logger.error(f"切換模組狀態錯誤: {e}\n{traceback.format_exc()}") def filter_local_mods(self, *args) -> None: """篩選本地模組""" @@ -1274,9 +1266,7 @@ def import_mod_file(self) -> None: self.load_local_mods() except Exception as e: - LogUtils.error( - f"匯入模組失敗: {e}\n{traceback.format_exc()}", "ModManagementFrame" - ) + logger.error(f"匯入模組失敗: {e}\n{traceback.format_exc()}") UIUtils.show_error("錯誤", f"匯入模組失敗: {e}", self.parent) def open_mods_folder(self) -> None: @@ -1310,9 +1300,7 @@ def copy_mod_info(self) -> None: if hasattr(self, "status_label") and self.status_label.winfo_exists(): self.update_status("模組資訊已複製到剪貼板") except Exception as e: - LogUtils.error( - f"複製模組資訊失敗: {e}\n{traceback.format_exc()}", "ModManagementFrame" - ) + logger.error(f"複製模組資訊失敗: {e}\n{traceback.format_exc()}") if hasattr(self, "status_label") and self.status_label.winfo_exists(): self.status_label.configure(text=f"複製失敗: {e}") @@ -1365,9 +1353,7 @@ def show_in_explorer(self) -> None: self.status_label.configure(text="無法識別模組檔案") except Exception as e: - LogUtils.error( - f"開啟檔案總管失敗: {e}\n{traceback.format_exc()}", "ModManagementFrame" - ) + logger.error(f"開啟檔案總管失敗: {e}\n{traceback.format_exc()}") if hasattr(self, "status_label") and self.status_label.winfo_exists(): self.status_label.configure(text=f"開啟檔案總管失敗: {e}") @@ -1431,9 +1417,7 @@ def delete_local_mod(self) -> None: self.status_label.configure(text="無法識別要刪除的模組") except Exception as e: - LogUtils.error( - f"刪除模組失敗: {e}\n{traceback.format_exc()}", "ModManagementFrame" - ) + logger.error(f"刪除模組失敗: {e}\n{traceback.format_exc()}") if hasattr(self, "status_label") and self.status_label.winfo_exists(): self.status_label.configure(text=f"刪除失敗: {e}") UIUtils.show_error("錯誤", f"刪除模組失敗: {e}", self.parent) @@ -1443,7 +1427,7 @@ def get_frame(self) -> Optional[ctk.CTkFrame]: if hasattr(self, "main_frame") and self.main_frame: return self.main_frame else: - LogUtils.debug("主框架未初始化") + logger.debug("主框架未初始化") return None def toggle_select_all(self) -> None: @@ -1466,9 +1450,7 @@ def toggle_select_all(self) -> None: if hasattr(self.select_all_btn, "configure"): self.select_all_btn.configure(text="☑️ 全選") except Exception as e: - LogUtils.error_exc( - f"更新全選按鈕文字失敗: {e}", "ModManagementFrame", e - ) + logger.exception(f"更新全選按鈕文字失敗: {e}") else: # 全選 self.local_tree.selection_set(*items) @@ -1486,16 +1468,12 @@ def toggle_select_all(self) -> None: if hasattr(self.select_all_btn, "configure"): self.select_all_btn.configure(text="❌ 取消全選") except Exception as e: - LogUtils.error_exc( - f"更新全選按鈕文字失敗: {e}", "ModManagementFrame", e - ) + logger.exception(f"更新全選按鈕文字失敗: {e}") # 更新狀態顯示 self.update_selection_status() except Exception as e: - LogUtils.error( - f"切換全選失敗: {e}\n{traceback.format_exc()}", "ModManagementFrame" - ) + logger.error(f"切換全選失敗: {e}\n{traceback.format_exc()}") def batch_toggle_selected(self) -> None: """批量切換選中模組的啟用/停用狀態""" @@ -1541,12 +1519,12 @@ def _set_controls_enabled(enabled: bool) -> None: if hasattr(self, "select_all_btn") and self.select_all_btn: self.select_all_btn.configure(state=state) except Exception as e: - LogUtils.debug(f"設定全選按鈕狀態失敗: {e}", "ModManagement") + logger.debug(f"設定全選按鈕狀態失敗: {e}", "ModManagement") try: if hasattr(self, "batch_toggle_btn") and self.batch_toggle_btn: self.batch_toggle_btn.configure(state=state) except Exception as e: - LogUtils.debug(f"設定批量切換按鈕狀態失敗: {e}", "ModManagement") + logger.debug(f"設定批量切換按鈕狀態失敗: {e}", "ModManagement") def do_batch(): total = len(selected_pairs) @@ -1646,7 +1624,7 @@ def apply_row_update( ) except Exception as e: # 批量過程中 UI 更新失敗不阻塞主流程 - LogUtils.debug(f"批量更新 UI row 失敗: {e}", "ModManagement") + logger.debug(f"批量更新 UI row 失敗: {e}", "ModManagement") self.ui_queue.put(apply_row_update) else: @@ -1667,9 +1645,7 @@ def apply_row_update( threading.Thread(target=do_batch, daemon=True).start() except Exception as e: - LogUtils.error( - f"批量操作失敗: {e}\n{traceback.format_exc()}", "ModManagementFrame" - ) + logger.error(f"批量操作失敗: {e}\n{traceback.format_exc()}") self.update_progress_safe(0) UIUtils.show_error("錯誤", f"批量操作失敗: {e}", self.parent) @@ -1688,9 +1664,7 @@ def update_selection_status(self) -> None: self.status_label.configure(text=status_text) except Exception as e: - LogUtils.error( - f"更新選擇狀態失敗: {e}\n{traceback.format_exc()}", "ModManagementFrame" - ) + logger.error(f"更新選擇狀態失敗: {e}\n{traceback.format_exc()}") def on_tree_selection_changed(self, event=None) -> None: """樹狀檢視選擇變化事件""" @@ -1718,34 +1692,28 @@ def on_tree_selection_changed(self, event=None) -> None: if hasattr(self.select_all_btn, "configure"): self.select_all_btn.configure(text="☑️ 全選") except Exception as e: - LogUtils.error_exc( - f"更新全選按鈕文字失敗: {e}", "ModManagementFrame", e - ) + logger.exception(f"更新全選按鈕文字失敗: {e}") elif selected_items_count == total_items: self.all_selected = True try: if hasattr(self.select_all_btn, "configure"): self.select_all_btn.configure(text="❌ 取消全選") except Exception as e: - LogUtils.error_exc( - f"更新全選按鈕文字失敗: {e}", "ModManagementFrame", e - ) + logger.exception(f"更新全選按鈕文字失敗: {e}") except Exception as e: - LogUtils.error( - f"處理選擇變化失敗: {e}\n{traceback.format_exc()}", "ModManagementFrame" - ) + logger.error(f"處理選擇變化失敗: {e}\n{traceback.format_exc()}") def pack(self, **kwargs) -> None: """讓框架可以被 pack""" if hasattr(self, "main_frame") and self.main_frame: self.main_frame.pack(**kwargs) else: - LogUtils.debug("主框架未初始化,無法打包", "ModManagementFrame") + logger.debug("主框架未初始化,無法打包", "ModManagementFrame") def grid(self, **kwargs) -> None: """讓框架可以被 grid""" if hasattr(self, "main_frame") and self.main_frame: self.main_frame.grid(**kwargs) else: - LogUtils.debug("主框架未初始化,無法佈局", "ModManagementFrame") + logger.debug("主框架未初始化,無法佈局", "ModManagementFrame") diff --git a/src/ui/server_monitor_window.py b/src/ui/server_monitor_window.py index dad5aa7..9ac46af 100644 --- a/src/ui/server_monitor_window.py +++ b/src/ui/server_monitor_window.py @@ -22,7 +22,10 @@ get_dpi_scaled_size, get_font, ) -from ..utils import UIUtils, LogUtils +from ..utils import UIUtils +from ..utils.logger import get_logger + +logger = get_logger().bind(component="ServerMonitorWindow") class ServerMonitorWindow: """ @@ -55,10 +58,8 @@ def stop_auto_refresh(self) -> None: try: self.window.after_cancel(self._auto_refresh_id) except Exception as e: - LogUtils.error_exc( - f"停止自動刷新時取消 after 失敗(視窗可能已關閉): {e}", - "ServerMonitorWindow", - e, + logger.exception( + f"停止自動刷新時取消 after 失敗(視窗可能已關閉): {e}" ) self._auto_refresh_id = None @@ -112,9 +113,8 @@ def safe_update_widget( widget = getattr(self, widget_name) UIUtils.safe_update_widget(widget, update_func, *args, **kwargs) except Exception as e: - LogUtils.error( - f"更新 {widget_name} 失敗: {e}\n{traceback.format_exc()}", - "ServerMonitorWindow", + logger.error( + f"更新 {widget_name} 失敗: {e}\n{traceback.format_exc()}" ) def safe_config_widget(self, widget_name: str, **config) -> None: @@ -279,9 +279,7 @@ def create_control_panel(self, parent) -> None: self.version_label = ctk.CTkLabel( right_frame, text="📦 版本: N/A", font=get_font(size=18), anchor="w" ) - LogUtils.debug( - "初始化 ServerMonitorWindow,預設版本顯示 N/A", "ServerMonitorWindow" - ) + logger.debug("初始化 ServerMonitorWindow,預設版本顯示 N/A") self.version_label.pack(anchor="w", pady=2) # 玩家列表面板 @@ -329,9 +327,9 @@ def _on_player_click(self, event) -> None: self.window.clipboard_clear() self.window.clipboard_append(name) self.window.update() # 確保剪貼簿更新生效 - LogUtils.info(f"已複製玩家名稱: {name}", "ServerMonitorWindow") + logger.info(f"已複製玩家名稱: {name}") except Exception as e: - LogUtils.error(f"複製玩家名稱失敗: {e}", "ServerMonitorWindow") + logger.error(f"複製玩家名稱失敗: {e}") def create_console_panel(self, parent) -> None: """ @@ -410,7 +408,7 @@ def _flush(): self.console_text.insert("end", text) self.console_text.see("end") except Exception as e: - LogUtils.error( + logger.error( f"刷新控制台失敗: {e}\n{traceback.format_exc()}", "ServerMonitorWindow", ) @@ -450,7 +448,7 @@ def stop_monitoring(self) -> None: try: self.window.after_cancel(self._console_flush_job) except Exception as e: - LogUtils.error_exc( + logger.exception( f"停止監控時取消 console flush job 失敗(視窗可能已關閉): {e}", "ServerMonitorWindow", e, @@ -465,7 +463,7 @@ def stop_monitoring(self) -> None: try: self.monitor_future.result(timeout=1) except Exception as e: - LogUtils.error_exc( + logger.exception( f"等待監控 future 結束超時/失敗(忽略): {e}", "ServerMonitorWindow", e, @@ -505,7 +503,7 @@ def monitor_loop(self) -> None: last_log_mtime = current_mtime self.read_server_output() except Exception as e: - LogUtils.debug( + logger.debug( f"檢查日誌檔案變更時發生例外(忽略): {e}", "ServerMonitorWindow", ) @@ -514,7 +512,7 @@ def monitor_loop(self) -> None: # 適度休眠,減少 CPU 使用 self._monitor_stop_event.wait(0.1) except Exception as e: - LogUtils.error( + logger.error( f"監控更新錯誤: {e}\n{traceback.format_exc()}", "ServerMonitorWindow", ) @@ -577,7 +575,7 @@ def _apply_players(): text=f"👥 玩家數量: {current_players}/{max_players}" ) except Exception: - LogUtils.error( + logger.error( "更新玩家數量 label 失敗(可能視窗已關閉)", "ServerMonitorWindow", ) @@ -585,7 +583,7 @@ def _apply_players(): self.ui_queue.put(_apply_players) except Exception as e: - LogUtils.error( + logger.error( f"讀取伺服器輸出錯誤: {e}\n{traceback.format_exc()}", "ServerMonitorWindow", ) @@ -680,7 +678,7 @@ def _update_ui(self, info) -> None: self._last_ui_state["btn_state_stop"] = btn_state_stop except Exception as e: - LogUtils.error( + logger.error( f"_update_ui 更新 UI 狀態失敗: {e}\n{traceback.format_exc()}", "ServerMonitorWindow", ) @@ -695,7 +693,7 @@ def update_player_count(self) -> None: if success: self.executor.submit(self._delayed_read_player_list) except Exception as e: - LogUtils.error( + logger.error( f"更新玩家數量錯誤: {e}\n{traceback.format_exc()}", "ServerMonitorWindow", ) @@ -756,7 +754,7 @@ def update_ui(): # 僅當真的沒抓到任何玩家列表才不動作 pass except Exception as e: - LogUtils.error( + logger.error( f"讀取玩家列表時發生錯誤: {e}\n{traceback.format_exc()}", "ServerMonitorWindow", ) @@ -785,7 +783,7 @@ def update_player_list(self, players: list) -> None: else: self.players_listbox.insert(tk.END, "無玩家在線") except Exception as e: - LogUtils.error( + logger.error( f"更新玩家列表錯誤: {e}\n{traceback.format_exc()}", "ServerMonitorWindow", ) @@ -805,9 +803,7 @@ def update_status(self) -> None: # 在主線程中更新 UI self._update_ui(info) except Exception as e: - LogUtils.error( - f"更新狀態失敗: {e}\n{traceback.format_exc()}", "ServerMonitorWindow" - ) + logger.error(f"更新狀態失敗: {e}\n{traceback.format_exc()}") def start_server(self) -> None: """ @@ -847,7 +843,7 @@ def stop_server(self) -> None: if self.window and self.window.winfo_exists(): self.window.after(100, self.update_status) except Exception as e: - LogUtils.error( + logger.error( f"安全 after 調用錯誤: {e}\n{traceback.format_exc()}", "ServerMonitorWindow", ) @@ -908,9 +904,7 @@ def refresh_status(self) -> None: else: self.add_console_message("⚠️ 未找到日誌檔案") except Exception as e: - LogUtils.error( - f"載入日誌失敗: {e}\n{traceback.format_exc()}", "ServerMonitorWindow" - ) + logger.error(f"載入日誌失敗: {e}\n{traceback.format_exc()}") self.add_console_message(f"❌ 載入日誌失敗: {e}") # 更新狀態 @@ -1034,7 +1028,7 @@ def handle_server_ready(self): UIUtils.show_info("伺服器啟動成功", msg, self.window) # 額外 debug log except Exception as e: - LogUtils.error( + logger.error( f"handle_server_ready 執行錯誤: {e}\n{traceback.format_exc()}", "ServerMonitorWindow", ) diff --git a/src/ui/server_properties_dialog.py b/src/ui/server_properties_dialog.py index d26c690..4b35695 100644 --- a/src/ui/server_properties_dialog.py +++ b/src/ui/server_properties_dialog.py @@ -14,7 +14,10 @@ from ..utils import ServerPropertiesHelper from ..core import ServerConfig, ServerManager from ..utils import font_manager, get_dpi_scaled_size, get_font -from ..utils import UIUtils, LogUtils +from ..utils import UIUtils +from ..utils.logger import get_logger + +logger = get_logger().bind(component="ServerPropertiesDialog") class ServerPropertiesDialog: """ @@ -78,9 +81,8 @@ def setup_dialog(self) -> None: try: self.dialog.configure(bg="#ffffff") # 淺色背景 except Exception as e: - LogUtils.error( - f"應用對話框主題失敗: {e}\n{traceback.format_exc()}", - "ServerPropertiesDialog", + logger.error( + f"應用對話框主題失敗: {e}\n{traceback.format_exc()}" ) def create_widgets(self) -> None: @@ -211,9 +213,7 @@ def _schedule_scrollregion_update(event=None) -> None: canvas.after_cancel(state["job"]) state["job"] = canvas.after_idle(_apply_scrollregion) except Exception as e: - LogUtils.error_exc( - f"排程 scrollregion 更新失敗: {e}", "ServerPropertiesDialog", e - ) + logger.exception(f"排程 scrollregion 更新失敗: {e}") scrollable_frame.bind("", _schedule_scrollregion_update) @@ -247,9 +247,7 @@ def _on_mousewheel(event, canvas=canvas): defaults = self.server_manager.get_default_server_properties() all_properties = {**defaults, **all_properties} except Exception as e: - LogUtils.error_exc( - f"讀取預設 server.properties 失敗: {e}", "ServerPropertiesDialog", e - ) + logger.exception(f"讀取預設 server.properties 失敗: {e}") all_keys = set(all_properties.keys()) # 找出未分類的 key @@ -396,9 +394,8 @@ def update_bool_var(*args, bv=bool_var, sv=var): try: UIUtils.apply_unified_dropdown_styling(widget) except Exception as e: - LogUtils.error( - f"套用下拉選單樣式失敗: {e}\n{traceback.format_exc()}", - "ServerPropertiesDialog", + logger.error( + f"套用下拉選單樣式失敗: {e}\n{traceback.format_exc()}" ) elif prop_name in range_props: @@ -498,9 +495,8 @@ def save_properties(self) -> None: UIUtils.show_error("錯誤", "儲存伺服器屬性失敗", self.dialog) except Exception as e: - LogUtils.error( - f"儲存時發生錯誤: {e}\n{traceback.format_exc()}", - "ServerPropertiesDialog", + logger.error( + f"儲存時發生錯誤: {e}\n{traceback.format_exc()}" ) UIUtils.show_error("錯誤", f"儲存時發生錯誤: {e}", self.dialog) diff --git a/src/ui/window_preferences_dialog.py b/src/ui/window_preferences_dialog.py index 1903479..291f2fc 100644 --- a/src/ui/window_preferences_dialog.py +++ b/src/ui/window_preferences_dialog.py @@ -18,7 +18,9 @@ set_ui_scale_factor, get_font, ) -from ..utils import LogUtils +from ..utils.logger import get_logger + +logger = get_logger().bind(component="WindowPreferencesDialog") class WindowPreferencesDialog: """ @@ -409,9 +411,8 @@ def _reset_all_settings(self) -> None: schedule_restart_and_exit(self.parent, delay=0.5) return except Exception as restart_error: - LogUtils.error( - f"重啟失敗: {restart_error}\n{traceback.format_exc()}", - "WindowPreferencesDialog", + logger.error( + f"重啟失敗: {restart_error}\n{traceback.format_exc()}" ) UIUtils.show_error( "重啟失敗", @@ -473,9 +474,8 @@ def _apply_settings(self) -> None: return except Exception as restart_error: - LogUtils.error( - f"重啟失敗: {restart_error}\n{traceback.format_exc()}", - "WindowPreferencesDialog", + logger.error( + f"重啟失敗: {restart_error}\n{traceback.format_exc()}" ) UIUtils.show_error( "重啟失敗", @@ -493,9 +493,7 @@ def _apply_settings(self) -> None: self.dialog.destroy() except Exception as e: - LogUtils.error( - f"儲存失敗: {e}\n{traceback.format_exc()}", "WindowPreferencesDialog" - ) + logger.error(f"儲存失敗: {e}\n{traceback.format_exc()}") UIUtils.show_error("儲存失敗", f"無法儲存設定: {e}", parent=self.dialog) def _cancel(self) -> None: diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 1e1afce..b4ca7ba 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -5,6 +5,10 @@ 提供 Minecraft 伺服器管理器應用程式的各種工具函數和輔助類別 Utility Modules Package Provides various utility functions and helper classes for the Minecraft Server Manager application + +Logger can be imported conveniently: + from src.utils import get_logger + logger = get_logger().bind(component="ComponentName") """ from __future__ import annotations @@ -13,8 +17,8 @@ from typing import Dict, Tuple _EXPORTS: Dict[str, Tuple[str, str]] = { - # logging - "LogUtils": (".log_utils", "LogUtils"), + # logger + "get_logger": (".logger", "get_logger"), # UI helpers "UIUtils": (".ui_utils", "UIUtils"), "DialogUtils": (".ui_utils", "DialogUtils"), diff --git a/src/utils/app_restart.py b/src/utils/app_restart.py index e9f6d99..164eb7c 100644 --- a/src/utils/app_restart.py +++ b/src/utils/app_restart.py @@ -15,7 +15,9 @@ import threading import time # ======專案內部模組 ====== -from src.utils import LogUtils +from src.utils.logger import get_logger + +logger = get_logger().bind(component="AppRestart") # ====== 執行檔資訊檢測 ====== # 取得當前執行檔的詳細資訊 @@ -124,7 +126,7 @@ def delayed_restart(): restart_error.set() except Exception as e: - LogUtils.error_exc(f"重啟失敗: {e}", "AppRestart", e) + logger.exception(f"重啟失敗: {e}") restart_error.set() # 在背景執行緒中執行延遲重啟 @@ -142,7 +144,7 @@ def delayed_restart(): return True except Exception as e: - LogUtils.error_exc(f"準備重啟時發生錯誤: {e}", "AppRestart", e) + logger.exception(f"準備重啟時發生錯誤: {e}") return False # 安排重啟並退出當前應用程式 @@ -163,7 +165,7 @@ def schedule_restart_and_exit(parent_window=None, delay: float = 1.0) -> None: restart_initiated = restart_application(delay) if restart_initiated: - LogUtils.info("重啟程序已啟動,準備關閉當前應用程式", "AppRestart") + logger.info("重啟程序已啟動,準備關閉當前應用程式") # 給重啟程序一些時間來準備 time.sleep(0.2) @@ -177,9 +179,7 @@ def delayed_close(): parent_window.quit() # 停止主事件迴圈 parent_window.destroy() # 銷毀視窗 except Exception as e: - LogUtils.error_exc( - f"關閉視窗時發生錯誤: {e}", "AppRestart", e - ) + logger.exception(f"關閉視窗時發生錯誤: {e}") # 延遲退出以確保新程序有時間啟動 time.sleep(0.5) @@ -189,13 +189,13 @@ def delayed_close(): parent_window.after(100, delayed_close) except Exception as e: - LogUtils.error_exc(f"安排視窗關閉時發生錯誤: {e}", "AppRestart", e) + logger.exception(f"安排視窗關閉時發生錯誤: {e}") # 如果無法使用 after 方法,直接關閉 try: parent_window.quit() parent_window.destroy() except Exception as e2: - LogUtils.error_exc(f"直接關閉視窗失敗: {e2}", "AppRestart", e2) + logger.exception(f"直接關閉視窗失敗: {e2}") time.sleep(0.5) sys.exit(0) else: @@ -203,7 +203,7 @@ def delayed_close(): time.sleep(0.5) sys.exit(0) else: - LogUtils.error("重啟失敗,程式將繼續運行", "AppRestart") + logger.error("重啟失敗,程式將繼續運行") except Exception as e: - LogUtils.error_exc(f"重啟程序失敗: {e}", "AppRestart", e) + logger.exception(f"重啟程序失敗: {e}") diff --git a/src/utils/font_manager.py b/src/utils/font_manager.py index 43a7f5e..04f3841 100644 --- a/src/utils/font_manager.py +++ b/src/utils/font_manager.py @@ -11,7 +11,9 @@ import weakref import customtkinter as ctk # ======專案內部模組 ====== -from src.utils import LogUtils +from src.utils.logger import get_logger + +logger = get_logger().bind(component="FontManager") class FontManager: """ @@ -137,8 +139,8 @@ def get_font( ) return font except Exception as e: - LogUtils.error_exc( - f"建立字體失敗 {family}, {scaled_size}, {weight}: {e}", "FontManager", e + logger.exception( + f"建立字體失敗 {family}, {scaled_size}, {weight}: {e}" ) # 回退到預設字體 return self._get_fallback_font() @@ -201,13 +203,13 @@ def clear_cache(self) -> None: if hasattr(font, "destroy"): font.destroy() except Exception as e: - LogUtils.error_exc(f"銷毀字體物件失敗: {e}", "FontManager", e) + logger.exception(f"銷毀字體物件失敗: {e}") self._fonts.clear() self._font_refs.clear() except Exception as e: - LogUtils.error_exc(f"清理字體快取時發生錯誤: {e}", "FontManager", e) + logger.exception(f"清理字體快取時發生錯誤: {e}") # ====== 全域實例與便利函數 ====== # 全域字體管理器實例 diff --git a/src/utils/http_utils.py b/src/utils/http_utils.py index d9d3e04..c9e8f5d 100644 --- a/src/utils/http_utils.py +++ b/src/utils/http_utils.py @@ -15,9 +15,11 @@ from urllib3.util.retry import Retry import aiohttp # ====== 專案內部模組 ====== -from src.utils import LogUtils +from src.utils.logger import get_logger from src.version_info import APP_NAME, APP_VERSION +logger = get_logger().bind(component="HTTPUtils") + class HTTPUtils: """ HTTP 網路請求工具類別,提供各種 HTTP 操作的統一介面 @@ -85,7 +87,7 @@ def get_json( Optional[Dict[str, Any]]: 成功時返回 JSON 字典,失敗時返回 None """ if not url or not isinstance(url, str): - LogUtils.error("HTTP GET JSON 請求失敗: URL 參數無效", "HTTPUtils") + logger.error("HTTP GET JSON 請求失敗: URL 參數無效") return None timeout = max(10, timeout) @@ -96,7 +98,7 @@ def get_json( response.raise_for_status() return response.json() except Exception as e: - LogUtils.error_exc(f"HTTP GET JSON 請求失敗 ({url}): {e}", "HTTPUtils", e) + logger.exception(f"HTTP GET JSON 請求失敗 ({url}): {e}") return None # ====== 內容資料請求 ====== @@ -123,7 +125,7 @@ def get_content( Optional[requests.Response]: 成功時返回 Response 物件,失敗時返回 None """ if not url or not isinstance(url, str): - LogUtils.error("HTTP GET 請求失敗: URL 參數無效", "HTTPUtils") + logger.error("HTTP GET 請求失敗: URL 參數無效") return None timeout = max(30, timeout) @@ -136,7 +138,7 @@ def get_content( response.raise_for_status() return response except Exception as e: - LogUtils.error_exc(f"HTTP GET 請求失敗 ({url}): {e}", "HTTPUtils", e) + logger.exception(f"HTTP GET 請求失敗 ({url}): {e}") return None # ====== 檔案下載功能 ====== @@ -159,10 +161,10 @@ def download_file( bool: 下載成功返回 True,失敗返回 False """ if not url or not isinstance(url, str): - LogUtils.error("檔案下載失敗: URL 參數無效", "HTTPUtils") + logger.error("檔案下載失敗: URL 參數無效") return False if not local_path or not isinstance(local_path, str): - LogUtils.error("檔案下載失敗: 本地路徑參數無效", "HTTPUtils") + logger.error("檔案下載失敗: 本地路徑參數無效") return False timeout = max(60, timeout) @@ -181,8 +183,8 @@ def download_file( f.write(chunk) return True except Exception as e: - LogUtils.error_exc( - f"檔案下載失敗 ({url} -> {local_path}): {e}", "HTTPUtils", e + logger.exception( + f"檔案下載失敗 ({url} -> {local_path}): {e}" ) return False @@ -221,8 +223,8 @@ async def fetch_one( response.raise_for_status() return await response.json() except Exception as e: - LogUtils.error_exc( - f"非同步 HTTP GET JSON 請求失敗 ({url}): {e}", "HTTPUtils", e + logger.exception( + f"非同步 HTTP GET JSON 請求失敗 ({url}): {e}" ) return None @@ -274,7 +276,7 @@ def get_json_batch( HTTPUtils.get_json_batch_async(urls, timeout, headers, max_workers) ) except Exception as e: - LogUtils.error_exc(f"批次 HTTP 請求失敗: {e}", "HTTPUtils", e) + logger.exception(f"批次 HTTP 請求失敗: {e}") return [None] * len(urls) # ====== 向後相容性函數別名 ====== diff --git a/src/utils/java_downloader.py b/src/utils/java_downloader.py index fae9636..b24e384 100644 --- a/src/utils/java_downloader.py +++ b/src/utils/java_downloader.py @@ -10,7 +10,9 @@ import subprocess # ====== 專案內部模組 ====== from .ui_utils import UIUtils -from .log_utils import LogUtils +from .logger import get_logger + +logger = get_logger().bind(component="JavaDownloader") # 只負責安裝 def install_java_with_winget(major: int): @@ -53,13 +55,13 @@ def is_winget_available(): # 直接在主程式同步執行 winget,安裝過程會在主程式 console 顯示 subprocess.run(winget_cmd, shell=False, check=True) except subprocess.CalledProcessError as e: - LogUtils.error_exc(f"winget 安裝失敗: {e}", "JavaDownloader", e) + logger.exception(f"winget 安裝失敗: {e}") UIUtils.show_error( "winget 安裝失敗", f"winget 執行失敗,請檢查錯誤訊息:\n{e}", topmost=True ) raise Exception(f"執行 winget 失敗: {e}") except Exception as e: - LogUtils.error_exc(f"winget 執行異常: {e}", "JavaDownloader", e) + logger.exception(f"winget 執行異常: {e}") UIUtils.show_error( "winget 執行異常", f"執行 winget 發生例外:{e}", topmost=True ) diff --git a/src/utils/java_utils.py b/src/utils/java_utils.py index ee7e4bd..e63d663 100644 --- a/src/utils/java_utils.py +++ b/src/utils/java_utils.py @@ -18,10 +18,12 @@ from .http_utils import HTTPUtils from .runtime_paths import get_cache_dir from .ui_utils import UIUtils -from .log_utils import LogUtils +from .logger import get_logger from src.core import MinecraftVersionManager from .java_downloader import install_java_with_winget +logger = get_logger().bind(component="JavaUtils") + COMMON_JAVA_PATHS = [ r"C:\\Program Files\\Java", r"C:\\Program Files (x86)\\Java", @@ -47,25 +49,38 @@ def get_java_version(java_path: str) -> int: out = subprocess.check_output( [java_path, "-version"], stderr=subprocess.STDOUT, encoding="utf-8" ) - m = re.search(r'version "([0-9]+)\.([0-9]+)', out) + # 統一的版本解析模式 + # 先試圖匹配 "version \"X.Y" 格式(Java 9+) + m = re.search(r'version "(\d+)\.(\d+)', out) if m: major = int(m.group(1)) + # Java 8 及以前版本格式為 "1.8",需要取第二個數字 if major == 1: - # 1.x 代表 Java 8 及以前 - m2 = re.search(r'version "1\.([0-9]+)', out) - if m2 and m2.group(1) == "8": - return 8 - return major + return int(m.group(2)) + # Java 9+ 格式為 "9.x", "11.x" 等,直接取第一個數字 return major - m = re.search(r'version "1\.([0-9]+)', out) + + # 備用模式:直接匹配 "version \"X" 格式 + m = re.search(r'version "(\d+)"', out) if m: - if m.group(1) == "8": - return 8 return int(m.group(1)) except Exception as e: - LogUtils.error_exc(f"取得 Java 版本失敗 {java_path}: {e}", "JavaUtils", e) + logger.exception(f"取得 Java 版本失敗 {java_path}: {e}") return None + +def _ensure_cache_exists(cache_path): + """確保快取檔案存在且非空""" + if not cache_path.exists() or cache_path.stat().st_size == 0: + try: + vm = MinecraftVersionManager() + vm.fetch_versions() + except Exception as e: + raise FileNotFoundError(f"找不到 {cache_path},且自動建立快取失敗: {e}") + # 再次檢查 + if not cache_path.exists() or cache_path.stat().st_size == 0: + raise FileNotFoundError(f"找不到 {cache_path} 或檔案為空") + # 取得指定 Minecraft 版本所需的 Java 版本 def get_required_java_major(mc_version: str) -> int: """ @@ -80,17 +95,10 @@ def get_required_java_major(mc_version: str) -> int: """ if not isinstance(mc_version, str) or not mc_version: raise ValueError("mc_version 必須為非空字串") + cache_path = get_cache_dir() / "mc_versions_cache.json" - # 若快取不存在或內容為空,則自動建立快取 - if not cache_path.exists() or cache_path.stat().st_size == 0: - try: - vm = MinecraftVersionManager() - vm.fetch_versions() - except Exception as e: - raise FileNotFoundError(f"找不到 {cache_path},且自動建立快取失敗: {e}") - # 再次檢查 - if not cache_path.exists() or cache_path.stat().st_size == 0: - raise FileNotFoundError(f"找不到 {cache_path} 或檔案為空") + _ensure_cache_exists(cache_path) + with open(cache_path, "r", encoding="utf-8") as f: try: data = json.load(f) @@ -162,7 +170,7 @@ def get_all_local_java_candidates() -> list: if os.path.isfile(javaw_path): search_paths.add(os.path.dirname(javaw_path)) except Exception as e: - LogUtils.error_exc(f"PATH 環境變數尋找 java 失敗:{e}", "JavaUtils", e) + logger.exception(f"PATH 環境變數尋找 java 失敗:{e}") # 4.where javaw 檢查 candidates = [] @@ -177,7 +185,7 @@ def get_all_local_java_candidates() -> list: if major: candidates.append((os.path.normpath(java_path), major)) except Exception as e: - LogUtils.error_exc(f"搜尋 Java 失敗: {e}", "JavaUtils", e) + logger.exception(f"搜尋 Java 失敗: {e}") # 5.搜尋所有目錄下的 javaw.exe for p in search_paths: @@ -196,9 +204,9 @@ def get_all_local_java_candidates() -> list: result.append((path, major)) result.sort(key=lambda x: x[1]) - LogUtils.info(f"找到 {len(result)} 個 Java 執行檔選擇:", "JavaUtils") + logger.info(f"找到 {len(result)} 個 Java 執行檔選擇:") for path, major in result: - LogUtils.info(f" {path} -> {major}", "JavaUtils") + logger.info(f" {path} -> {major}") return result @@ -249,8 +257,8 @@ def get_best_java_path( ) return path except Exception as e: - LogUtils.error_exc( - f"自動下載 Microsoft JDK {required_major} 失敗:{e}", "JavaUtils", e + logger.exception( + f"自動下載 Microsoft JDK {required_major} 失敗:{e}" ) UIUtils.show_error( "Java 下載失敗", diff --git a/src/utils/log_utils.py b/src/utils/log_utils.py deleted file mode 100644 index 84ba6b4..0000000 --- a/src/utils/log_utils.py +++ /dev/null @@ -1,279 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -日誌工具模組 -提供統一的日誌記錄功能(包含控制台輸出與檔案記錄) -Logging Utilities Module -Provides unified logging functionality (including console output and file logging) -""" -# ====== 標準函式庫 ====== -from datetime import datetime -from pathlib import Path -from typing import Optional -import traceback -import threading - -# ====== 日誌工具類別 ====== -class LogUtils: - """ - 統一的日誌工具類別 - Unified logging utility class for consistent message output with color coding and file logging - """ - - # 類別層級的日誌檔案配置 - _log_file = None - _log_lock = threading.Lock() - _max_log_size = 10 * 1024 * 1024 # 10 MB - _log_initialized = False - - @staticmethod - def _initialize_log_file() -> None: - """ - 初始化日誌檔案(延遲初始化,避免在模組載入時就建立檔案) - Initialize log file (lazy initialization to avoid creating files during module load) - """ - if LogUtils._log_initialized: - return - - try: - # 取得日誌目錄路徑 - from .runtime_paths import get_user_data_dir - - log_dir = Path(get_user_data_dir()) / "logs" - log_dir.mkdir(parents=True, exist_ok=True) - - # 建立日誌檔案名稱(使用日期) - log_filename = ( - f"minecraft_server_manager_{datetime.now().strftime('%Y%m%d')}.log" - ) - LogUtils._log_file = log_dir / log_filename - - # 檢查日誌檔案大小,如果過大則輪轉 - if ( - LogUtils._log_file.exists() - and LogUtils._log_file.stat().st_size > LogUtils._max_log_size - ): - # 重命名舊檔案 - backup_name = f"minecraft_server_manager_{datetime.now().strftime('%Y%m%d_%H%M%S')}_backup.log" - backup_file = log_dir / backup_name - LogUtils._log_file.rename(backup_file) - - # 清理超過 7 天的舊日誌檔案 - LogUtils._clean_old_logs(log_dir, days=7) - - LogUtils._log_initialized = True - except Exception as e: - # 如果初始化失敗,只輸出到控制台,不影響程式運行 - print(f"[WARNING] 日誌檔案初始化失敗: {e}") - LogUtils._log_file = None - LogUtils._log_initialized = True # 標記為已初始化,避免重複嘗試 - - @staticmethod - def _clean_old_logs(log_dir: Path, days: int = 7) -> None: - """ - 清理超過指定天數的舊日誌檔案 - Clean up old log files older than specified days - - Args: - log_dir (Path): 日誌目錄 - days (int): 保留天數 - """ - try: - from datetime import timedelta - - cutoff_time = datetime.now() - timedelta(days=days) - - for log_file in log_dir.glob("*.log"): - try: - if log_file.stat().st_mtime < cutoff_time.timestamp(): - log_file.unlink() - except Exception: - pass # 忽略單個檔案的刪除錯誤 - except Exception: - pass # 忽略清理錯誤 - - @staticmethod - def _write_to_file(level: str, message: str, component: str = "") -> None: - """ - 將日誌訊息寫入檔案 - Write log message to file - - Args: - level (str): 日誌級別 - message (str): 訊息內容 - component (str): 組件名稱 - """ - if not LogUtils._log_initialized: - LogUtils._initialize_log_file() - - if LogUtils._log_file is None: - return - - try: - with LogUtils._log_lock: - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - component_str = f"[{component}]" if component else "" - log_line = f"[{timestamp}][{level}]{component_str} {message}\n" - - with open(LogUtils._log_file, "a", encoding="utf-8") as f: - f.write(log_line) - except Exception: - # 檔案寫入失敗時靜默忽略,不影響程式運行 - pass - - @staticmethod - def _should_show_debug_output() -> bool: - """ - 檢查是否應該顯示調試輸出 - Check if debug output should be shown - - Returns: - bool: True 表示應該顯示調試輸出,False 表示不顯示 - """ - try: - # 在需要時才導入設定管理器以避免循環導入 - from .settings_manager import get_settings_manager - - settings = get_settings_manager() - return settings.is_debug_logging_enabled() - except Exception: - # 如果無法取得設定,預設不顯示調試輸出 - return False - - # 輸出錯誤訊息到控制台和檔案 - @staticmethod - def error(message: str, component: str = "") -> None: - """ - 列印錯誤訊息到控制台和檔案,使用紅色標記 - 錯誤訊息一律輸出,不受調試設定控制 - Print error message to console and file with red color marking - Error messages are always output regardless of debug settings - - Args: - message (str): 錯誤訊息內容 - component (str): 可選的組件名稱,用於標識訊息來源 - - Returns: - None - """ - if component: - print(f"\033[91m[ERROR][{component}] {message}\033[0m") - else: - print(f"\033[91m[ERROR] {message}\033[0m") - - # 寫入檔案 - LogUtils._write_to_file("ERROR", message, component) - - @staticmethod - def error_exc( - message: str, component: str = "", exc: Optional[BaseException] = None - ) -> None: - """以統一格式輸出錯誤並附上 traceback(同時寫入檔案)。 - - - 若提供 exc,使用該例外的 traceback(適合跨執行緒傳遞)。 - - 否則使用當前 except 區塊的 traceback.format_exc()。 - """ - if exc is not None: - trace = "".join( - traceback.format_exception(type(exc), exc, exc.__traceback__) - ) - else: - trace = traceback.format_exc() - LogUtils.error(f"{message}\n{trace}", component) - - # 輸出資訊訊息到控制台和檔案 - @staticmethod - def info(message: str, component: str = "") -> None: - """ - 列印資訊訊息到控制台和檔案 - Print info message to console and file - - Args: - message (str): 資訊訊息內容 - component (str): 可選的組件名稱,用於標識訊息來源 - - Returns: - None - """ - if not LogUtils._should_show_debug_output(): - # 即使不顯示,也要寫入檔案 - LogUtils._write_to_file("INFO", message, component) - return - - if component: - print(f"[INFO][{component}] {message}") - else: - print(f"[INFO] {message}") - - # 寫入檔案 - LogUtils._write_to_file("INFO", message, component) - - # 輸出警告訊息到控制台和檔案 - @staticmethod - def warning(message: str, component: str = "") -> None: - """ - 列印警告訊息到控制台和檔案,使用黃色標記 - 警告訊息一律輸出,不受調試設定控制 - Print warning message to console and file with yellow color marking - Warning messages are always output regardless of debug settings - - Args: - message (str): 警告訊息內容 - component (str): 可選的組件名稱,用於標識訊息來源 - - Returns: - None - """ - if component: - print(f"\033[93m[WARNING][{component}] {message}\033[0m") - else: - print(f"\033[93m[WARNING] {message}\033[0m") - - # 寫入檔案 - LogUtils._write_to_file("WARNING", message, component) - - # 輸出調試訊息到控制台和檔案(受設定控制) - @staticmethod - def debug(message: str, component: str = "") -> None: - """ - 列印調試訊息到控制台和檔案,受設定檔控制是否顯示 - Print debug message to console and file, controlled by settings configuration - - Args: - message (str): 調試訊息內容 - component (str): 可選的組件名稱,用於標識訊息來源 - - Returns: - None - """ - # 寫入檔案(即使不顯示也要記錄) - LogUtils._write_to_file("DEBUG", message, component) - - if not LogUtils._should_show_debug_output(): - return - - if component: - print(f"\033[94m[DEBUG][{component}] {message}\033[0m") - else: - print(f"\033[94m[DEBUG] {message}\033[0m") - - # 輸出視窗狀態調試訊息 - @staticmethod - def debug_window_state(message: str) -> None: - """ - 列印視窗狀態調試訊息到控制台和檔案,使用統一的調試輸出判斷 - Print window state debug message to console and file, using unified debug output check - - Args: - message (str): 視窗狀態調試訊息內容 - - Returns: - None - """ - # 寫入檔案 - LogUtils._write_to_file("DEBUG", message, "WindowState") - - if not LogUtils._should_show_debug_output(): - return - - print(f"\033[94m[DEBUG] {message}\033[0m") diff --git a/src/utils/logger.py b/src/utils/logger.py new file mode 100644 index 0000000..097e2fc --- /dev/null +++ b/src/utils/logger.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +日誌工具模組 (基於 loguru) +提供統一的日誌記錄功能 +Logging Utilities Module (Based on loguru) +Provides unified logging functionality using loguru +""" + +import os +import sys +from datetime import datetime +from pathlib import Path +from typing import Optional + +from loguru import logger + +# 記憶體常數 Memory Constants +MB = 1024 * 1024 + + +class LoggerConfig: + """Loguru 日誌配置管理""" + + _initialized = False + _log_dir: Optional[Path] = None + _max_folder_size_mb = 10 + _target_cleanup_size_mb = 8 # 當超過限制時,刪除相當於 8MB 的舊日誌 + _settings_manager = None # 快取 settings manager + + @classmethod + def initialize(cls) -> None: + """ + 初始化 loguru 日誌系統 + Initialize loguru logging system + """ + if cls._initialized: + return + + try: + # 移除預設的 stderr handler + logger.remove() + + # 取得日誌目錄路徑 + cls._log_dir = cls._get_log_directory() + cls._log_dir.mkdir(parents=True, exist_ok=True) + + # 清理超過大小限制的舊日誌 + cls._cleanup_old_logs_if_needed() + + # 建立日誌檔案名稱(格式:年-月-日-時-分.log) + log_filename = datetime.now().strftime("%Y-%m-%d-%H-%M.log") + log_file_path = cls._log_dir / log_filename + + # 添加檔案 handler + logger.add( + log_file_path, + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {extra[component]: <15} | {message}", + level="DEBUG", + encoding="utf-8", + enqueue=True, # 執行緒安全 + ) + + # 添加控制台 handler(根據設定決定是否顯示) + logger.add( + sys.stderr, + format="{level: <8} | {extra[component]: <15} | {message}", + level="DEBUG", + colorize=True, + filter=cls._console_filter, + ) + + cls._initialized = True + logger.bind(component="Logger").info(f"日誌系統初始化完成,日誌檔案:{log_file_path}") + + except Exception as e: + # 如果初始化失敗,添加一個基本的 stderr handler + logger.add(sys.stderr, level="ERROR") + logger.bind(component="Logger").error(f"日誌系統初始化失敗: {e}") + cls._initialized = True + + @classmethod + def _get_log_directory(cls) -> Path: + """ + 取得日誌目錄路徑 + Get log directory path + + Returns: + Path: 日誌目錄路徑 (%LOCALAPPDATA%/Programs/MinecraftServerManager/log) + """ + localappdata = os.environ.get("LOCALAPPDATA") + if not localappdata: + localappdata = str(Path.home() / "AppData" / "Local") + + return Path(localappdata) / "Programs" / "MinecraftServerManager" / "log" + + @classmethod + def _get_folder_size_mb(cls, folder: Path) -> float: + """ + 計算資料夾大小(MB) + Calculate folder size in MB + + Args: + folder (Path): 資料夾路徑 + + Returns: + float: 資料夾大小(MB) + """ + total_size = 0 + try: + for file in folder.glob("*.log"): + if file.is_file(): + total_size += file.stat().st_size + except Exception: + pass + + return total_size / MB + + @classmethod + def _cleanup_old_logs_if_needed(cls) -> None: + """ + 檢查日誌資料夾大小,如果超過 10MB 則刪除相當於 8MB 的舊日誌 + Check log folder size, delete logs worth 8MB if exceeds 10MB + """ + if cls._log_dir is None: + return + + try: + folder_size = cls._get_folder_size_mb(cls._log_dir) + + if folder_size > cls._max_folder_size_mb: + # 取得所有日誌檔案並按修改時間排序(最舊的在前) + log_files = sorted( + cls._log_dir.glob("*.log"), + key=lambda f: f.stat().st_mtime + ) + + # 刪除舊日誌直到釋放 8MB 空間 + deleted_size_mb = 0.0 + files_deleted = 0 + target_mb = cls._target_cleanup_size_mb + + for log_file in log_files: + if deleted_size_mb >= target_mb: + break + + try: + # 取得檔案大小(MB) + file_size_mb = log_file.stat().st_size / MB + log_file.unlink() + deleted_size_mb += file_size_mb + files_deleted += 1 + except Exception: + pass + + if files_deleted > 0: + logger.bind(component="Logger").info( + f"日誌資料夾大小超過 {cls._max_folder_size_mb}MB,已刪除 {files_deleted} 個舊日誌檔案(釋放 {deleted_size_mb:.2f}MB)" + ) + except Exception as e: + logger.bind(component="Logger").warning(f"清理舊日誌時發生錯誤: {e}") + + @classmethod + def _console_filter(cls, record) -> bool: + """ + 控制台輸出過濾器 + Console output filter based on settings + + Args: + record: loguru 日誌記錄 + + Returns: + bool: True 表示應該輸出,False 表示不輸出 + """ + level = record["level"].name + + # ERROR 和 WARNING 一律輸出 + if level in ("ERROR", "WARNING", "CRITICAL"): + return True + + # DEBUG 和 INFO 根據設定決定 + try: + # 使用快取的 settings manager + if cls._settings_manager is None: + from .settings_manager import get_settings_manager + cls._settings_manager = get_settings_manager() + return cls._settings_manager.is_debug_logging_enabled() + except Exception: + # 如果無法取得設定,DEBUG 不輸出,INFO 輸出 + return level == "INFO" + + @classmethod + def get_logger(cls): + """ + 取得 logger 實例 + Get logger instance + + Returns: + logger: loguru logger 實例 + """ + if not cls._initialized: + cls.initialize() + return logger + + +# 初始化並取得 logger +_logger = LoggerConfig.get_logger() + + +def get_logger(): + """取得全域 logger 實例""" + return _logger + + +# 便捷函數,直接使用 loguru(不需要 component 參數時的簡化版本) +def info(message: str, component: str = ""): + """記錄 INFO 級別訊息""" + if component: + _logger.bind(component=component).info(message) + else: + _logger.bind(component="").info(message) + + +def warning(message: str, component: str = ""): + """記錄 WARNING 級別訊息""" + if component: + _logger.bind(component=component).warning(message) + else: + _logger.bind(component="").warning(message) + + +def error(message: str, component: str = ""): + """記錄 ERROR 級別訊息""" + if component: + _logger.bind(component=component).error(message) + else: + _logger.bind(component="").error(message) + + +def debug(message: str, component: str = ""): + """記錄 DEBUG 級別訊息""" + if component: + _logger.bind(component=component).debug(message) + else: + _logger.bind(component="").debug(message) + + +def error_with_exception(message: str, component: str = "", exc: Exception = None): + """記錄錯誤並附上 traceback""" + import traceback + if exc is not None: + trace = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)) + else: + trace = traceback.format_exc() + error(f"{message}\n{trace}", component) diff --git a/src/utils/server_utils.py b/src/utils/server_utils.py index 16f6fce..5d33adc 100644 --- a/src/utils/server_utils.py +++ b/src/utils/server_utils.py @@ -16,10 +16,18 @@ # ====== 專案內部模組 Internal Modules ====== from ..models import ServerConfig -from .log_utils import LogUtils +from .logger import get_logger from .ui_utils import UIUtils from . import java_utils +logger = get_logger().bind(component="ServerUtils") + + +# ====== 記憶體常數 Memory Constants ====== +KB = 1024 +MB = 1024 * 1024 +GB = 1024 * 1024 * 1024 + # ====== 記憶體工具類別 Memory Utilities ====== class MemoryUtils: @@ -66,14 +74,14 @@ def format_memory(memory_bytes: float) -> str: 格式化記憶體大小 Format memory size """ - if memory_bytes < 1024: + if memory_bytes < KB: return f"{memory_bytes:.1f} B" - elif memory_bytes < 1024 * 1024: - return f"{memory_bytes / 1024:.1f} KB" - elif memory_bytes < 1024 * 1024 * 1024: - return f"{memory_bytes / (1024 * 1024):.1f} MB" + elif memory_bytes < MB: + return f"{memory_bytes / KB:.1f} KB" + elif memory_bytes < GB: + return f"{memory_bytes / MB:.1f} MB" else: - return f"{memory_bytes / (1024 * 1024 * 1024):.1f} GB" + return f"{memory_bytes / GB:.1f} GB" @staticmethod def format_memory_mb(memory_mb: int) -> str: @@ -313,8 +321,8 @@ def load_properties(file_path) -> Dict[str, str]: key, value = line.split("=", 1) properties[key.strip()] = value.strip() except Exception as e: - LogUtils.error_exc( - f"載入 server.properties 失敗: {e}", "PropertiesHelper", e + logger.exception( + f"載入 server.properties 失敗: {e}" ) return properties @@ -338,8 +346,8 @@ def save_properties(file_path, properties: Dict[str, str]): for key, value in properties.items(): f.write(f"{key}={value}\n") except Exception as e: - LogUtils.error_exc( - f"儲存 server.properties 失敗: {e}", "PropertiesHelper", e + logger.exception( + f"儲存 server.properties 失敗: {e}" ) @@ -439,7 +447,7 @@ def detect_eula_acceptance(server_path: Path) -> bool: return value.strip().lower() == "true" return False except Exception as e: - LogUtils.error_exc(f"讀取 eula.txt 失敗: {e}", "detect_eula_acceptance", e) + logger.exception(f"讀取 eula.txt 失敗: {e}") return False # ====== 記憶體設定管理 Memory Settings Management ====== @@ -463,7 +471,7 @@ def update_forge_user_jvm_args(server_path: Path, config: ServerConfig) -> None: with open(user_jvm_args_path, "w", encoding="utf-8") as f: f.writelines(lines) except Exception as e: - LogUtils.error_exc(f"寫入失敗: {e}", "update_forge_user_jvm_args", e) + logger.exception(f"寫入失敗: {e}") UIUtils.show_error( "寫入失敗", f"無法更新 {user_jvm_args_path} 檔案。請檢查權限或磁碟空間。錯誤: {e}", @@ -495,9 +503,8 @@ def process_script_file(fpath: Path) -> tuple: # 移除 pause 命令 if line_stripped in ["pause", "@pause", "pause.", "@pause."]: script_modified = True - LogUtils.info( - f"發現並移除 pause 命令: {line.strip()}", - "ServerDetection", + logger.info( + f"發現並移除 pause 命令: {line.strip()}" ) continue @@ -508,8 +515,8 @@ def process_script_file(fpath: Path) -> tuple: if "nogui" not in line.lower(): line = line.rstrip("\r\n") + " nogui\n" script_modified = True - LogUtils.info( - "在 Java 命令行添加 nogui 參數", "ServerDetection" + logger.info( + "在 Java 命令行添加 nogui 參數" ) # 解析記憶體設定 @@ -525,16 +532,16 @@ def process_script_file(fpath: Path) -> tuple: try: with open(fpath, "w", encoding="utf-8") as f: f.writelines(script_content) - LogUtils.info( - f"已從 {fpath} 移除 pause 命令", "ServerDetection" + logger.info( + f"已從 {fpath} 移除 pause 命令" ) except Exception as e: - LogUtils.error_exc( - f"無法重寫腳本 {fpath}: {e}", "ServerDetection", e + logger.exception( + f"無法重寫腳本 {fpath}: {e}" ) except Exception as e: - LogUtils.error_exc( - f"解析啟動腳本失敗 {fpath}: {e}", "ServerDetection", e + logger.exception( + f"解析啟動腳本失敗 {fpath}: {e}" ) return max_m, min_m @@ -551,8 +558,8 @@ def process_script_file(fpath: Path) -> tuple: if not min_mem: min_mem = MemoryUtils.parse_memory_setting(content, "Xms") except Exception as e: - LogUtils.error_exc( - f"解析 JVM 參數檔失敗 {fpath}: {e}", "ServerDetection", e + logger.exception( + f"解析 JVM 參數檔失敗 {fpath}: {e}" ) # === 2. 優先解析常見啟動腳本 === @@ -654,31 +661,29 @@ def detect_server_type( # 顯示結果(若有啟用) if print_result: - LogUtils.info(f"偵測結果 - 路徑: {server_path.name}", "ServerDetection") - LogUtils.info(f" 載入器: {config.loader_type}", "ServerDetection") - LogUtils.info( - f" MC版本: {config.minecraft_version}", "ServerDetection" + logger.info(f"偵測結果 - 路徑: {server_path.name}") + logger.info(f" 載入器: {config.loader_type}") + logger.info( + f" MC版本: {config.minecraft_version}" ) - LogUtils.info( - f" EULA狀態: {'已接受' if config.eula_accepted else '未接受'}", - "ServerDetection", + logger.info( + f" EULA狀態: {'已接受' if config.eula_accepted else '未接受'}" ) # 記憶體顯示邏輯 if hasattr(config, "memory_max_mb") and config.memory_max_mb: if hasattr(config, "memory_min_mb") and config.memory_min_mb: - LogUtils.info( - f" 記憶體: 最小 {config.memory_min_mb}MB, 最大 {config.memory_max_mb}MB", - "ServerDetection", + logger.info( + f" 記憶體: 最小 {config.memory_min_mb}MB, 最大 {config.memory_max_mb}MB" ) else: - LogUtils.info( - f" 記憶體: 0-{config.memory_max_mb}MB", "ServerDetection" + logger.info( + f" 記憶體: 0-{config.memory_max_mb}MB" ) else: - LogUtils.info(" 記憶體: 未設定", "ServerDetection") + logger.info(" 記憶體: 未設定") except Exception as e: - LogUtils.error_exc(f"檢測伺服器類型失敗: {e}", "ServerDetection", e) + logger.exception(f"檢測伺服器類型失敗: {e}") @staticmethod def is_valid_server_folder(folder_path: Path) -> bool: @@ -860,8 +865,8 @@ def detect_from_version_json(): if "forgeVersion" in data: set_if_unknown("loader_version", data["forgeVersion"]) except Exception as e: - LogUtils.error_exc( - f"解析 version.json 失敗 {fp}: {e}", "ServerDetection", e + logger.exception( + f"解析 version.json 失敗 {fp}: {e}" ) # ---------- 主流程 ---------- @@ -900,8 +905,8 @@ def detect_main_jar_file(server_path: Path, loader_type: str) -> str: Returns: str: 主伺服器 JAR 檔案名稱 (Main server JAR file name) """ - LogUtils.debug(f"server_path={server_path}", "detect_main_jar_file") - LogUtils.debug(f"loader_type={loader_type}", "detect_main_jar_file") + logger.debug(f"server_path={server_path}") + logger.debug(f"loader_type={loader_type}") loader_type_lc = loader_type.lower() if loader_type else "" jar_files = [f for f in os.listdir(server_path) if f.endswith(".jar")] @@ -911,18 +916,17 @@ def detect_main_jar_file(server_path: Path, loader_type: str) -> str: if loader_type_lc == "forge": # 1. 新版 Forge:libraries/.../forge/**/win_args.txt forge_lib_dir = server_path / "libraries/net/minecraftforge/forge" - LogUtils.debug(f"forge_lib_dir={forge_lib_dir}", "detect_main_jar_file") + logger.debug(f"forge_lib_dir={forge_lib_dir}") if forge_lib_dir.is_dir(): arg_files = list(forge_lib_dir.rglob("win_args.txt")) - LogUtils.debug( - f"rglob args.txt found: {[str(f) for f in arg_files]}", - "detect_main_jar_file", + logger.debug( + f"rglob args.txt found: {[str(f) for f in arg_files]}" ) if arg_files: arg_files.sort(key=lambda p: len(p.parts), reverse=True) result = f"@{arg_files[0].relative_to(server_path)}" - LogUtils.debug( - f"return (forge new args.txt): {result}", "detect_main_jar_file" + logger.debug( + f"return (forge new args.txt): {result}" ) return result @@ -945,35 +949,35 @@ def detect_main_jar_file(server_path: Path, loader_type: str) -> str: and forge_ver in lower and "installer" not in lower ): - LogUtils.debug( - f"return (forge old): {fname}", "detect_main_jar_file" + logger.debug( + f"return (forge old): {fname}" ) return fname # 3. fallback: 任一含 forge 且非 installer 的 jar for fname, lower in zip(jar_files, jar_files_lower): if "forge" in lower and "installer" not in lower: - LogUtils.debug( - f"return (forge fallback): {fname}", "detect_main_jar_file" + logger.debug( + f"return (forge fallback): {fname}" ) return fname # 4. fallback: server.jar 存在 if (server_path / "server.jar").exists(): - LogUtils.debug( - "return (server.jar fallback): server.jar", "detect_main_jar_file" + logger.debug( + "return (server.jar fallback): server.jar" ) return "server.jar" # 5. fallback: 任一 jar if jar_files: - LogUtils.debug( - f"return (any jar fallback): {jar_files[0]}", "detect_main_jar_file" + logger.debug( + f"return (any jar fallback): {jar_files[0]}" ) return jar_files[0] - LogUtils.debug( - "return (final fallback): server.jar", "detect_main_jar_file" + logger.debug( + "return (final fallback): server.jar" ) return "server.jar" @@ -985,12 +989,12 @@ def detect_main_jar_file(server_path: Path, loader_type: str) -> str: "server.jar", ]: if (server_path / candidate).exists(): - LogUtils.debug( - f"return (fabric): {candidate}", "detect_main_jar_file" + logger.debug( + f"return (fabric): {candidate}" ) return candidate - LogUtils.debug( - "return (fabric fallback): server.jar", "detect_main_jar_file" + logger.debug( + "return (fabric fallback): server.jar" ) return "server.jar" @@ -998,12 +1002,12 @@ def detect_main_jar_file(server_path: Path, loader_type: str) -> str: else: for candidate in ["server.jar", "minecraft_server.jar"]: if (server_path / candidate).exists(): - LogUtils.debug( - f"return (vanilla): {candidate}", "detect_main_jar_file" + logger.debug( + f"return (vanilla): {candidate}" ) return candidate - LogUtils.debug( - "return (vanilla fallback): server.jar", "detect_main_jar_file" + logger.debug( + "return (vanilla fallback): server.jar" ) return "server.jar" @@ -1041,7 +1045,7 @@ def graceful_stop_server(server_manager, server_name: str) -> bool: # 如果命令失敗,使用強制停止 return server_manager.stop_server(server_name) except Exception as e: - LogUtils.error_exc(f"停止伺服器失敗: {e}", "ServerOperations", e) + logger.exception(f"停止伺服器失敗: {e}") return False diff --git a/src/utils/settings_manager.py b/src/utils/settings_manager.py index fb84957..152a9a7 100644 --- a/src/utils/settings_manager.py +++ b/src/utils/settings_manager.py @@ -11,7 +11,48 @@ import json import sys # ====== 專案內部模組 ====== -from src.utils import LogUtils, ensure_dir, get_user_data_dir +from src.utils import ensure_dir, get_user_data_dir +from src.utils.logger import get_logger + +logger = get_logger().bind(component="SettingsManager") + +# ====== 預設設定常數 Default Settings Constants ====== +DEFAULT_WINDOW_PREFERENCES = { + "remember_size_position": True, # 記住視窗大小和位置 + "main_window": { + "width": 1200, + "height": 800, + "x": None, # None 表示置中 + "y": None, + "maximized": False, + }, + "auto_center": True, # 自動置中新視窗 + "adaptive_sizing": True, # 根據螢幕大小自動調整 + "dpi_scaling": 1.0, # DPI 縮放因子 +} + + +def _get_default_settings() -> Dict[str, Any]: + """ + 取得預設設定(根據環境動態計算) + Get default settings (dynamically calculated based on environment) + """ + # 透過檢查是否為打包環境來設定調試日誌預設值 + is_packaged = bool(getattr(sys, "frozen", False) or hasattr(sys, "_MEIPASS")) + # 開發環境預設啟用調試日誌,打包環境預設關閉 + default_debug_logging = not is_packaged + + return { + "servers_root": "", + "auto_update_enabled": True, # 預設啟用自動更新 + "first_run_completed": False, # 標記是否已完成首次執行提示 + "window_preferences": DEFAULT_WINDOW_PREFERENCES.copy(), + "debug_settings": { + "enable_debug_logging": default_debug_logging, # 根據環境設定調試日誌預設值 + "enable_window_state_logging": False, # 控制視窗狀態儲存日誌 + }, + } + class SettingsManager: """ @@ -48,35 +89,7 @@ def _load_settings(self) -> Dict[str, Any]: """ if not self.settings_path.exists(): # 建立預設設定 - # 透過檢查是否為打包環境來設定調試日誌預設值 - is_packaged = bool( - getattr(sys, "frozen", False) or hasattr(sys, "_MEIPASS") - ) - # 開發環境預設啟用調試日誌,打包環境預設關閉 - default_debug_logging = not is_packaged - - default_settings = { - "servers_root": "", - "auto_update_enabled": True, # 預設啟用自動更新 - "first_run_completed": False, # 標記是否已完成首次執行提示 - "window_preferences": { - "remember_size_position": True, # 記住視窗大小和位置 - "main_window": { - "width": 1200, - "height": 800, - "x": None, # None 表示置中 - "y": None, - "maximized": False, - }, - "auto_center": True, # 自動置中新視窗 - "adaptive_sizing": True, # 根據螢幕大小自動調整 - "dpi_scaling": 1.0, # DPI 縮放因子 - }, - "debug_settings": { - "enable_debug_logging": default_debug_logging, # 根據環境設定調試日誌預設值 - "enable_window_state_logging": False, # 控制視窗狀態儲存日誌 - }, - } + default_settings = _get_default_settings() self._save_settings(default_settings) return default_settings @@ -90,42 +103,13 @@ def _load_settings(self) -> Dict[str, Any]: if "first_run_completed" not in settings: settings["first_run_completed"] = False if "window_preferences" not in settings: - settings["window_preferences"] = { - "remember_size_position": True, - "main_window": { - "width": 1200, - "height": 800, - "x": None, - "y": None, - "maximized": False, - }, - "auto_center": True, - "adaptive_sizing": True, - "dpi_scaling": 1.0, - } + settings["window_preferences"] = DEFAULT_WINDOW_PREFERENCES.copy() return settings except Exception as e: - LogUtils.error_exc(f"載入設定失敗: {e}", "SettingsManager", e) + logger.exception(f"載入設定失敗: {e}") # 如果載入失敗,回傳預設設定 - return { - "servers_root": "", - "auto_update_enabled": True, - "first_run_completed": False, - "window_preferences": { - "remember_size_position": True, - "main_window": { - "width": 1200, - "height": 800, - "x": None, - "y": None, - "maximized": False, - }, - "auto_center": True, - "adaptive_sizing": True, - "dpi_scaling": 1.0, - }, - } + return _get_default_settings() # 儲存設定到檔案 def _save_settings(self, settings: Dict[str, Any]) -> None: @@ -143,8 +127,8 @@ def _save_settings(self, settings: Dict[str, Any]) -> None: with open(self.settings_path, "w", encoding="utf-8") as f: json.dump(settings, f, indent=2, ensure_ascii=False) except Exception as e: - LogUtils.error_exc( - f"無法寫入 user_settings.json: {e}", "SettingsManager", e + logger.exception( + f"無法寫入 user_settings.json: {e}" ) raise Exception(f"無法寫入 user_settings.json: {e}") diff --git a/src/utils/ui_utils.py b/src/utils/ui_utils.py index e6b99c3..f7826d4 100644 --- a/src/utils/ui_utils.py +++ b/src/utils/ui_utils.py @@ -16,9 +16,11 @@ # ====== 專案內部模組 ====== from .path_utils import PathUtils from .window_manager import WindowManager -from .log_utils import LogUtils +from .logger import get_logger from .font_manager import font_manager +logger = get_logger().bind(component="UIUtils") + # 設置 CustomTkinter 主題 ctk.set_appearance_mode("light") # 固定使用淺色主題 ctk.set_default_color_theme("blue") # 淺色藍色主題 @@ -69,7 +71,7 @@ def create_modal_dialog( dialog.grab_set() dialog.focus_set() except Exception as e: - LogUtils.error_exc(f"設定模態視窗失敗: {e}", "DialogUtils", e) + logger.exception(f"設定模態視窗失敗: {e}") # 延遲綁定圖示 IconUtils.set_window_icon(dialog, 250) @@ -116,13 +118,13 @@ def _delayed_icon_bind(): try: window.after_idle(window.update_idletasks) except Exception as e: - LogUtils.error_exc( - f"after_idle(update_idletasks) 失敗: {e}", "IconUtils", e + logger.exception( + f"after_idle(update_idletasks) 失敗: {e}" ) else: - LogUtils.warning(f"圖示檔案不存在 - {icon_path}") + logger.warning(f"圖示檔案不存在 - {icon_path}") except Exception as e: - LogUtils.error_exc(f"設定視窗圖示失敗 - {e}", "IconUtils", e) + logger.exception(f"設定視窗圖示失敗 - {e}") # 延遲綁定圖示,確保視窗完全初始化完成 try: @@ -134,7 +136,7 @@ def _delayed_icon_bind(): else: _delayed_icon_bind() # 立即執行作為備選 except Exception as e: - LogUtils.warning(f"無法延遲執行圖示綁定: {e}") + logger.warning(f"無法延遲執行圖示綁定: {e}") _delayed_icon_bind() # 直接執行作為最後備選 # ====== UI 通用工具類別 ====== @@ -187,7 +189,7 @@ def setup_window_properties( window.grab_set() window.focus_set() except Exception as e: - LogUtils.error_exc(f"設定模態視窗失敗: {e}", "UIUtils", e) + logger.exception(f"設定模態視窗失敗: {e}") # 延遲綁定圖示,確保不會被覆蓋,使用更長的延遲 if bind_icon: @@ -234,7 +236,7 @@ def safe_update_widget(widget, update_func: Callable, *args, **kwargs) -> None: if widget and widget.winfo_exists(): update_func(widget, *args, **kwargs) except Exception as e: - LogUtils.error_exc(f"更新 widget 失敗: {e}", "UIUtils", e) + logger.exception(f"更新 widget 失敗: {e}") @staticmethod def safe_config_widget(widget, **config) -> None: @@ -275,18 +277,14 @@ def _cancel_existing() -> None: if job_id: widget.after_cancel(job_id) except Exception as e: - LogUtils.error_exc( - f"取消舊的 UI queue pump job 失敗(視窗可能已關閉): {e}", - "UIUtils", - e, + logger.exception( + f"取消舊的 UI queue pump job 失敗(視窗可能已關閉): {e}" ) try: setattr(widget, job_attr, None) except Exception as e: - LogUtils.error_exc( - f"重設 UI queue pump job 欄位失敗(視窗可能已關閉): {e}", - "UIUtils", - e, + logger.exception( + f"重設 UI queue pump job 欄位失敗(視窗可能已關閉): {e}" ) def _tick() -> None: @@ -303,7 +301,7 @@ def _tick() -> None: try: task() except Exception as e: - LogUtils.error_exc(f"UI 任務執行失敗: {e}", "UIUtils", e) + logger.exception(f"UI 任務執行失敗: {e}") processed += 1 if not _alive(): @@ -320,10 +318,8 @@ def _tick() -> None: setattr(widget, job_attr, widget.after(next_delay, _tick)) except Exception as e: # 視窗可能正在銷毀,忽略 - LogUtils.error_exc( - f"排程下一次 UI queue pump 失敗(視窗可能正在銷毀): {e}", - "UIUtils", - e, + logger.exception( + f"排程下一次 UI queue pump 失敗(視窗可能正在銷毀): {e}" ) if not _alive(): @@ -358,19 +354,19 @@ def _dispatch(cb: Optional[Callable[[], None]]) -> None: ui_queue.put(cb) return except Exception as e: - LogUtils.debug(f"ui_queue put 失敗: {e}", "UIUtils") + logger.debug(f"ui_queue put 失敗: {e}") pass if widget is not None: try: widget.after(0, cb) return except Exception as e: - LogUtils.debug(f"widget.after 失敗: {e}", "UIUtils") + logger.debug(f"widget.after 失敗: {e}") pass try: cb() except Exception as e: - LogUtils.debug(f"直接執行 callback 失敗: {e}", "UIUtils") + logger.debug(f"直接執行 callback 失敗: {e}") pass def _wrapper() -> None: @@ -378,7 +374,7 @@ def _wrapper() -> None: task_func() except Exception as e: prefix = (error_log_prefix + ": ") if error_log_prefix else "" - LogUtils.error_exc(f"{prefix}{e}", component, e) + get_logger().bind(component=component).exception(f"{prefix}{e}") _dispatch(on_error) threading.Thread(target=_wrapper, daemon=True).start() @@ -416,23 +412,23 @@ def _destroy_tooltip() -> None: if tip.winfo_exists(): tip.destroy() except Exception as e: - LogUtils.debug(f"銷毀 tooltip 失敗: {e}", "UIUtils") + logger.debug(f"銷毀 tooltip 失敗: {e}", "UIUtils") pass try: setattr(widget, "_msm_tooltip", None) except Exception as e: - LogUtils.debug(f"重置 _msm_tooltip 屬性失敗: {e}", "UIUtils") + logger.debug(f"重置 _msm_tooltip 屬性失敗: {e}", "UIUtils") job = getattr(widget, "_msm_tooltip_job", None) if job is not None: try: widget.after_cancel(job) except Exception as e: - LogUtils.debug(f"取消 tooltip job 失敗: {e}", "UIUtils") + logger.debug(f"取消 tooltip job 失敗: {e}", "UIUtils") pass try: setattr(widget, "_msm_tooltip_job", None) except Exception as e: - LogUtils.debug(f"重置 _msm_tooltip_job 屬性失敗: {e}", "UIUtils") + logger.debug(f"重置 _msm_tooltip_job 屬性失敗: {e}", "UIUtils") def _show_tooltip(event) -> None: try: @@ -470,10 +466,10 @@ def _show_tooltip(event) -> None: tip.after(auto_hide_ms, _destroy_tooltip), ) except Exception as e: - LogUtils.debug(f"設定 tooltip 自動隱藏失敗: {e}", "UIUtils") + logger.debug(f"設定 tooltip 自動隱藏失敗: {e}", "UIUtils") pass except Exception as e: - LogUtils.error_exc(f"顯示 tooltip 失敗: {e}", "UIUtils", e) + logger.exception(f"顯示 tooltip 失敗: {e}") def _hide_tooltip(_event=None) -> None: _destroy_tooltip() @@ -482,7 +478,7 @@ def _hide_tooltip(_event=None) -> None: widget.bind("", _show_tooltip) widget.bind("", _hide_tooltip) except Exception as e: - LogUtils.error_exc(f"綁定 tooltip 事件失敗: {e}", "UIUtils", e) + logger.exception(f"綁定 tooltip 事件失敗: {e}") # 顯示錯誤對話框 @staticmethod @@ -505,7 +501,7 @@ def show_error( None """ # 任何顯示給使用者的錯誤,都同時寫入 log,方便追蹤 - LogUtils.error(f"{title}: {message}", "UIUtils") + logger.error(f"{title}: {message}") try: # 如果沒有父視窗,創建臨時根視窗 @@ -533,8 +529,8 @@ def show_error( else: tk.messagebox.showerror(title, message, parent=parent) except Exception as e: - LogUtils.error_exc(f"顯示錯誤對話框失敗: {e}", "UIUtils", e) - LogUtils.error(f"錯誤: {title} - {message}") + logger.exception(f"顯示錯誤對話框失敗: {e}") + logger.error(f"錯誤: {title} - {message}") # 顯示警告對話框 @staticmethod @@ -582,8 +578,8 @@ def show_warning( else: tk.messagebox.showwarning(title, message, parent=parent) except Exception as e: - LogUtils.error_exc(f"顯示警告對話框失敗: {e}", "UIUtils", e) - LogUtils.warning(f"警告: {title} - {message}") + logger.exception(f"顯示警告對話框失敗: {e}") + logger.warning(f"警告: {title} - {message}") # 顯示資訊對話框 @staticmethod @@ -631,8 +627,8 @@ def show_info( else: tk.messagebox.showinfo(title, message, parent=parent) except Exception as e: - LogUtils.error_exc(f"顯示資訊對話框失敗: {e}", "UIUtils", e) - LogUtils.warning(f"警告: {title} - {message}") + logger.exception(f"顯示資訊對話框失敗: {e}") + logger.warning(f"警告: {title} - {message}") # 顯示確認對話框(是/否/取消) @staticmethod @@ -690,8 +686,8 @@ def ask_yes_no_cancel( else: return tk.messagebox.askyesno(title, message, parent=parent) except Exception as e: - LogUtils.error_exc(f"顯示確認對話框失敗: {e}", "UIUtils", e) - LogUtils.warning(f"確認: {title} - {message}") + logger.exception(f"顯示確認對話框失敗: {e}") + logger.warning(f"確認: {title} - {message}") return False if not show_cancel else None @staticmethod @@ -746,13 +742,13 @@ def on_mouse_wheel(event): dropdown_widget._command(values[new_index]) except Exception as e: - LogUtils.error_exc(f"滑鼠滾輪處理錯誤: {e}", "UIUtils", e) + logger.exception(f"滑鼠滾輪處理錯誤: {e}") # 綁定滑鼠滾輪事件 dropdown_widget.bind("", on_mouse_wheel) except Exception as e: - LogUtils.error_exc(f"應用下拉選單樣式失敗: {e}", "UIUtils", e) + logger.exception(f"應用下拉選單樣式失敗: {e}") @staticmethod def create_styled_button( @@ -840,7 +836,7 @@ def __init__(self, parent, title="進度", show_cancel=True): self.dialog.grab_set() self.dialog.focus_set() except Exception as e: - LogUtils.error_exc(f"設定模態視窗失敗: {e}", "ProgressDialog", e) + logger.exception(f"設定模態視窗失敗: {e}") # 延遲綁定圖示 IconUtils.set_window_icon(self.dialog, 250) @@ -901,7 +897,7 @@ def _update(): self.status_label.configure(text=status_text) self.percent_label.configure(text=f"{percent:.1f}%") except Exception as e: - LogUtils.error_exc(f"更新進度 UI 失敗: {e}", "ProgressDialog", e) + logger.exception(f"更新進度 UI 失敗: {e}") # 確保在主線程執行 if threading.current_thread() is threading.main_thread(): @@ -934,4 +930,4 @@ def close(self) -> None: try: self.dialog.destroy() except Exception as e: - LogUtils.error_exc(f"關閉進度對話框失敗: {e}", "ProgressDialog", e) + logger.exception(f"關閉進度對話框失敗: {e}") diff --git a/src/utils/update_checker.py b/src/utils/update_checker.py index b46cbb9..2698f00 100644 --- a/src/utils/update_checker.py +++ b/src/utils/update_checker.py @@ -5,44 +5,43 @@ # ====== 標準函式庫 ====== from pathlib import Path +from typing import Optional import os import re import tempfile import threading import webbrowser +# ====== 第三方函式庫 ====== +from packaging.version import Version, InvalidVersion + # ====== 專案內部模組 ====== from .http_utils import HTTPUtils -from .log_utils import LogUtils +from .logger import get_logger from .ui_utils import UIUtils -GITHUB_API = "https://api.github.com" +logger = get_logger().bind(component="UpdateChecker") -# 預編譯正則表達式以提升效能 -_VERSION_PATTERN = re.compile(r"(\d+)\.(\d+)(?:\.(\d+))?") +GITHUB_API = "https://api.github.com" -def _normalize_version(v: str) -> tuple: +def _parse_version(version_str: str) -> Optional[Version]: """ - 將版本字串標準化為可比較的 release 版本 tuple。 + 解析版本字串為 Version 物件 + Parse version string to Version object + + Args: + version_str: 版本字串(可能包含 'v' 或 'V' 前綴) + + Returns: + Version 物件,解析失敗時返回 None """ - - if not v: - return (0, 0, 0) - - text = str(v).strip() - if text.startswith(("v", "V")): - text = text[1:] - - # 使用預編譯的正則表達式 - m = _VERSION_PATTERN.search(text) - if not m: - return (0, 0, 0) - - major = int(m.group(1)) - minor = int(m.group(2)) - patch = int(m.group(3)) if m.group(3) is not None else 0 - return (major, minor, patch) + try: + # 移除前綴 'v' 或 'V' + clean_version = version_str.strip().lstrip('vV') + return Version(clean_version) + except (InvalidVersion, Exception): + return None def _get_latest_release(owner: str, repo: str) -> dict: @@ -62,7 +61,7 @@ def _get_latest_release(owner: str, repo: str) -> dict: if rel and not rel.get("draft") and not rel.get("prerelease"): return rel except Exception as e: - LogUtils.debug(f"檢查 release 資料時發生錯誤: {e}", "UpdateChecker") + logger.debug(f"檢查 release 資料時發生錯誤: {e}") continue return {} @@ -77,7 +76,7 @@ def _choose_installer_asset(release: dict) -> dict: if name.endswith(".exe") and a.get("browser_download_url"): exe_assets.append(a) except Exception as e: - LogUtils.debug(f"檢查 asset 資料時發生錯誤: {e}", "UpdateChecker") + logger.debug(f"檢查 asset 資料時發生錯誤: {e}") continue if not exe_assets: return {} @@ -138,7 +137,7 @@ def _runner(): return result["value"] except Exception as e: # 任何排程失敗都退回直接呼叫(最後備援) - LogUtils.debug(f"UI 排程執行失敗,回退至直接呼叫: {e}", "UpdateChecker") + logger.debug(f"UI 排程執行失敗,回退至直接呼叫: {e}") pass return func() @@ -158,7 +157,17 @@ def _work() -> None: return latest_tag = latest.get("tag_name") or "" - if _normalize_version(latest_tag) <= _normalize_version(current_version): + + # 使用 packaging.version 進行版本比較 + latest_ver = _parse_version(latest_tag) + current_ver = _parse_version(current_version) + + if not latest_ver or not current_ver: + logger.warning("無法解析版本號,跳過更新檢查") + return + + # 比較版本 + if latest_ver <= current_ver: if show_up_to_date_message: _call_on_ui( lambda: UIUtils.show_info( @@ -243,7 +252,7 @@ def _work() -> None: ) ) except Exception as e: - LogUtils.error_exc(f"更新檢查失敗: {e}", "UpdateChecker", e) + logger.exception(f"更新檢查失敗: {e}") _call_on_ui( lambda: UIUtils.show_error( "更新檢查失敗", diff --git a/src/utils/window_manager.py b/src/utils/window_manager.py index 81f60d1..ef2e1cc 100644 --- a/src/utils/window_manager.py +++ b/src/utils/window_manager.py @@ -13,7 +13,9 @@ import time # ====== 專案內部模組 ====== from .settings_manager import get_settings_manager -from .log_utils import LogUtils +from .logger import get_logger + +logger = get_logger().bind(component="WindowManager") class WindowManager: """ @@ -79,7 +81,7 @@ def get_screen_info(window=None) -> Dict[str, Any]: "center_y": screen_height // 2, } except Exception as e: - LogUtils.error_exc(f"取得螢幕資訊失敗: {e}", "WindowManager", e) + logger.exception(f"取得螢幕資訊失敗: {e}") # 回傳預設值 return { "width": 1920, @@ -206,9 +208,9 @@ def setup_main_window(window, force_defaults: bool = False) -> None: ): window.after(100, lambda: window.state("zoomed")) - LogUtils.debug_window_state(f"主視窗設定: {width}x{height}+{x}+{y}") + get_logger().bind(component="WindowState").debug(f"主視窗設定: {width}x{height}+{x}+{y}") except Exception as e: - LogUtils.error_exc(f"設定主視窗失敗: {e}", "WindowManager", e) + logger.exception(f"設定主視窗失敗: {e}") # 備用設定 window.geometry("1200x800") window.minsize(1000, 700) @@ -254,10 +256,10 @@ def save_main_window_state(window) -> None: not hasattr(WindowManager, "_last_debug_time") or current_time - WindowManager._last_debug_time > 5 ): - LogUtils.debug_window_state("已儲存主視窗狀態") + get_logger().bind(component="WindowState").debug("已儲存主視窗狀態") WindowManager._last_debug_time = current_time except Exception as e: - LogUtils.error_exc(f"儲存主視窗狀態失敗: {e}", "WindowManager", e) + logger.exception(f"儲存主視窗狀態失敗: {e}") @staticmethod def setup_dialog_window( @@ -319,9 +321,9 @@ def setup_dialog_window( # 設定視窗幾何 try: window.geometry(f"{width}x{height}+{x}+{y}") - LogUtils.debug(f"對話框設定: {width}x{height}+{x}+{y}", "WindowManager") + logger.debug(f"對話框設定: {width}x{height}+{x}+{y}") except Exception as e: - LogUtils.error_exc(f"設定對話框失敗: {e}", "WindowManager", e) + logger.exception(f"設定對話框失敗: {e}") @staticmethod def bind_window_state_tracking(window) -> None: diff --git a/uv.lock b/uv.lock index 35c5006..7a744a2 100644 --- a/uv.lock +++ b/uv.lock @@ -214,6 +214,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + [[package]] name = "customtkinter" version = "5.2.2" @@ -318,6 +327,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + [[package]] name = "lxml" version = "6.0.2" @@ -450,6 +472,7 @@ source = { virtual = "." } dependencies = [ { name = "aiohttp" }, { name = "customtkinter" }, + { name = "loguru" }, { name = "lxml" }, { name = "nuitka" }, { name = "packaging" }, @@ -466,6 +489,7 @@ dependencies = [ requires-dist = [ { name = "aiohttp", specifier = ">=3.13.2" }, { name = "customtkinter", specifier = ">=5.2.2" }, + { name = "loguru", specifier = ">=0.7.3" }, { name = "lxml", specifier = ">=6.0.2" }, { name = "nuitka", specifier = ">=2.8.9" }, { name = "packaging", specifier = ">=25.0" }, @@ -766,6 +790,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ed/9d/3b2631931649b1783f5024796ca8ad2b42a01a829b9ce1202d973cc7bce5/uv-0.9.26-py3-none-win_arm64.whl", hash = "sha256:344ff38749b6cd7b7dfdfb382536f168cafe917ae3a5aa78b7a63746ba2a905b", size = 22158123, upload-time = "2026-01-15T20:51:30.939Z" }, ] +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] + [[package]] name = "yarl" version = "1.22.0"