diff --git a/app/common/IPC_URL/csharp_ipc_handler.py b/app/common/IPC_URL/csharp_ipc_handler.py index 25873237..5b035f83 100644 --- a/app/common/IPC_URL/csharp_ipc_handler.py +++ b/app/common/IPC_URL/csharp_ipc_handler.py @@ -66,6 +66,7 @@ def __init__(self): self.client_thread: Optional[threading.Thread] = None self.is_running = False self.is_connected = False + self._stop_event = threading.Event() self._disconnect_logged = False # 跟踪是否已记录断连日志 self._last_on_class_left_log_time = 0 # 上次记录距离上课时间的时间 @@ -80,6 +81,7 @@ def start_ipc_client(self) -> bool: return True try: + self._stop_event.clear() self.client_thread = threading.Thread( target=self._run_client, daemon=False ) @@ -92,9 +94,24 @@ def start_ipc_client(self) -> bool: def stop_ipc_client(self): """停止 C# IPC 客户端""" + if not self.is_running: + return + + logger.debug("正在停止 C# IPC 客户端...") self.is_running = False + self._stop_event.set() + if self.client_thread and self.client_thread.is_alive(): - self.client_thread.join(timeout=1) + logger.debug("等待 C# IPC 线程结束...") + # 给予足够的时间正常退出,因为 daemon=False + self.client_thread.join(timeout=1.0) + if self.client_thread.is_alive(): + logger.warning("C# IPC 线程未能在超时时间内完全结束,可能由于 .NET 调用阻塞") + + # 线程结束后再清理资源,避免竞态条件 + self.ipc_client = None + self.is_connected = False + logger.debug("C# IPC 客户端停止指令已发出") def send_notification( self, @@ -208,39 +225,77 @@ def _run_client(self): async def client(): """异步客户端""" - - self.ipc_client = IpcClient() - self.ipc_client.JsonIpcProvider.AddNotifyHandler( - IpcRoutedNotifyIds.OnClassNotifyId, - Action(lambda: self._on_class_test()), - ) - - task = self.ipc_client.Connect() - await loop.run_in_executor(None, lambda: task.Wait()) - self.is_connected = True - - while self.is_running: - await asyncio.sleep(1) - - if not self._check_alive(): - if not self._disconnect_logged: - logger.debug("C# IPC 断连!重连...") - self._disconnect_logged = True + try: + self.ipc_client = IpcClient() + self.ipc_client.JsonIpcProvider.AddNotifyHandler( + IpcRoutedNotifyIds.OnClassNotifyId, + Action(lambda: self._on_class_test()), + ) + + task = self.ipc_client.Connect() + # 优化:在等待连接时定期检查 is_running 标志,以便快速响应退出请求 + # 避免在 .NET 内部 Wait() 导致线程无法被 Python 正常终止 + while self.is_running and not task.IsCompleted: + await asyncio.sleep(0.1) + + if not self.is_running: + return + + if task.IsFaulted: + logger.error(f"C# IPC 连接失败: {task.Exception}") self.is_connected = False - - task = self.ipc_client.Connect() - await loop.run_in_executor(None, task.Wait) - self.is_connected = True - self._disconnect_logged = False - - self.ipc_client = None - self.is_connected = False + return + + self.is_connected = True + + while self.is_running: + # 缩短轮询间隔,提高退出响应速度 (每次睡眠 0.1s,共 0.5s) + for _ in range(5): + if not self.is_running: + break + await asyncio.sleep(0.1) + + if not self.is_running: + break + + if not self._check_alive(): + if not self._disconnect_logged: + logger.debug("C# IPC 断连!重连...") + self._disconnect_logged = True + self.is_connected = False + + task = self.ipc_client.Connect() + while self.is_running and not task.IsCompleted: + await asyncio.sleep(0.1) + + if not self.is_running: + break + + if task.IsFaulted: + continue + + self.is_connected = True + self._disconnect_logged = False + except Exception as e: + if self.is_running: + logger.error(f"C# IPC 客户端运行出错: {e}") + finally: + # 在线程内部安全地释放资源 + try: + if self.ipc_client and hasattr(self.ipc_client, "Dispose"): + self.ipc_client.Dispose() + logger.debug("C# IPC 客户端资源已释放 (Dispose)") + except Exception as e: + logger.debug(f"释放 C# IPC 客户端资源时出错: {e}") + self.is_connected = False # 启动新的 asyncio 事件循环 loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - loop.run_until_complete(client()) - loop.close() + try: + loop.run_until_complete(client()) + finally: + loop.close() def _check_alive(self) -> bool: """客户端是否正常连接""" @@ -291,6 +346,7 @@ def start_ipc_client(self) -> bool: def stop_ipc_client(self): """停止 C# IPC 客户端""" + logger.debug("C# IPC 处理器未启用,无需停止") pass def send_notification( diff --git a/app/common/safety/secure_store.py b/app/common/safety/secure_store.py index 96ed9259..6a2ece23 100644 --- a/app/common/safety/secure_store.py +++ b/app/common/safety/secure_store.py @@ -102,10 +102,13 @@ def read_secrets() -> dict: # 不存在则创建空文件 if not os.path.exists(p): ensure_dir(os.path.dirname(p)) - with open(p, "wb") as f: - f.write(b"") - _set_hidden(str(p)) - logger.debug(f"创建空安全配置文件:{p}") + try: + with open(p, "w", encoding="utf-8") as f: + json.dump({}, f) + _set_hidden(str(p)) + logger.debug(f"创建空安全配置文件:{p}") + except Exception as e: + logger.warning(f"创建安全配置文件失败: {e}") return {} if os.path.exists(p): try: @@ -185,10 +188,13 @@ def read_behind_scenes_settings() -> dict: p = get_settings_path("behind_scenes.json") if not os.path.exists(p): ensure_dir(os.path.dirname(p)) - with open(p, "wb") as f: - f.write(b"") - _set_hidden(str(p)) - logger.debug(f"创建空内幕设置文件:{p}") + try: + with open(p, "w", encoding="utf-8") as f: + json.dump({}, f) + _set_hidden(str(p)) + logger.debug(f"创建空内幕设置文件:{p}") + except Exception as e: + logger.warning(f"创建内幕设置文件失败: {e}") return {} if os.path.exists(p): try: diff --git a/app/common/shortcut/shortcut_manager.py b/app/common/shortcut/shortcut_manager.py index f8f4fc5c..db768489 100644 --- a/app/common/shortcut/shortcut_manager.py +++ b/app/common/shortcut/shortcut_manager.py @@ -221,10 +221,17 @@ def is_enabled(self) -> bool: def cleanup(self): """清理所有快捷键""" - logger.info("清理所有快捷键") - for config_key, hotkey in self.shortcuts.items(): - try: - keyboard.remove_hotkey(hotkey) - except Exception as e: - logger.error(f"注销快捷键失败 {config_key}: {e}") - self.shortcuts.clear() + if self.shortcuts: + logger.info(f"清理已注册的 {len(self.shortcuts)} 个快捷键") + for config_key, hotkey in self.shortcuts.items(): + try: + keyboard.remove_hotkey(hotkey) + except Exception as e: + logger.error(f"注销快捷键失败 {config_key}: {e}") + self.shortcuts.clear() + + # 无论是否有已注册快捷键,都尝试清理全局钩子,确保 keyboard 库的监听线程能正确关闭 + try: + keyboard.unhook_all() + except Exception as e: + logger.warning(f"清理全局键盘钩子失败: {e}") diff --git a/app/core/window_manager.py b/app/core/window_manager.py index c8e9a583..fb19a23e 100644 --- a/app/core/window_manager.py +++ b/app/core/window_manager.py @@ -19,6 +19,15 @@ def __init__(self) -> None: self.settings_window: Optional["QWidget"] = None self.float_window: Optional["QWidget"] = None self.url_handler: Optional = None + self.shared_memory = None + + def set_shared_memory(self, shared_memory) -> None: + """设置共享内存实例 + + Args: + shared_memory: QSharedMemory 实例 + """ + self.shared_memory = shared_memory def set_url_handler(self, url_handler) -> None: """设置URL处理器 @@ -42,7 +51,9 @@ def _create_main_window_impl(self) -> None: self.create_float_window() self.main_window = MainWindow( - float_window=self.float_window, url_handler_instance=self.url_handler + float_window=self.float_window, + url_handler_instance=self.url_handler, + shared_memory=self.shared_memory, ) self._connect_main_window_signals() diff --git a/app/tools/update_utils.py b/app/tools/update_utils.py index be0d891f..cd710855 100644 --- a/app/tools/update_utils.py +++ b/app/tools/update_utils.py @@ -1818,3 +1818,17 @@ def check_for_updates_on_startup(settings_window=None): update_check_thread = UpdateCheckThread(settings_window) update_check_thread.start() return update_check_thread + + +def stop_update_check(): + """停止更新检查线程""" + global update_check_thread + if update_check_thread and update_check_thread.isRunning(): + logger.debug("停止更新检查线程...") + update_check_thread.terminate() # 直接强制终止 + # 给予一定时间正常退出 + if not update_check_thread.wait(1000): + logger.warning("更新检查线程未能在超时时间内正常退出,强制终止") + update_check_thread.terminate() + update_check_thread.wait(500) + update_check_thread = None diff --git a/app/view/main/window.py b/app/view/main/window.py index 6e216b30..35b4f9cc 100644 --- a/app/view/main/window.py +++ b/app/view/main/window.py @@ -2,18 +2,19 @@ # 导入库 # ================================================== import sys +import os import subprocess import loguru from loguru import logger from PySide6.QtWidgets import QApplication, QWidget from PySide6.QtGui import QIcon -from PySide6.QtCore import QTimer, QEvent, Signal +from PySide6.QtCore import QTimer, QEvent, Signal, QSharedMemory from qfluentwidgets import FluentWindow, NavigationItemPosition from app.common.IPC_URL.csharp_ipc_handler import CSharpIPCHandler from app.common.shortcut import ShortcutManager -from app.tools.variable import MINIMUM_WINDOW_SIZE, APP_INIT_DELAY +from app.tools.variable import MINIMUM_WINDOW_SIZE, APP_INIT_DELAY, SHARED_MEMORY_KEY from app.tools.path_utils import get_data_path, get_app_root from app.tools.personalised import get_theme_icon from app.tools.settings_access import get_safe_font_size @@ -32,6 +33,7 @@ from app.view.tray.tray import Tray from app.view.floating_window.levitation import LevitationWindow from app.common.IPC_URL.url_command_handler import URLCommandHandler +from app.common.music.music_player import music_player # ================================================== @@ -48,13 +50,20 @@ class MainWindow(FluentWindow): showTrayActionRequested = Signal(str) # 请求执行托盘操作 classIslandDataReceived = Signal(dict) # 接收ClassIsland数据信号 - def __init__(self, float_window: LevitationWindow, url_handler_instance=None): + def __init__( + self, + float_window: LevitationWindow, + url_handler_instance=None, + shared_memory=None, + ): super().__init__() # 设置窗口对象名称,方便其他组件查找 self.setObjectName("MainWindow") # 保存URL处理器实例引用 self.url_handler_instance = url_handler_instance + # 保存共享内存引用 + self.shared_memory_instance = shared_memory self.roll_call_page = None self.settingsInterface = None @@ -624,23 +633,108 @@ def toggle_window(self): def close_window_secrandom(self): """关闭窗口 执行安全验证后关闭程序,释放所有资源""" - # 停止课前重置定时器 - if self.pre_class_reset_timer.isActive(): + logger.info("开始执行程序退出流程...") + + self._perform_cleanup() + + logger.info("正在请求退出 QApplication...") + QApplication.quit() + + # 尝试处理最后残留的事件 + QApplication.processEvents() + + logger.info("退出流程执行完毕,终止进程") + sys.exit(0) + + def _perform_cleanup(self): + """执行通用的清理逻辑,确保程序能够干净地退出""" + # 1. 停止更新检查线程 + try: + from app.tools import update_utils + # 使用标准的 API 停止线程,避免使用危险的 terminate() + if hasattr(update_utils, "stop_update_check"): + update_utils.stop_update_check() + elif hasattr(update_utils, "update_check_thread") and update_utils.update_check_thread: + thread = update_utils.update_check_thread + if thread.isRunning(): + logger.debug("正在请求停止更新检查线程...") + if hasattr(thread, "stop"): + thread.stop() + else: + thread.terminate() + thread.wait(1000) + except Exception as e: + logger.debug(f"停止更新检查线程时发生非致命错误: {e}") + + # 2. 停止所有后台服务(音乐、语音等) + self._stop_all_services() + + # 3. 停止课前重置定时器 + if hasattr(self, "pre_class_reset_timer") and self.pre_class_reset_timer.isActive(): + logger.debug("停止课前重置定时器") self.pre_class_reset_timer.stop() + # 4. 清理快捷键 self.cleanup_shortcuts() + + # 5. 停止 C# IPC 客户端 + # 注意:stop_ipc_client 内部现在已经处理了优雅退出逻辑 + CSharpIPCHandler.instance().stop_ipc_client() + + # 6. 显式释放共享内存 try: - loguru.logger.remove() + # 优先使用保存的共享内存实例进行分离 + if self.shared_memory_instance: + if self.shared_memory_instance.isAttached(): + self.shared_memory_instance.detach() + logger.debug("已通过主实例分离共享内存") + else: + # 备选方案:尝试创建临时实例并分离(虽然不推荐,但可作为最后尝试) + shared_mem = QSharedMemory(SHARED_MEMORY_KEY) + if shared_mem.attach(): + shared_mem.detach() + logger.debug("已通过临时实例分离共享内存") except Exception as e: - logger.error(f"日志系统关闭出错: {e}") + logger.debug(f"释放共享内存时发生非致命错误: {e}") - QApplication.quit() - CSharpIPCHandler.instance().stop_ipc_client() - sys.exit(0) + # 7. 刷新标准流,确保所有日志都已写入 + try: + sys.stderr.flush() + sys.stdout.flush() + except Exception as e: + # 在退出前刷新标准流失败时忽略异常,但记录调试信息 + logger.debug(f"刷新标准输出/错误流时出错: {e}") + + def _stop_all_services(self): + """停止所有后台服务(音乐、语音等)""" + try: + # 停止全局音乐播放器 + from app.common.music.music_player import music_player + # 只有在正在播放时才尝试停止 + if music_player.is_playing(): + music_player.stop_music(fade_out=False) + except Exception as e: + logger.debug(f"停止音乐播放器时发生非致命错误: {e}") + + try: + # 停止各页面的语音播放器 + # 显式检查页面是否存在,避免 AttributeError + pages_to_cleanup = [] + for attr_name in ["roll_call_page", "lottery_page"]: + page_obj = getattr(self, attr_name, None) + if page_obj is not None: + pages_to_cleanup.append(page_obj) + + for page in pages_to_cleanup: + if hasattr(page, "tts_handler") and page.tts_handler: + page.tts_handler.stop() + except Exception as e: + logger.debug(f"停止语音处理器时发生非致命错误: {e}") def cleanup_shortcuts(self): """清理快捷键""" if hasattr(self, "shortcut_manager"): + # 内部 cleanup 会判断是否有快捷键需要清理,并处理日志 self.shortcut_manager.cleanup() def _connect_shortcut_signals(self): @@ -755,9 +849,8 @@ def _on_shortcut_settings_changed( self, first_level_key: str, second_level_key: str, value ): """当快捷键设置发生变化时的处理函数""" - logger.debug(f"快捷键设置变化: {first_level_key}.{second_level_key} = {value}") - if first_level_key == "shortcut_settings": + logger.debug(f"快捷键设置变化: {first_level_key}.{second_level_key} = {value}") if second_level_key == "enable_shortcut": logger.debug(f"快捷键启用状态变化: {value}") self.shortcut_manager.set_enabled(value) @@ -789,20 +882,15 @@ def restart_app(self): logger.error(f"启动新进程失败: {e}") return - # 停止课前重置定时器 - if self.pre_class_reset_timer.isActive(): - self.pre_class_reset_timer.stop() + self._perform_cleanup() - self.cleanup_shortcuts() + logger.info("正在请求退出 QApplication 以进行重启...") + QApplication.quit() - try: - loguru.logger.remove() - except Exception as e: - logger.error(f"日志系统关闭出错: {e}") + # 尝试处理最后残留的事件 + QApplication.processEvents() - # 完全退出当前应用程序 - QApplication.quit() - CSharpIPCHandler.instance().stop_ipc_client() + logger.info("重启前的清理流程执行完毕,终止当前进程") sys.exit(0) def _check_pre_class_reset(self): diff --git a/main.py b/main.py index 71df545f..d8e7247c 100644 --- a/main.py +++ b/main.py @@ -68,6 +68,7 @@ def main(): app.setAttribute(Qt.ApplicationAttribute.AA_DontCreateNativeWidgetSiblings) window_manager = WindowManager() + window_manager.set_shared_memory(shared_memory) url_handler = create_url_handler() cs_ipc_handler = create_cs_ipc_handler() window_manager.set_url_handler(url_handler)