#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ P2P Chat Client GUI 启动脚本 用法: python run_client_gui.py [--server HOST] [--port PORT] 示例: python run_client_gui.py python run_client_gui.py --server 192.168.1.100 --port 8888 """ import asyncio import argparse import logging import sys import os from typing import Optional, List # 确保能找到模块 sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from PyQt6.QtWidgets import QApplication, QMessageBox, QSplashScreen from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal, QObject from PyQt6.QtGui import QPixmap, QFont from client.app import P2PClientApp from client.connection_manager import ConnectionState from client.ui import ( MainWindow, LoginDialog, ChatWidget, ContactListWidget, FileTransferWidget, MediaPlayerWidget, VoiceCallWidget, SystemTrayManager ) from shared.models import UserInfo, UserStatus, Message, MessageType from config import load_client_config, ClientConfig # 配置日志 logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[ logging.StreamHandler(sys.stdout), logging.FileHandler('client_gui.log', encoding='utf-8') ] ) logger = logging.getLogger(__name__) class AsyncWorker(QThread): """异步工作线程,用于运行 asyncio 事件循环""" connected = pyqtSignal(bool, str) # (success, message) disconnected = pyqtSignal(str) # reason message_received = pyqtSignal(object) # Message user_list_updated = pyqtSignal(list) # List[UserInfo] state_changed = pyqtSignal(str, str) # (state, reason) def __init__(self, client: P2PClientApp, user_info: UserInfo): super().__init__() self.client = client self.user_info = user_info self._running = False self._loop: Optional[asyncio.AbstractEventLoop] = None def run(self): """运行异步事件循环""" self._loop = asyncio.new_event_loop() asyncio.set_event_loop(self._loop) self._running = True try: # 连接到服务器 success = self._loop.run_until_complete( self.client.start(self.user_info) ) if success: self.connected.emit(True, "连接成功") # 设置回调 self.client.add_message_callback(self._on_message) self.client.add_state_callback(self._on_state_change) self.client.add_user_list_callback(self._on_user_list) # 保持运行 while self._running: self._loop.run_until_complete(asyncio.sleep(0.1)) else: self.connected.emit(False, "连接失败") except Exception as e: logger.error(f"Worker error: {e}") self.connected.emit(False, str(e)) finally: self._loop.close() def stop(self): """停止工作线程""" self._running = False if self._loop and self._loop.is_running(): self._loop.call_soon_threadsafe(self._loop.stop) def _on_message(self, message: Message): """消息回调""" self.message_received.emit(message) def _on_state_change(self, state: ConnectionState, reason: Optional[str]): """状态变化回调""" self.state_changed.emit(state.value, reason or "") if state == ConnectionState.DISCONNECTED: self.disconnected.emit(reason or "连接断开") def _on_user_list(self, users: List[UserInfo]): """用户列表更新回调""" self.user_list_updated.emit(users) def send_message(self, peer_id: str, content: str) -> bool: """发送消息(线程安全)""" if self._loop and self._running: future = asyncio.run_coroutine_threadsafe( self.client.send_text_message(peer_id, content), self._loop ) try: return future.result(timeout=5.0) except Exception as e: logger.error(f"Send message error: {e}") return False return False def send_file(self, peer_id: str, file_path: str) -> bool: """发送文件(线程安全)""" if self._loop and self._running: future = asyncio.run_coroutine_threadsafe( self.client.send_file(peer_id, file_path), self._loop ) try: return future.result(timeout=300.0) # 5分钟超时 except Exception as e: logger.error(f"Send file error: {e}") return False return False def send_image(self, peer_id: str, image_path: str) -> bool: """发送图片(线程安全)""" if self._loop and self._running: future = asyncio.run_coroutine_threadsafe( self.client.send_image(peer_id, image_path), self._loop ) try: return future.result(timeout=300.0) except Exception as e: logger.error(f"Send image error: {e}") return False return False def start_voice_call(self, peer_id: str) -> bool: """发起语音通话(线程安全)""" if self._loop and self._running: future = asyncio.run_coroutine_threadsafe( self.client.start_voice_call(peer_id), self._loop ) try: return future.result(timeout=30.0) except Exception as e: logger.error(f"Start voice call error: {e}") return False return False def end_voice_call(self): """结束语音通话(线程安全)""" if self._loop and self._running: self.client.end_voice_call() def refresh_users(self): """刷新用户列表(线程安全)""" if self._loop and self._running: asyncio.run_coroutine_threadsafe( self.client.refresh_online_users(), self._loop ) class P2PChatGUI(QObject): """P2P 聊天 GUI 应用程序""" def __init__(self, config: ClientConfig): super().__init__() self.config = config self.app: Optional[QApplication] = None self.main_window: Optional[MainWindow] = None self.client: Optional[P2PClientApp] = None self.worker: Optional[AsyncWorker] = None self._current_user: Optional[UserInfo] = None self._current_chat_peer: Optional[str] = None def run(self) -> int: """运行 GUI 应用程序""" # 创建 Qt 应用 self.app = QApplication(sys.argv) self.app.setApplicationName("P2P Chat") self.app.setApplicationVersion("0.1.0") # 设置默认字体 font = QFont("Microsoft YaHei", 10) self.app.setFont(font) # 设置样式 self.app.setStyle("Fusion") # 应用退出时清理资源 self.app.aboutToQuit.connect(self.cleanup) # 显示登录对话框 if not self._show_login(): return 0 # 创建主窗口 self._create_main_window() # 连接到服务器 self._connect_to_server() # 运行应用 return self.app.exec() def _show_login(self) -> bool: """显示登录对话框""" dialog = LoginDialog() # 设置服务器地址 dialog._login_server_input.setText( f"{self.config.server_host}:{self.config.server_port}" ) dialog._reg_server_input.setText( f"{self.config.server_host}:{self.config.server_port}" ) if dialog.exec(): self._current_user = dialog.get_user_info() host, port = dialog.get_server_address() self.config.server_host = host self.config.server_port = port return True return False def _create_main_window(self): """创建主窗口""" self.main_window = MainWindow(self.config) if self._current_user: self.main_window.set_current_user(self._current_user) # 创建并设置联系人列表组件 self._contact_list_widget = ContactListWidget() self.main_window.set_contact_list_widget(self._contact_list_widget) # 创建并设置聊天组件 self._chat_widget = ChatWidget() self.main_window.set_chat_widget(self._chat_widget) # 连接聊天组件信号 self._chat_widget.message_sent.connect(self._on_send_message) self._chat_widget.file_send_requested.connect(lambda peer_id: self._on_send_file()) self._chat_widget.image_send_requested.connect(lambda peer_id: self._on_send_image()) self._chat_widget.voice_call_requested.connect(lambda peer_id: self._on_voice_call()) # 连接联系人列表信号 self._contact_list_widget.contact_selected.connect(self._on_contact_selected) self._contact_list_widget.contact_double_clicked.connect(self._on_contact_double_clicked) self._contact_list_widget.refresh_requested.connect(self._refresh_users) # 设置系统托盘 try: tray = SystemTrayManager(self.main_window) self.main_window.set_system_tray(tray) except Exception as e: logger.warning(f"Failed to create system tray: {e}") # 连接菜单动作 self._connect_menu_actions() self.main_window.show() def _connect_menu_actions(self): """连接菜单动作""" # 发送文件 self.main_window._send_file_action.triggered.connect(self._on_send_file) # 发送图片 self.main_window._send_image_action.triggered.connect(self._on_send_image) # 语音通话 self.main_window._voice_call_action.triggered.connect(self._on_voice_call) def _connect_to_server(self): """连接到服务器""" if not self._current_user: return # 更新状态 self.main_window.connection_state_changed.emit("连接中...") # 创建客户端 self.client = P2PClientApp(self.config) # 创建工作线程 self.worker = AsyncWorker(self.client, self._current_user) self.worker.connected.connect(self._on_connected) self.worker.disconnected.connect(self._on_disconnected) self.worker.message_received.connect(self._on_message_received) self.worker.user_list_updated.connect(self._on_user_list_updated) self.worker.state_changed.connect(self._on_state_changed) # 启动工作线程 self.worker.start() def _on_connected(self, success: bool, message: str): """连接结果回调""" if success: self.main_window.connection_state_changed.emit("已连接") self.main_window._statusbar.showMessage(f"已连接到服务器") logger.info("Connected to server") # 刷新用户列表 QTimer.singleShot(500, self._refresh_users) else: self.main_window.connection_state_changed.emit("连接失败") QMessageBox.critical( self.main_window, "连接失败", f"无法连接到服务器: {message}" ) def _on_disconnected(self, reason: str): """断开连接回调""" self.main_window.connection_state_changed.emit("已断开") self.main_window._statusbar.showMessage(f"连接断开: {reason}") # 询问是否重连 reply = QMessageBox.question( self.main_window, "连接断开", f"与服务器的连接已断开: {reason}\n\n是否尝试重新连接?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.Yes: self._connect_to_server() def _on_message_received(self, message: Message): """消息接收回调""" if message.msg_type == MessageType.TEXT: content = message.payload.decode('utf-8') sender = message.sender_id # 显示通知 if self.main_window.isMinimized() or not self.main_window.isActiveWindow(): self.main_window.show_notification( f"来自 {sender} 的消息", content[:50] + ("..." if len(content) > 50 else "") ) # 更新状态栏 self.main_window._statusbar.showMessage( f"收到来自 {sender} 的消息", 3000 ) # 将消息添加到聊天窗口显示 from datetime import datetime from shared.models import ChatMessage msg = ChatMessage( message_id=message.message_id, sender_id=sender, receiver_id=message.receiver_id, content_type=MessageType.TEXT, content=content, timestamp=datetime.fromtimestamp(message.timestamp), is_sent=True, is_read=False ) # 如果当前正在和发送者聊天,直接显示消息 if hasattr(self, '_chat_widget') and self._chat_widget: if self._current_chat_peer == sender: self._chat_widget.add_message(msg, is_self=False) logger.info(f"Message from {sender}: {content[:50]}...") elif message.msg_type == MessageType.VOICE_CALL_REQUEST: sender = message.sender_id self.main_window.show_notification( "来电", f"{sender} 正在呼叫你" ) # 显示来电对话框 self._show_incoming_call_dialog(sender) def _on_user_list_updated(self, users: List[UserInfo]): """用户列表更新回调""" logger.info(f"Online users: {len(users)}") self.main_window._statusbar.showMessage( f"在线用户: {len(users)}", 3000 ) # 更新联系人列表(排除自己) if hasattr(self, '_contact_list_widget') and self._contact_list_widget: # 过滤掉当前用户自己 other_users = [ user for user in users if self._current_user is None or user.user_id != self._current_user.user_id ] self._contact_list_widget.set_contacts(other_users) logger.info(f"Contact list updated: {len(other_users)} contacts") def _on_state_changed(self, state: str, reason: str): """状态变化回调""" state_text = { "disconnected": "已断开", "connecting": "连接中...", "connected": "已连接", "reconnecting": "重连中..." }.get(state, state) self.main_window.connection_state_changed.emit(state_text) def _refresh_users(self): """刷新用户列表""" if self.worker: self.worker.refresh_users() def _on_send_file(self): """发送文件""" from PyQt6.QtWidgets import QFileDialog, QMessageBox if not self._current_chat_peer: QMessageBox.warning(self.main_window, "提示", "请先选择一个联系人") return file_path, _ = QFileDialog.getOpenFileName( self.main_window, "选择文件", "", "所有文件 (*.*)" ) if file_path and self._current_chat_peer: self.main_window._statusbar.showMessage(f"正在发送文件...") if self.worker: success = self.worker.send_file(self._current_chat_peer, file_path) if success: self.main_window._statusbar.showMessage("文件发送成功", 3000) else: self.main_window._statusbar.showMessage("文件发送失败", 3000) def _on_send_image(self): """发送图片""" from PyQt6.QtWidgets import QFileDialog, QMessageBox if not self._current_chat_peer: QMessageBox.warning(self.main_window, "提示", "请先选择一个联系人") return file_path, _ = QFileDialog.getOpenFileName( self.main_window, "选择图片", "", "图片文件 (*.png *.jpg *.jpeg *.gif *.bmp)" ) if file_path and self._current_chat_peer: self.main_window._statusbar.showMessage(f"正在发送图片...") if self.worker: success = self.worker.send_image(self._current_chat_peer, file_path) if success: self.main_window._statusbar.showMessage("图片发送成功", 3000) else: self.main_window._statusbar.showMessage("图片发送失败", 3000) def _on_voice_call(self): """发起语音通话""" from PyQt6.QtWidgets import QMessageBox if not self._current_chat_peer: QMessageBox.warning(self.main_window, "提示", "请先选择一个联系人") return self.main_window._statusbar.showMessage(f"正在呼叫...") if self.worker: success = self.worker.start_voice_call(self._current_chat_peer) if success: self.main_window._statusbar.showMessage("通话已建立", 3000) else: self.main_window._statusbar.showMessage("呼叫失败", 3000) def _show_incoming_call_dialog(self, caller_id: str): """显示来电对话框""" from PyQt6.QtWidgets import QMessageBox reply = QMessageBox.question( self.main_window, "来电", f"{caller_id} 正在呼叫你,是否接听?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.Yes: # 接听 if self.worker and self.worker._loop and self.worker._running: future = asyncio.run_coroutine_threadsafe( self.client.accept_voice_call(caller_id), self.worker._loop ) try: success = future.result(timeout=10.0) if success: self.main_window._statusbar.showMessage("通话已接通", 3000) else: self.main_window._statusbar.showMessage("接听失败", 3000) except Exception as e: logger.error(f"Accept call error: {e}") else: # 拒绝 if self.client: self.client.reject_voice_call(caller_id) self.main_window._statusbar.showMessage("已拒绝来电", 3000) def _on_contact_selected(self, user_id: str): """联系人选中回调 - 打开聊天窗口""" self._current_chat_peer = user_id logger.debug(f"Contact selected: {user_id}") # 获取联系人信息并打开聊天窗口 if hasattr(self, '_contact_list_widget') and self._contact_list_widget: peer_info = self._contact_list_widget.get_contact(user_id) if hasattr(self, '_chat_widget') and self._chat_widget: self._chat_widget.set_peer(user_id, peer_info) self.main_window.set_current_chat_peer(user_id) def _on_contact_double_clicked(self, user_id: str): """联系人双击回调 - 打开聊天""" self._current_chat_peer = user_id logger.info(f"Opening chat with: {user_id}") # 获取联系人信息并打开聊天窗口 if hasattr(self, '_contact_list_widget') and self._contact_list_widget: peer_info = self._contact_list_widget.get_contact(user_id) if hasattr(self, '_chat_widget') and self._chat_widget: self._chat_widget.set_peer(user_id, peer_info) self.main_window.set_current_chat_peer(user_id) self.main_window._statusbar.showMessage(f"与 {user_id} 聊天", 3000) def _on_send_message(self, peer_id: str, content: str): """发送消息回调""" if self.worker: success = self.worker.send_message(peer_id, content) if success: # 添加消息到聊天窗口显示 from datetime import datetime from shared.models import ChatMessage msg = ChatMessage( message_id=f"msg_{datetime.now().timestamp()}", sender_id=self._current_user.user_id if self._current_user else "", receiver_id=peer_id, content_type=MessageType.TEXT, content=content, timestamp=datetime.now(), is_sent=True ) if hasattr(self, '_chat_widget') and self._chat_widget: self._chat_widget.add_message(msg, is_self=True) logger.info(f"Message sent to {peer_id}") else: self.main_window._statusbar.showMessage("消息发送失败", 3000) def cleanup(self): """清理资源""" logger.info("Cleaning up resources...") if self.worker: self.worker.stop() self.worker.wait(3000) self.worker = None if self.client: # 在新的事件循环中停止客户端 loop = asyncio.new_event_loop() try: loop.run_until_complete(self.client.stop()) except Exception as e: logger.error(f"Error stopping client: {e}") finally: loop.close() self.client = None logger.info("Cleanup completed") def parse_args() -> argparse.Namespace: """解析命令行参数""" parser = argparse.ArgumentParser( description='P2P Chat Client (GUI)', formatter_class=argparse.RawDescriptionHelpFormatter ) parser.add_argument( '--server', '-s', type=str, default=None, help='服务器地址 (默认: 127.0.0.1)' ) parser.add_argument( '--port', '-p', type=int, default=None, help='服务器端口 (默认: 8888)' ) parser.add_argument( '--debug', action='store_true', help='启用调试模式' ) return parser.parse_args() def main() -> int: """主函数""" args = parse_args() # 设置日志级别 if args.debug: logging.getLogger().setLevel(logging.DEBUG) # 加载配置 config = load_client_config() # 命令行参数覆盖配置 if args.server: config.server_host = args.server if args.port: config.server_port = args.port # 创建并运行 GUI gui = P2PChatGUI(config) try: exit_code = gui.run() return exit_code except KeyboardInterrupt: logger.info("Application interrupted") gui.cleanup() return 0 except Exception as e: logger.error(f"Application error: {e}") gui.cleanup() return 1 if __name__ == "__main__": sys.exit(main())