| 功能模块 | C++方案 | Python方案 | 影响程度 |
|---|---|---|---|
| 桌面窗口管理 | ✅ QMainWindow | ✅ PyQt6/PySide6 | ✅ 无影响 |
| 登录界面 | ✅ Qt Widgets/Web | ✅ PyQt6 Widgets/WebEngine | ✅ 无影响 |
| 主界面布局 | ✅ Qt Layout | ✅ PyQt6 Layout | ✅ 无影响 |
| Chrome浏览器自动化 | ✅ QWebEngineView | ✅ Selenium/Playwright | ✅ 无影响(甚至更简单) |
| Chrome插件支持 | ✅ QWebEngineProfile | ⚠️ 需要额外处理 | ⚠️ 轻微影响 |
| Steam集成 | ✅ 进程调用/API | ✅ subprocess/requests | ✅ 无影响 |
| 自动更新 | ✅ QNetworkAccessManager | ✅ requests + 自定义 | ✅ 无影响 |
| 窗口大小控制 | ✅ QMainWindow API | ✅ PyQt6 API | ✅ 无影响 |
| 多Tab页系统 | ✅ QTabWidget | ✅ QTabWidget | ✅ 无影响 |
结论:功能上基本无影响,Python完全可以实现所有需求!
| 技术组件 | 选型 | 说明 |
|---|---|---|
| 桌面框架 | PyQt6 / PySide6 | Qt的Python绑定,功能与C++版完全一致 |
| 编程语言 | Python 3.10+ | 开发效率高,代码简洁 |
| 浏览器自动化 | Selenium / Playwright | 比QWebEngine更强大的自动化能力 |
| Web界面 | QWebEngineView | 支持HTML/React界面 |
| 加密库 | cryptography / pycryptodome | Python加密库,功能完整 |
| 打包工具 | PyInstaller / cx_Freeze | 打包为exe |
| 代码保护 | PyArmor / Nuitka | 代码混淆和编译保护 |
buff-python-app/
├── requirements.txt
├── setup.py
├── main.py # 应用入口
├── src/
│ ├── core/ # 核心逻辑(Python)
│ │ ├── auth/ # 认证模块
│ │ │ ├── license.py # 许可证验证
│ │ │ ├── crypto.py # 加密算法
│ │ │ └── hwid.py # 硬件ID
│ │ ├── steam/ # Steam集成
│ │ │ ├── authenticator.py # Steam验证器
│ │ │ └── trade.py # 交易确认
│ │ ├── browser/ # 浏览器控制
│ │ │ ├── automation.py # 自动化引擎(Selenium/Playwright)
│ │ │ └── extension.py # 插件管理
│ │ └── updater/ # 更新模块
│ │ └── updater.py
│ ├── ui/ # 界面层
│ │ ├── main_window.py # 主窗口
│ │ ├── login_window.py # 登录窗口
│ │ ├── components/ # UI组件
│ │ │ ├── header.py # 顶部栏
│ │ │ ├── sidebar.py # 左侧菜单
│ │ │ └── tab_view.py # Tab页系统
│ │ └── webview.py # Web视图容器
│ └── utils/ # 工具函数
│ ├── config.py
│ └── logger.py
├── resources/
│ ├── chromium/ # Chrome浏览器
│ ├── extensions/ # Chrome插件
│ └── sda/ # Steam工具
└── dist/ # 构建输出
# main_window.py
from PyQt6.QtWidgets import QMainWindow, QWidget, QVBoxLayout, QHBoxLayout
from PyQt6.QtCore import Qt
from ui.components.header import Header
from ui.components.sidebar import Sidebar
from ui.components.tab_view import TabView
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Buff App")
self.setMinimumSize(1000, 600)
self.resize(1200, 800)
# 创建中央部件
central_widget = QWidget()
self.setCentralWidget(central_widget)
# 主布局
main_layout = QVBoxLayout(central_widget)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
# 顶部栏
self.header = Header()
main_layout.addWidget(self.header)
# 内容区域(左侧菜单 + 右侧Tab)
content_layout = QHBoxLayout()
content_layout.setContentsMargins(0, 0, 0, 0)
content_layout.setSpacing(0)
# 左侧菜单
self.sidebar = Sidebar()
content_layout.addWidget(self.sidebar, 0) # 固定宽度
# 右侧Tab页
self.tab_view = TabView()
content_layout.addWidget(self.tab_view, 1) # 可伸缩
main_layout.addLayout(content_layout)
# 连接信号
self.header.exit_clicked.connect(self.close)
self.sidebar.menu_clicked.connect(self.tab_view.add_tab)
def resize_window(self, width: int, height: int):
"""通过按钮控制窗口大小"""
self.resize(width, height)
def maximize_window(self):
"""最大化/还原窗口"""
if self.isMaximized():
self.showNormal()
else:
self.showMaximized()
# browser/automation.py
from playwright.sync_api import sync_playwright, Browser, Page, BrowserContext
from pathlib import Path
import os
import sys
from typing import Optional, List, Dict
from src.utils.logger import get_logger
logger = get_logger(__name__)
class BrowserAutomation:
def __init__(self):
self.browser: Optional[Browser] = None
self.context: Optional[BrowserContext] = None
self.page: Optional[Page] = None
self.playwright = None
def launch_browser(self, headless: bool = False, extensions: List[str] = None):
"""启动浏览器"""
try:
self.playwright = sync_playwright().start()
# Chrome路径(打包后的资源路径)
chrome_path = self._get_chrome_path()
if not os.path.exists(chrome_path):
raise FileNotFoundError(f"Chrome浏览器未找到: {chrome_path}")
# 准备启动参数
launch_args = [
'--disable-web-security',
'--disable-features=IsolateOrigins,site-per-process',
'--disable-blink-features=AutomationControlled',
'--no-first-run',
'--no-default-browser-check'
]
# 加载插件
if extensions:
for ext_path in extensions:
if os.path.exists(ext_path):
launch_args.append(f'--load-extension={ext_path}')
self.browser = self.playwright.chromium.launch(
executable_path=chrome_path,
headless=headless,
args=launch_args
)
# 创建上下文
context_options = {
'viewport': {'width': 1920, 'height': 1080},
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
self.context = self.browser.new_context(**context_options)
self.page = self.context.new_page()
logger.info("浏览器启动成功")
return self.page
except Exception as e:
logger.error(f"浏览器启动失败: {e}")
raise
def _get_chrome_path(self) -> str:
"""获取Chrome浏览器路径"""
if getattr(sys, 'frozen', False):
# 打包后的路径
base_path = sys._MEIPASS
else:
# 开发环境
base_path = Path(__file__).parent.parent.parent
# 尝试多个可能的路径
possible_paths = [
Path(base_path) / "resources" / "chromium" / "chrome.exe",
Path(base_path) / "resources" / "chromium" / "chrome-win" / "chrome.exe",
Path(os.path.dirname(sys.executable)) / "resources" / "chromium" / "chrome.exe"
]
for path in possible_paths:
if path.exists():
return str(path)
# 如果都找不到,返回默认路径
return str(possible_paths[0])
def load_extensions(self, extension_paths: List[str]):
"""加载Chrome插件"""
if not self.browser:
raise RuntimeError("浏览器未启动")
for ext_path in extension_paths:
if not os.path.exists(ext_path):
logger.warning(f"插件路径不存在: {ext_path}")
continue
# 重新创建上下文以加载插件
context_options = {
'viewport': {'width': 1920, 'height': 1080}
}
# Playwright通过启动参数加载插件,需要重启浏览器
logger.info(f"加载插件: {ext_path}")
def navigate(self, url: str, wait_until: str = "load"):
"""导航到URL"""
if not self.page:
raise RuntimeError("页面未创建")
try:
self.page.goto(url, wait_until=wait_until)
logger.info(f"导航到: {url}")
except Exception as e:
logger.error(f"导航失败: {e}")
raise
def fill_form(self, form_data: Dict[str, str]):
"""填充表单"""
if not self.page:
raise RuntimeError("页面未创建")
for selector, value in form_data.items():
try:
self.page.fill(selector, value)
logger.debug(f"填充表单字段: {selector}")
except Exception as e:
logger.error(f"填充表单失败 {selector}: {e}")
raise
def click_element(self, selector: str, timeout: int = 30000):
"""点击元素"""
if not self.page:
raise RuntimeError("页面未创建")
try:
self.page.click(selector, timeout=timeout)
logger.debug(f"点击元素: {selector}")
except Exception as e:
logger.error(f"点击元素失败 {selector}: {e}")
raise
def wait_for_element(self, selector: str, timeout: int = 30000):
"""等待元素出现"""
if not self.page:
raise RuntimeError("页面未创建")
try:
self.page.wait_for_selector(selector, timeout=timeout)
logger.debug(f"元素已出现: {selector}")
except Exception as e:
logger.error(f"等待元素超时 {selector}: {e}")
raise
def execute_script(self, script: str):
"""执行JavaScript"""
if not self.page:
raise RuntimeError("页面未创建")
try:
result = self.page.evaluate(script)
logger.debug("执行JavaScript脚本")
return result
except Exception as e:
logger.error(f"执行脚本失败: {e}")
raise
def screenshot(self, path: str, full_page: bool = False):
"""截图"""
if not self.page:
raise RuntimeError("页面未创建")
try:
self.page.screenshot(path=path, full_page=full_page)
logger.info(f"截图保存: {path}")
except Exception as e:
logger.error(f"截图失败: {e}")
raise
def get_page_content(self) -> str:
"""获取页面内容"""
if not self.page:
raise RuntimeError("页面未创建")
return self.page.content()
def close(self):
"""关闭浏览器"""
try:
if self.page:
self.page.close()
if self.context:
self.context.close()
if self.browser:
self.browser.close()
if self.playwright:
self.playwright.stop()
logger.info("浏览器已关闭")
except Exception as e:
logger.error(f"关闭浏览器失败: {e}")
# steam/authenticator.py
import subprocess
import json
import hmac
import hashlib
import time
import base64
import os
import sys
from pathlib import Path
from typing import Optional
from src.utils.logger import get_logger
logger = get_logger(__name__)
class SteamAuthenticator:
def __init__(self):
self.sda_path = self._get_sda_path()
self.accounts = {}
self.mafiles_path = self._get_mafiles_path()
def _get_sda_path(self) -> str:
"""获取SDA路径"""
if getattr(sys, 'frozen', False):
base_path = sys._MEIPASS
else:
base_path = Path(__file__).parent.parent.parent
possible_paths = [
Path(base_path) / "resources" / "sda" / "SteamDesktopAuthenticator.exe",
Path(os.path.dirname(sys.executable)) / "resources" / "sda" / "SteamDesktopAuthenticator.exe"
]
for path in possible_paths:
if path.exists():
return str(path)
return str(possible_paths[0])
def _get_mafiles_path(self) -> Path:
"""获取maFiles配置目录"""
if sys.platform == 'win32':
appdata = os.getenv('APPDATA', '')
return Path(appdata) / "SteamDesktopAuthenticator" / "maFiles"
else:
return Path.home() / ".steam" / "maFiles"
def get_guard_code(self, username: str) -> Optional[str]:
"""获取Steam Guard验证码"""
# 方法1:调用SDA命令行
if os.path.exists(self.sda_path):
try:
result = subprocess.run(
[self.sda_path, '--user', username, '--get-code'],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
code = result.stdout.strip()
logger.info(f"从SDA获取验证码成功: {username}")
return code
except subprocess.TimeoutExpired:
logger.error(f"SDA调用超时: {username}")
except Exception as e:
logger.error(f"SDA调用失败: {e}")
# 方法2:读取配置文件自行生成
code = self._generate_code_from_config(username)
if code:
logger.info(f"从配置文件生成验证码成功: {username}")
else:
logger.error(f"无法获取验证码: {username}")
return code
def _generate_code_from_config(self, username: str) -> Optional[str]:
"""从SDA配置文件生成验证码"""
config_path = self.mafiles_path / f"{username}.maFile"
if not config_path.exists():
logger.warning(f"配置文件不存在: {config_path}")
return None
try:
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
shared_secret = config.get('shared_secret', '')
if not shared_secret:
logger.error(f"配置文件中缺少shared_secret: {username}")
return None
# Steam TOTP算法
return self._generate_steam_totp(shared_secret)
except json.JSONDecodeError as e:
logger.error(f"配置文件解析失败: {e}")
return None
except Exception as e:
logger.error(f"读取配置文件失败: {e}")
return None
def _generate_steam_totp(self, shared_secret: str) -> str:
"""生成Steam TOTP验证码"""
steam_chars = "23456789BCDFGHJKMNPQRTVWXY"
current_time = int(time.time())
time_code = current_time // 30
# 转换为大端字节序
time_buffer = time_code.to_bytes(8, byteorder='big')
# HMAC-SHA1
try:
secret_bytes = base64.b64decode(shared_secret)
except Exception as e:
logger.error(f"Base64解码失败: {e}")
return ""
hmac_digest = hmac.new(secret_bytes, time_buffer, hashlib.sha1).digest()
# 生成5位代码
start = hmac_digest[19] & 0x0F
full_code = int.from_bytes(hmac_digest[start:start+4], byteorder='big') & 0x7FFFFFFF
code = ""
for _ in range(5):
code += steam_chars[full_code % len(steam_chars)]
full_code //= len(steam_chars)
return code
def confirm_trade(self, username: str, trade_id: str) -> bool:
"""确认Steam交易"""
if not os.path.exists(self.sda_path):
logger.error("SDA路径不存在")
return False
try:
result = subprocess.run(
[self.sda_path, '--confirm-trade', trade_id, '--user', username],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
logger.info(f"交易确认成功: {trade_id}")
return True
else:
logger.error(f"交易确认失败: {result.stderr}")
return False
except subprocess.TimeoutExpired:
logger.error("交易确认超时")
return False
except Exception as e:
logger.error(f"交易确认异常: {e}")
return False
def list_accounts(self) -> list:
"""列出所有已配置的Steam账号"""
accounts = []
if not self.mafiles_path.exists():
return accounts
for file in self.mafiles_path.glob("*.maFile"):
try:
with open(file, 'r', encoding='utf-8') as f:
config = json.load(f)
account_name = config.get('account_name', file.stem)
accounts.append(account_name)
except Exception as e:
logger.warning(f"读取账号配置失败 {file}: {e}")
return accounts
# core/auth/license.py
import hashlib
import hmac
import requests
import platform
import subprocess
import base64
import uuid
import json
import time
from pathlib import Path
from typing import Optional
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.backends import default_backend
from src.utils.logger import get_logger
from src.utils.config import get_config
logger = get_logger(__name__)
class LicenseValidator:
def __init__(self):
config = get_config()
self.api_url = config.get('license', {}).get('api_url', 'https://your-api.com/validate')
self.public_key = self._load_public_key()
self.cache_file = Path.home() / ".buff_app" / "license_cache.json"
self.cache_file.parent.mkdir(parents=True, exist_ok=True)
def get_hardware_id(self) -> str:
"""获取硬件ID"""
components = []
# CPU ID
try:
result = subprocess.run(
['wmic', 'cpu', 'get', 'ProcessorId'],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
lines = result.stdout.strip().split('\n')
if len(lines) > 1:
cpu_id = lines[1].strip()
if cpu_id:
components.append(cpu_id)
except Exception as e:
logger.warning(f"获取CPU ID失败: {e}")
components.append(platform.processor() or "unknown")
# 主板序列号
try:
result = subprocess.run(
['wmic', 'baseboard', 'get', 'SerialNumber'],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
lines = result.stdout.strip().split('\n')
if len(lines) > 1:
board_id = lines[1].strip()
if board_id:
components.append(board_id)
except Exception as e:
logger.warning(f"获取主板序列号失败: {e}")
components.append(platform.machine() or "unknown")
# MAC地址
try:
mac = uuid.getnode()
mac_str = ':'.join(['{:02x}'.format((mac >> elements) & 0xff)
for elements in range(0, 2*6, 2)][::-1])
components.append(mac_str)
except Exception as e:
logger.warning(f"获取MAC地址失败: {e}")
components.append("unknown")
# 生成硬件ID
combined = '|'.join(components)
hwid = hashlib.sha256(combined.encode()).hexdigest()
logger.debug(f"硬件ID生成成功: {hwid[:16]}...")
return hwid
def validate_online(self, license_key: str) -> bool:
"""在线验证许可证"""
hwid = self.get_hardware_id()
try:
response = requests.post(
self.api_url,
json={
'license': license_key,
'hwid': hwid,
'version': '1.0.0'
},
timeout=10
)
if response.status_code == 200:
data = response.json()
# 验证服务器签名
if data.get('valid') and self._verify_signature(data):
# 缓存验证结果
self._cache_validation(license_key, True)
logger.info("许可证验证成功")
return True
else:
logger.warning("许可证验证失败:签名无效")
else:
logger.error(f"许可证验证失败:HTTP {response.status_code}")
except requests.exceptions.Timeout:
logger.error("许可证验证超时")
except requests.exceptions.RequestException as e:
logger.error(f"许可证验证请求失败: {e}")
# 网络失败时使用缓存
return self._check_cache(license_key)
except Exception as e:
logger.error(f"许可证验证异常: {e}")
return False
def _verify_signature(self, data: dict) -> bool:
"""验证RSA签名"""
if not self.public_key:
logger.warning("公钥未加载,跳过签名验证")
return True # 如果公钥未配置,允许通过(开发环境)
try:
message = json.dumps(data.get('data', {}), sort_keys=True).encode()
signature_str = data.get('signature', '')
if not signature_str:
return False
signature = base64.b64decode(signature_str)
self.public_key.verify(
signature,
message,
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH
),
hashes.SHA256()
)
return True
except Exception as e:
logger.error(f"签名验证失败: {e}")
return False
def _load_public_key(self):
"""加载RSA公钥"""
try:
# 从配置文件或资源文件加载
public_key_pem = """
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----
"""
# 实际应从配置文件或资源文件读取
public_key = serialization.load_pem_public_key(
public_key_pem.encode(),
backend=default_backend()
)
logger.info("RSA公钥加载成功")
return public_key
except Exception as e:
logger.warning(f"公钥加载失败: {e}")
return None
def _cache_validation(self, license_key: str, valid: bool):
"""缓存验证结果"""
try:
cache_data = {
'license': license_key,
'valid': valid,
'timestamp': int(time.time())
}
with open(self.cache_file, 'w') as f:
json.dump(cache_data, f)
except Exception as e:
logger.warning(f"缓存验证结果失败: {e}")
def _check_cache(self, license_key: str) -> bool:
"""检查缓存的验证结果"""
try:
if not self.cache_file.exists():
return False
with open(self.cache_file, 'r') as f:
cache_data = json.load(f)
# 检查缓存是否过期(24小时)
if int(time.time()) - cache_data.get('timestamp', 0) > 86400:
return False
# 检查许可证是否匹配
if cache_data.get('license') != license_key:
return False
return cache_data.get('valid', False)
except Exception as e:
logger.warning(f"读取缓存失败: {e}")
return False
# utils/logger.py
import logging
import sys
from pathlib import Path
from logging.handlers import RotatingFileHandler
_loggers = {}
def get_logger(name: str = __name__) -> logging.Logger:
"""获取日志记录器"""
if name in _loggers:
return _loggers[name]
logger = logging.getLogger(name)
logger.setLevel(logging.DEBUG)
# 避免重复添加handler
if logger.handlers:
_loggers[name] = logger
return logger
# 控制台输出
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(logging.INFO)
console_format = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
console_handler.setFormatter(console_format)
logger.addHandler(console_handler)
# 文件输出
log_dir = Path.home() / ".buff_app" / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / "app.log"
file_handler = RotatingFileHandler(
log_file,
maxBytes=10*1024*1024, # 10MB
backupCount=5
)
file_handler.setLevel(logging.DEBUG)
file_format = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
file_handler.setFormatter(file_format)
logger.addHandler(file_handler)
_loggers[name] = logger
return logger
# utils/config.py
import json
import os
import sys
from pathlib import Path
from typing import Dict, Any
_config = None
def get_config() -> Dict[str, Any]:
"""获取配置"""
global _config
if _config is not None:
return _config
# 默认配置
default_config = {
'app': {
'name': 'Buff App',
'version': '1.0.0'
},
'license': {
'api_url': 'https://your-api.com/validate',
'cache_duration': 86400 # 24小时
},
'browser': {
'headless': False,
'timeout': 30000
},
'steam': {
'sda_path': 'resources/sda/SteamDesktopAuthenticator.exe'
},
'updater': {
'check_interval': 3600, # 1小时
'update_url': 'https://your-server.com/updates'
}
}
# 从配置文件加载
config_file = Path.home() / ".buff_app" / "config.json"
if config_file.exists():
try:
with open(config_file, 'r', encoding='utf-8') as f:
user_config = json.load(f)
# 合并配置
_config = _merge_config(default_config, user_config)
except Exception as e:
print(f"加载配置文件失败: {e}")
_config = default_config
else:
_config = default_config
# 保存默认配置
config_file.parent.mkdir(parents=True, exist_ok=True)
try:
with open(config_file, 'w', encoding='utf-8') as f:
json.dump(_config, f, indent=2, ensure_ascii=False)
except Exception:
pass
return _config
def _merge_config(default: Dict, user: Dict) -> Dict:
"""合并配置"""
result = default.copy()
for key, value in user.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = _merge_config(result[key], value)
else:
result[key] = value
return result
def save_config(config: Dict[str, Any]):
"""保存配置"""
global _config
_config = config
config_file = Path.home() / ".buff_app" / "config.json"
config_file.parent.mkdir(parents=True, exist_ok=True)
with open(config_file, 'w', encoding='utf-8') as f:
json.dump(config, f, indent=2, ensure_ascii=False)
# core/updater/updater.py
import requests
import json
import sys
import os
import subprocess
from pathlib import Path
from typing import Optional, Dict
from src.utils.logger import get_logger
from src.utils.config import get_config
logger = get_logger(__name__)
class AppUpdater:
def __init__(self):
config = get_config()
self.update_url = config.get('updater', {}).get('update_url', '')
self.current_version = config.get('app', {}).get('version', '1.0.0')
self.check_interval = config.get('updater', {}).get('check_interval', 3600)
def check_update(self) -> Optional[Dict]:
"""检查更新"""
if not self.update_url:
logger.warning("更新URL未配置")
return None
try:
response = requests.get(
f"{self.update_url}/version.json",
timeout=10
)
if response.status_code == 200:
update_info = response.json()
latest_version = update_info.get('version', '')
if self._compare_version(latest_version, self.current_version) > 0:
logger.info(f"发现新版本: {latest_version}")
return update_info
else:
logger.debug("当前已是最新版本")
return None
else:
logger.error(f"检查更新失败: HTTP {response.status_code}")
except requests.exceptions.RequestException as e:
logger.error(f"检查更新请求失败: {e}")
except Exception as e:
logger.error(f"检查更新异常: {e}")
return None
def download_update(self, update_info: Dict) -> bool:
"""下载更新"""
download_url = update_info.get('download_url', '')
if not download_url:
logger.error("下载URL不存在")
return False
try:
# 创建临时目录
temp_dir = Path.home() / ".buff_app" / "temp"
temp_dir.mkdir(parents=True, exist_ok=True)
# 下载文件
logger.info("开始下载更新...")
response = requests.get(download_url, stream=True, timeout=60)
response.raise_for_status()
update_file = temp_dir / "update.exe"
total_size = int(response.headers.get('content-length', 0))
downloaded = 0
with open(update_file, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
downloaded += len(chunk)
# 可以发送进度信号
if total_size > 0:
progress = (downloaded / total_size) * 100
logger.debug(f"下载进度: {progress:.1f}%")
logger.info("更新文件下载完成")
return True
except Exception as e:
logger.error(f"下载更新失败: {e}")
return False
def install_update(self, update_file: Path) -> bool:
"""安装更新"""
try:
# 执行更新安装程序
subprocess.Popen([str(update_file)], shell=True)
logger.info("更新安装程序已启动")
return True
except Exception as e:
logger.error(f"安装更新失败: {e}")
return False
def _compare_version(self, v1: str, v2: str) -> int:
"""比较版本号,返回: 1 if v1 > v2, -1 if v1 < v2, 0 if v1 == v2"""
def version_tuple(v):
return tuple(map(int, v.split('.')))
try:
t1 = version_tuple(v1)
t2 = version_tuple(v2)
if t1 > t2:
return 1
elif t1 < t2:
return -1
else:
return 0
except Exception:
return 0
# main.py
import sys
import os
from pathlib import Path
from PyQt6.QtWidgets import QApplication
from PyQt6.QtCore import Qt
from src.ui.login_window import LoginWindow
from src.ui.main_window import MainWindow
from src.core.auth.license import LicenseValidator
from src.core.updater.updater import AppUpdater
from src.utils.logger import get_logger
from src.utils.config import get_config
logger = get_logger(__name__)
def main():
"""主程序入口"""
# 设置高DPI支持
QApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
)
app = QApplication(sys.argv)
app.setApplicationName("Buff App")
app.setOrganizationName("Your Company")
# 检查许可证
license_validator = LicenseValidator()
license_key = os.getenv('BUFF_LICENSE_KEY', '')
if not license_key:
# 从配置文件读取
config = get_config()
license_key = config.get('license', {}).get('key', '')
if license_key:
if not license_validator.validate_online(license_key):
logger.error("许可证验证失败")
# 显示错误对话框
from PyQt6.QtWidgets import QMessageBox
msg = QMessageBox()
msg.setIcon(QMessageBox.Icon.Critical)
msg.setWindowTitle("许可证错误")
msg.setText("许可证验证失败,程序无法启动。")
msg.exec()
sys.exit(1)
else:
logger.warning("未找到许可证密钥,跳过验证(开发模式)")
# 检查更新(后台进行)
updater = AppUpdater()
update_info = updater.check_update()
if update_info:
logger.info(f"发现新版本: {update_info.get('version')}")
# 可以显示更新提示对话框
# 显示登录窗口
login_window = LoginWindow()
if login_window.exec() == LoginWindow.DialogCode.Accepted:
# 登录成功,显示主窗口
main_window = MainWindow()
main_window.show()
sys.exit(app.exec())
else:
# 登录取消或失败
sys.exit(0)
if __name__ == '__main__':
main()
# ui/login_window.py
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QLineEdit, QPushButton, QMessageBox
)
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QFont
from src.utils.logger import get_logger
logger = get_logger(__name__)
class LoginWindow(QDialog):
"""登录窗口"""
login_success = pyqtSignal(str, str) # username, password
def __init__(self):
super().__init__()
self.setWindowTitle("登录 - Buff App")
self.setFixedSize(400, 300)
self.setWindowFlags(Qt.WindowType.FramelessWindowHint)
self._init_ui()
def _init_ui(self):
layout = QVBoxLayout()
layout.setSpacing(15)
layout.setContentsMargins(30, 30, 30, 30)
# 标题
title = QLabel("Buff App")
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
title_font = QFont()
title_font.setPointSize(20)
title_font.setBold(True)
title.setFont(title_font)
layout.addWidget(title)
# 用户名
username_label = QLabel("用户名:")
layout.addWidget(username_label)
self.username_input = QLineEdit()
self.username_input.setPlaceholderText("请输入用户名")
layout.addWidget(self.username_input)
# 密码
password_label = QLabel("密码:")
layout.addWidget(password_label)
self.password_input = QLineEdit()
self.password_input.setPlaceholderText("请输入密码")
self.password_input.setEchoMode(QLineEdit.EchoMode.Password)
layout.addWidget(self.password_input)
# 按钮
button_layout = QHBoxLayout()
login_btn = QPushButton("登录")
login_btn.clicked.connect(self._handle_login)
cancel_btn = QPushButton("取消")
cancel_btn.clicked.connect(self.reject)
button_layout.addWidget(login_btn)
button_layout.addWidget(cancel_btn)
layout.addLayout(button_layout)
self.setLayout(layout)
def _handle_login(self):
"""处理登录"""
username = self.username_input.text().strip()
password = self.password_input.text().strip()
if not username or not password:
QMessageBox.warning(self, "提示", "请输入用户名和密码")
return
# 这里应该调用实际的登录验证逻辑
# 示例:简单的验证
if self._validate_login(username, password):
logger.info(f"用户登录成功: {username}")
self.login_success.emit(username, password)
self.accept()
else:
QMessageBox.warning(self, "登录失败", "用户名或密码错误")
def _validate_login(self, username: str, password: str) -> bool:
"""验证登录(示例实现)"""
# 实际应该调用API验证
# 这里只是示例
return len(username) > 0 and len(password) > 0
# requirements.txt
PyQt6==6.6.0
PyQt6-Qt6==6.6.0
PyQt6-sip==13.6.0
playwright==1.40.0
cryptography==41.0.7
requests==2.31.0
pyinstaller==6.3.0
# setup.py
from setuptools import setup, find_packages
setup(
name="buff-app",
version="1.0.0",
description="Buff App - Python Desktop Application",
author="Your Name",
packages=find_packages(),
install_requires=[
"PyQt6>=6.6.0",
"playwright>=1.40.0",
"cryptography>=41.0.0",
"requests>=2.31.0",
],
python_requires=">=3.10",
entry_points={
"console_scripts": [
"buff-app=main:main",
],
},
)
| 方面 | Python优势 |
|---|---|
| 开发效率 | ⭐⭐⭐⭐⭐ 代码简洁,开发速度快 |
| 浏览器自动化 | ⭐⭐⭐⭐⭐ Selenium/Playwright生态成熟,功能强大 |
| 第三方库 | ⭐⭐⭐⭐⭐ PyPI库丰富,Steam API、加密等都有现成库 |
| 调试便利 | ⭐⭐⭐⭐⭐ Python调试工具完善,开发体验好 |
| 学习曲线 | ⭐⭐⭐⭐⭐ Python语法简单,团队上手快 |
| 方面 | Python劣势 | 影响程度 |
|---|---|---|
| 安全性 | ⭐⭐ Python容易被反编译 | ⚠️ 中等影响(需用PyArmor/Nuitka保护) |
| 性能 | ⭐⭐⭐ 比C++慢,但桌面应用足够 | ✅ 影响较小 |
| 打包体积 | ⭐⭐⭐ 包含Python运行时,体积较大 | ⚠️ 轻微影响(约50-100MB) |
| 启动速度 | ⭐⭐⭐ 比C++慢,但可接受 | ✅ 影响较小 |
| Chrome插件加载 | ⭐⭐⭐ 需要额外处理 | ⚠️ 轻微影响(Playwright支持) |
# 安装
pip install pyarmor
# 混淆保护
pyarmor gen --enable-rft main.py
# 打包
pyinstaller --onefile --key=your-key main.py
优点:
缺点:
# 安装
pip install nuitka
# 编译为exe(会先编译为C++再编译为二进制)
python -m nuitka --onefile --windows-icon=icon.ico main.py
优点:
缺点:
# 1. 核心敏感代码用Nuitka编译为扩展模块
# core/auth/license_core.py -> license_core.pyd
# 2. 其他代码用PyArmor保护
pyarmor gen --enable-rft src/
# 3. 打包
pyinstaller --onefile main.py
# ui/components/tab_view.py
from PyQt6.QtWidgets import QWidget, QTabWidget, QVBoxLayout, QPushButton
from PyQt6.QtCore import pyqtSignal
from src.utils.logger import get_logger
logger = get_logger(__name__)
class TabView(QWidget):
"""Tab页视图组件"""
tab_closed = pyqtSignal(int)
def __init__(self):
super().__init__()
self.tabs = {}
self._init_ui()
def _init_ui(self):
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
self.tab_widget = QTabWidget()
self.tab_widget.setTabsClosable(True)
self.tab_widget.tabCloseRequested.connect(self._close_tab)
layout.addWidget(self.tab_widget)
self.setLayout(layout)
def add_tab(self, title: str, content: QWidget, tab_id: str = None):
"""添加Tab页"""
if tab_id is None:
tab_id = title
# 如果Tab已存在,切换到该Tab
if tab_id in self.tabs:
index = self.tab_widget.indexOf(self.tabs[tab_id])
self.tab_widget.setCurrentIndex(index)
return
# 创建新Tab
index = self.tab_widget.addTab(content, title)
self.tabs[tab_id] = content
self.tab_widget.setCurrentIndex(index)
logger.debug(f"添加Tab页: {title}")
def _close_tab(self, index: int):
"""关闭Tab页"""
widget = self.tab_widget.widget(index)
if widget:
tab_id = None
for tid, w in self.tabs.items():
if w == widget:
tab_id = tid
break
self.tab_widget.removeTab(index)
if tab_id:
del self.tabs[tab_id]
self.tab_closed.emit(index)
logger.debug(f"关闭Tab页: index={index}")
def get_current_tab(self) -> QWidget:
"""获取当前Tab页"""
return self.tab_widget.currentWidget()
# ui/components/sidebar.py
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QPushButton, QListWidget
from PyQt6.QtCore import pyqtSignal, Qt
from PyQt6.QtGui import QIcon
from src.utils.logger import get_logger
logger = get_logger(__name__)
class Sidebar(QWidget):
"""侧边栏组件"""
menu_clicked = pyqtSignal(str) # 发送菜单项ID
def __init__(self):
super().__init__()
self.setFixedWidth(200)
self.menu_items = {}
self._init_ui()
def _init_ui(self):
layout = QVBoxLayout()
layout.setContentsMargins(5, 5, 5, 5)
layout.setSpacing(5)
self.menu_list = QListWidget()
self.menu_list.itemClicked.connect(self._on_menu_clicked)
layout.addWidget(self.menu_list)
self.setLayout(layout)
def add_menu_item(self, item_id: str, text: str, icon: QIcon = None):
"""添加菜单项"""
from PyQt6.QtWidgets import QListWidgetItem
item = QListWidgetItem(text)
if icon:
item.setIcon(icon)
item.setData(Qt.ItemDataRole.UserRole, item_id)
self.menu_list.addItem(item)
self.menu_items[item_id] = item
logger.debug(f"添加菜单项: {item_id} - {text}")
def _on_menu_clicked(self, item: QListWidgetItem):
"""菜单项点击处理"""
item_id = item.data(Qt.ItemDataRole.UserRole)
if item_id:
self.menu_clicked.emit(item_id)
logger.debug(f"菜单项被点击: {item_id}")
# ui/components/header.py
from PyQt6.QtWidgets import QWidget, QHBoxLayout, QLabel, QPushButton
from PyQt6.QtCore import pyqtSignal, Qt
from PyQt6.QtGui import QFont
from src.utils.logger import get_logger
logger = get_logger(__name__)
class Header(QWidget):
"""顶部栏组件"""
exit_clicked = pyqtSignal()
minimize_clicked = pyqtSignal()
maximize_clicked = pyqtSignal()
def __init__(self):
super().__init__()
self.setFixedHeight(60)
self._init_ui()
def _init_ui(self):
layout = QHBoxLayout()
layout.setContentsMargins(10, 5, 10, 5)
# Logo/标题
self.logo = QLabel("Buff App")
logo_font = QFont()
logo_font.setPointSize(16)
logo_font.setBold(True)
self.logo.setFont(logo_font)
layout.addWidget(self.logo)
layout.addStretch()
# 窗口控制按钮
minimize_btn = QPushButton("—")
minimize_btn.setFixedSize(30, 30)
minimize_btn.clicked.connect(self.minimize_clicked.emit)
layout.addWidget(minimize_btn)
maximize_btn = QPushButton("□")
maximize_btn.setFixedSize(30, 30)
maximize_btn.clicked.connect(self.maximize_clicked.emit)
layout.addWidget(maximize_btn)
exit_btn = QPushButton("×")
exit_btn.setFixedSize(30, 30)
exit_btn.clicked.connect(self.exit_clicked.emit)
layout.addWidget(exit_btn)
self.setLayout(layout)
# BuffApp.spec
# -*- mode: python ; coding: utf-8 -*-
block_cipher = None
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[
# PyQt6资源文件(自动收集)
# ('resources', 'resources'), # 如果需要包含资源文件
],
hiddenimports=[
'PyQt6',
'PyQt6.QtCore',
'PyQt6.QtWidgets',
'PyQt6.QtGui',
'playwright',
'playwright.sync_api',
'cryptography',
'requests',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='BuffApp',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False, # 无控制台窗口
icon='resources/icon.ico',
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
# 如果需要安装包,使用NSIS
# coll = COLLECT(...)
# 1. 安装依赖
pip install -r requirements.txt
pip install pyinstaller
# 2. 安装Playwright浏览器
playwright install chromium
# 3. 复制Chrome到resources目录
# 从 %USERPROFILE%\AppData\Local\ms-playwright\ 复制到 resources/chromium/
# 4. 生成spec文件(如果还没有)
pyinstaller --name=BuffApp --windowed --icon=resources/icon.ico main.py
# 5. 编辑BuffApp.spec,添加资源文件配置
# 6. 打包
pyinstaller BuffApp.spec
# 7. 使用NSIS创建安装包(可选)
makensis installer.nsi
; installer.nsi
!include "MUI2.nsh"
Name "Buff App"
OutFile "BuffApp-Setup-1.0.0.exe"
InstallDir "$PROGRAMFILES\BuffApp"
; 设置压缩方式
SetCompressor /SOLID lzma
Section "MainSection" SEC01
SetOutPath "$INSTDIR"
; 主程序
File "dist\BuffApp.exe"
; Chrome浏览器
SetOutPath "$INSTDIR\resources\chromium"
File /r "resources\chromium\*"
; Chrome插件
SetOutPath "$INSTDIR\resources\extensions"
File /r "resources\extensions\*"
; Steam工具
SetOutPath "$INSTDIR\resources\sda"
File /r "resources\sda\*"
; 创建快捷方式
CreateShortcut "$DESKTOP\Buff App.lnk" "$INSTDIR\BuffApp.exe"
CreateShortcut "$SMPROGRAMS\Buff App.lnk" "$INSTDIR\BuffApp.exe"
; 写入注册表
WriteRegStr HKLM "Software\BuffApp" "InstallDir" "$INSTDIR"
WriteUninstaller "$INSTDIR\Uninstall.exe"
SectionEnd
| 操作 | C++方案 | Python方案 | 差异 |
|---|---|---|---|
| 应用启动 | ~0.5秒 | ~1-2秒 | 慢1-1.5秒 |
| 窗口响应 | 即时 | 即时 | 无差异 |
| 浏览器启动 | ~1秒 | ~1-2秒 | 慢0.5-1秒 |
| 自动化操作 | 快速 | 快速 | 无差异 |
| 内存占用 | ~100MB | ~150-200MB | 多50-100MB |
结论:性能差异在桌面应用中可接受,用户体验无明显影响。
PyQt6 / PySide6 # 桌面框架
Playwright # 浏览器自动化(比Selenium更现代)
cryptography # 加密库
requests # HTTP请求
PyInstaller # 打包工具
Nuitka # 核心模块编译保护
PyArmor # 代码混淆
结论:Python完全可以替代C++,功能无影响,开发效率更高!