You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
886 lines
34 KiB
886 lines
34 KiB
#!/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 accept_voice_call(self, peer_id: str) -> bool:
|
|
"""接听语音通话(线程安全)"""
|
|
if self._loop and self._running:
|
|
future = asyncio.run_coroutine_threadsafe(
|
|
self.client.accept_voice_call(peer_id),
|
|
self._loop
|
|
)
|
|
try:
|
|
return future.result(timeout=10.0)
|
|
except Exception as e:
|
|
logger.error(f"Accept voice call error: {e}")
|
|
return False
|
|
return False
|
|
|
|
def reject_voice_call(self, peer_id: str):
|
|
"""拒绝语音通话(线程安全)"""
|
|
if self._loop and self._running:
|
|
self.client.reject_voice_call(peer_id)
|
|
|
|
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 应用程序"""
|
|
|
|
# 信号定义 - 用于跨线程通信
|
|
file_received_signal = pyqtSignal(str, str, str) # sender_id, file_name, file_path
|
|
|
|
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
|
|
|
|
# 连接信号到槽
|
|
self.file_received_signal.connect(self._handle_file_received_ui)
|
|
|
|
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._chat_widget.voice_call_end_requested.connect(self._on_end_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)
|
|
self._contact_list_widget.lan_discovery_requested.connect(self._on_lan_discovery)
|
|
|
|
# 设置系统托盘
|
|
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.client.add_file_received_callback(self._on_file_received)
|
|
|
|
# 创建工作线程
|
|
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
|
|
logger.info(f"Voice call request from {sender}")
|
|
self.main_window.show_notification(
|
|
"来电",
|
|
f"{sender} 正在呼叫你"
|
|
)
|
|
# 显示来电对话框
|
|
self._show_incoming_call_dialog(sender)
|
|
|
|
elif message.msg_type == MessageType.VOICE_CALL_ACCEPT:
|
|
sender = message.sender_id
|
|
logger.info(f"Voice call accepted by {sender}")
|
|
self.main_window._statusbar.showMessage(f"通话已接通 - {sender}", 5000)
|
|
self.main_window.show_notification("通话已接通", f"与 {sender} 的通话已建立")
|
|
# 更新UI状态为通话中
|
|
if hasattr(self, '_chat_widget') and self._chat_widget:
|
|
self._chat_widget.set_call_state(True, "通话中...")
|
|
|
|
elif message.msg_type == MessageType.VOICE_CALL_REJECT:
|
|
sender = message.sender_id
|
|
reason = message.payload.decode('utf-8') if message.payload else "对方拒绝"
|
|
logger.info(f"Voice call rejected by {sender}: {reason}")
|
|
self.main_window._statusbar.showMessage(f"呼叫被拒绝: {reason}", 5000)
|
|
self.main_window.show_notification("呼叫被拒绝", f"{sender} 拒绝了你的通话请求")
|
|
# 恢复UI状态
|
|
if hasattr(self, '_chat_widget') and self._chat_widget:
|
|
self._chat_widget.set_call_state(False)
|
|
|
|
elif message.msg_type == MessageType.VOICE_CALL_END:
|
|
sender = message.sender_id
|
|
logger.info(f"Voice call ended by {sender}")
|
|
self.main_window._statusbar.showMessage("通话已结束", 3000)
|
|
self.main_window.show_notification("通话结束", f"与 {sender} 的通话已结束")
|
|
# 恢复UI状态
|
|
if hasattr(self, '_chat_widget') and self._chat_widget:
|
|
self._chat_widget.set_call_state(False)
|
|
|
|
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")
|
|
|
|
# 更新连接模式显示
|
|
self._update_connection_modes()
|
|
|
|
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_lan_discovery(self):
|
|
"""发现局域网用户"""
|
|
self.main_window._statusbar.showMessage("正在发现局域网用户...", 5000)
|
|
|
|
if self.worker and self.worker._loop and self.worker._running:
|
|
future = asyncio.run_coroutine_threadsafe(
|
|
self._discover_lan_peers_async(),
|
|
self.worker._loop
|
|
)
|
|
try:
|
|
peers = future.result(timeout=5.0)
|
|
if peers:
|
|
self.main_window._statusbar.showMessage(f"发现 {len(peers)} 个局域网用户", 3000)
|
|
self.main_window.show_notification("局域网发现", f"发现 {len(peers)} 个局域网用户")
|
|
# 更新联系人列表中的连接模式
|
|
self._update_connection_modes()
|
|
else:
|
|
self.main_window._statusbar.showMessage("未发现局域网用户", 3000)
|
|
except Exception as e:
|
|
logger.error(f"LAN discovery error: {e}")
|
|
self.main_window._statusbar.showMessage("局域网发现失败", 3000)
|
|
|
|
async def _discover_lan_peers_async(self):
|
|
"""异步发现局域网用户"""
|
|
if self.client:
|
|
return await self.client.discover_lan_peers()
|
|
return []
|
|
|
|
def _update_connection_modes(self):
|
|
"""更新联系人列表中的连接模式显示"""
|
|
if not self.client or not hasattr(self, '_contact_list_widget'):
|
|
return
|
|
|
|
# 获取所有连接模式
|
|
connection_manager = self.client.connection_manager
|
|
|
|
# 更新每个联系人的连接模式
|
|
for user_id in list(self._contact_list_widget._contacts.keys()):
|
|
mode = connection_manager.get_connection_mode(user_id)
|
|
mode_str = "p2p" if mode.value == "p2p" else "relay"
|
|
self._contact_list_widget.update_connection_mode(user_id, mode_str)
|
|
|
|
def _on_send_file(self):
|
|
"""发送文件"""
|
|
from PyQt6.QtWidgets import QFileDialog, QMessageBox
|
|
import os
|
|
|
|
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)
|
|
# 在聊天窗口显示发送的文件
|
|
self._add_sent_file_message(file_path, is_image=False)
|
|
else:
|
|
self.main_window._statusbar.showMessage("文件发送失败", 3000)
|
|
|
|
def _on_send_image(self):
|
|
"""发送图片"""
|
|
from PyQt6.QtWidgets import QFileDialog, QMessageBox
|
|
import os
|
|
|
|
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)
|
|
# 在聊天窗口显示发送的图片
|
|
self._add_sent_file_message(file_path, is_image=True)
|
|
else:
|
|
self.main_window._statusbar.showMessage("图片发送失败", 3000)
|
|
|
|
def _add_sent_file_message(self, file_path: str, is_image: bool):
|
|
"""在聊天窗口添加发送的文件/图片消息"""
|
|
from datetime import datetime
|
|
from shared.models import ChatMessage
|
|
|
|
content_type = MessageType.IMAGE if is_image else MessageType.FILE_REQUEST
|
|
|
|
msg = ChatMessage(
|
|
message_id=f"sent_file_{datetime.now().timestamp()}",
|
|
sender_id=self._current_user.user_id if self._current_user else "",
|
|
receiver_id=self._current_chat_peer,
|
|
content_type=content_type,
|
|
content=file_path,
|
|
timestamp=datetime.now(),
|
|
is_sent=True,
|
|
is_read=False
|
|
)
|
|
|
|
if hasattr(self, '_chat_widget') and self._chat_widget:
|
|
self._chat_widget.add_message(msg, is_self=True)
|
|
|
|
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"正在呼叫 {self._current_chat_peer}...")
|
|
|
|
# 更新UI状态为呼叫中
|
|
if hasattr(self, '_chat_widget') and self._chat_widget:
|
|
self._chat_widget.set_call_state(True, "正在呼叫...")
|
|
|
|
if self.worker:
|
|
success = self.worker.start_voice_call(self._current_chat_peer)
|
|
if success:
|
|
self.main_window._statusbar.showMessage("正在等待对方接听...", 30000)
|
|
else:
|
|
self.main_window._statusbar.showMessage("呼叫失败", 3000)
|
|
QMessageBox.warning(self.main_window, "呼叫失败", "无法发起语音通话,请检查网络连接")
|
|
# 恢复UI状态
|
|
if hasattr(self, '_chat_widget') and self._chat_widget:
|
|
self._chat_widget.set_call_state(False)
|
|
|
|
def _on_end_voice_call(self):
|
|
"""结束语音通话"""
|
|
logger.info("Ending voice call from GUI")
|
|
if self.worker:
|
|
self.worker.end_voice_call()
|
|
self.main_window._statusbar.showMessage("通话已结束", 3000)
|
|
|
|
# 更新UI状态
|
|
if hasattr(self, '_chat_widget') and self._chat_widget:
|
|
self._chat_widget.set_call_state(False)
|
|
|
|
def _show_incoming_call_dialog(self, caller_id: str):
|
|
"""显示来电对话框"""
|
|
from PyQt6.QtWidgets import QMessageBox
|
|
|
|
logger.info(f"Showing incoming call dialog for {caller_id}")
|
|
|
|
reply = QMessageBox.question(
|
|
self.main_window,
|
|
"来电",
|
|
f"{caller_id} 正在呼叫你,是否接听?",
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
QMessageBox.StandardButton.Yes # 默认选择接听
|
|
)
|
|
|
|
if reply == QMessageBox.StandardButton.Yes:
|
|
# 接听
|
|
logger.info(f"Accepting call from {caller_id}")
|
|
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)
|
|
logger.info(f"Call accepted successfully with {caller_id}")
|
|
# 更新UI状态为通话中
|
|
if hasattr(self, '_chat_widget') and self._chat_widget:
|
|
self._chat_widget.set_call_state(True, "通话中...")
|
|
else:
|
|
self.main_window._statusbar.showMessage("接听失败", 3000)
|
|
logger.error(f"Failed to accept call from {caller_id}")
|
|
except Exception as e:
|
|
logger.error(f"Accept call error: {e}")
|
|
self.main_window._statusbar.showMessage(f"接听失败: {e}", 3000)
|
|
else:
|
|
# 拒绝
|
|
logger.info(f"Rejecting call from {caller_id}")
|
|
if self.client:
|
|
self.client.reject_voice_call(caller_id)
|
|
self.main_window._statusbar.showMessage("已拒绝来电", 3000)
|
|
|
|
def _on_file_received(self, sender_id: str, file_name: str, file_path: str):
|
|
"""文件接收完成回调 - 使用信号在主线程中更新GUI"""
|
|
logger.info(f"GUI: File received callback triggered - {sender_id}, {file_name}, {file_path}")
|
|
# 使用信号在主线程中执行 GUI 更新
|
|
self.file_received_signal.emit(sender_id, file_name, file_path)
|
|
|
|
def _handle_file_received_ui(self, sender_id: str, file_name: str, file_path: str):
|
|
"""在主线程中处理文件接收的UI更新"""
|
|
import os
|
|
from datetime import datetime
|
|
from shared.models import ChatMessage
|
|
|
|
logger.info(f"GUI: _handle_file_received_ui called - sender: {sender_id}, current_peer: {self._current_chat_peer}")
|
|
|
|
# 判断是否是图片
|
|
image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'}
|
|
ext = os.path.splitext(file_name)[1].lower()
|
|
is_image = ext in image_extensions
|
|
|
|
# 显示通知
|
|
if is_image:
|
|
self.main_window.show_notification("收到图片", f"来自 {sender_id}: {file_name}")
|
|
else:
|
|
self.main_window.show_notification("收到文件", f"来自 {sender_id}: {file_name}")
|
|
|
|
self.main_window._statusbar.showMessage(f"收到文件: {file_name},已保存到 {file_path}", 5000)
|
|
|
|
# 创建消息对象
|
|
content_type = MessageType.IMAGE if is_image else MessageType.FILE_REQUEST
|
|
|
|
msg = ChatMessage(
|
|
message_id=f"file_{datetime.now().timestamp()}",
|
|
sender_id=sender_id,
|
|
receiver_id=self._current_user.user_id if self._current_user else "",
|
|
content_type=content_type,
|
|
content=file_path,
|
|
timestamp=datetime.now(),
|
|
is_sent=True,
|
|
is_read=False
|
|
)
|
|
|
|
# 如果当前没有打开聊天窗口或不是和发送者聊天,自动切换到发送者
|
|
if self._current_chat_peer != sender_id:
|
|
logger.info(f"GUI: Switching to chat with {sender_id}")
|
|
self._current_chat_peer = sender_id
|
|
if hasattr(self, '_chat_widget') and self._chat_widget:
|
|
# 获取发送者信息
|
|
peer_info = None
|
|
if hasattr(self, '_contact_list_widget') and self._contact_list_widget:
|
|
peer_info = self._contact_list_widget.get_contact(sender_id)
|
|
self._chat_widget.set_peer(sender_id, peer_info)
|
|
self.main_window.set_current_chat_peer(sender_id)
|
|
|
|
# 在聊天窗口显示
|
|
if hasattr(self, '_chat_widget') and self._chat_widget:
|
|
logger.info(f"GUI: Adding file message to chat widget")
|
|
self._chat_widget.add_message(msg, is_self=False)
|
|
|
|
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())
|