From a779797ad679fac56d7aca50302a19e0d6d88cc3 Mon Sep 17 00:00:00 2001 From: huanzhenpc Date: Fri, 21 Feb 2025 16:41:30 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=AF=E6=89=93=E5=8C=85=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E7=89=88=E6=9C=AC=EF=BC=8C=E8=AF=A5=E7=89=88=E6=9C=AC=E5=85=B7?= =?UTF-8?q?=E6=9C=89=E6=A8=A1=E5=9D=97=E5=8C=96=E7=89=B9=E6=80=A7=E3=80=82?= =?UTF-8?q?=E5=A6=82=E6=9E=9C=E9=87=8D=E7=BD=AE=E8=84=9A=E6=9C=AC=E6=8D=A2?= =?UTF-8?q?=E4=BA=86=EF=BC=8C=E7=9B=B4=E6=8E=A5=E6=94=B9=E7=9B=B8=E5=BA=94?= =?UTF-8?q?=E7=B1=BB=E5=B0=B1=E5=8F=AF=E4=BB=A5=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 22 ++ README.md | 122 +++++++ __init__.py | 5 + build.bat | 140 ++++++++ common_utils.py | 434 ++++++++++++++++++++++++ config.py | 54 +++ cursor_token_refresher.py | 145 ++++++++ exit_cursor.py | 68 ++++ file_version_info.txt | 43 +++ gui/__init__.py | 1 + gui/components/__init__.py | 1 + gui/components/widgets.py | 637 +++++++++++++++++++++++++++++++++++ gui/components/workers.py | 98 ++++++ gui/windows/__init__.py | 1 + gui/windows/main_window.py | 667 +++++++++++++++++++++++++++++++++++++ logger.py | 67 ++++ machine_resetter.py | 197 +++++++++++ requirements.txt | 6 + services/__init__.py | 1 + services/cursor_service.py | 237 +++++++++++++ testbuild.bat | 123 +++++++ tingquan_assistant.py | 91 +++++ two.ico | Bin 0 -> 29016 bytes update_disabler.py | 151 +++++++++ utils/version_manager.py | 227 +++++++++++++ version.txt | 1 + 听泉助手v4.0.5.spec | 44 +++ 27 files changed, 3583 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 build.bat create mode 100644 common_utils.py create mode 100644 config.py create mode 100644 cursor_token_refresher.py create mode 100644 exit_cursor.py create mode 100644 file_version_info.txt create mode 100644 gui/__init__.py create mode 100644 gui/components/__init__.py create mode 100644 gui/components/widgets.py create mode 100644 gui/components/workers.py create mode 100644 gui/windows/__init__.py create mode 100644 gui/windows/main_window.py create mode 100644 logger.py create mode 100644 machine_resetter.py create mode 100644 requirements.txt create mode 100644 services/__init__.py create mode 100644 services/cursor_service.py create mode 100644 testbuild.bat create mode 100644 tingquan_assistant.py create mode 100644 two.ico create mode 100644 update_disabler.py create mode 100644 utils/version_manager.py create mode 100644 version.txt create mode 100644 听泉助手v4.0.5.spec diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d9a272 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +dist/ +build/ +*.egg-info/ + +# IDE +.idea/ +.vscode/ + +# Virtual Environment +venv/ +env/ + +# Local development settings +.env + +testversion.txt \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5fdbc2f --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# 听泉助手 + +听泉助手是一个用于管理 Cursor 编辑器激活的工具软件。 + +## 功能特点 + +- 激活码管理 +- Cursor 编辑器授权刷新 +- 机器码重置 +- 更新禁用 +- 系统托盘支持 +- 自动更新检查 + +## 项目结构 + +``` +├── services/ # 服务层 - 处理核心业务逻辑 +│ ├── cursor_service.py # Cursor服务管理类 +│ └── __init__.py +│ +├── gui/ # GUI层 - 处理界面相关逻辑 +│ ├── components/ # 可复用的GUI组件 +│ │ ├── widgets.py # 基础UI组件 +│ │ ├── workers.py # 后台工作线程 +│ │ └── __init__.py +│ ├── windows/ # 窗口类 +│ │ ├── main_window.py # 主窗口实现 +│ │ └── __init__.py +│ └── __init__.py +│ +├── utils/ # 工具类 +│ ├── version_manager.py # 版本管理 +│ └── __init__.py +│ +├── config.py # 配置管理 +├── logger.py # 日志管理 +├── common_utils.py # 通用工具函数 +└── tingquan_assistant.py # 程序入口文件 +``` + +## 开发规范 + +1. 分层设计 + - services层: 处理核心业务逻辑,与界面解耦 + - gui层: 只处理界面相关逻辑,通过services层调用业务功能 + - 工具类: 独立的功能模块(如日志、配置等) + +2. 代码规范 + - 使用类型注解 + - 函数必须有文档字符串 + - 遵循PEP 8命名规范 + - 异常必须合理处理和记录日志 + +3. 界面设计 + - 使用PyQt5构建GUI + - 所有耗时操作必须在后台线程中执行 + - 界面组件需实现合理的状态管理 + +4. 配置管理 + - 用户配置存储在 %APPDATA%/TingquanAssistant/ + - 激活信息使用JSON格式存储 + - 配置文件需要权限控制 + +5. 日志规范 + - 所有关键操作必须记录日志 + - 日志按日期分文件存储 + - 包含足够的错误诊断信息 + +## 使用说明 + +1. 运行入口: `python tingquan_assistant.py` +2. 开发新功能流程: + - 在services层添加业务逻辑 + - 在gui/components添加必要的界面组件 + - 在gui/windows中整合界面 + - 更新配置和日志相关代码 + +## 环境要求 + +- Python 3.8+ +- PyQt5 +- Windows 10/11 + +## 安装依赖 + +```bash +pip install -r requirements.txt +``` + +## 开发环境设置 + +1. 克隆仓库 +```bash +git clone https://git.586vip.cn/oadmin/tingquanzhushou.git +cd tingquanzhushou +``` + +2. 创建虚拟环境 +```bash +python -m venv venv +source venv/bin/activate # Linux/Mac +venv\Scripts\activate # Windows +``` + +3. 安装依赖 +```bash +pip install -r requirements.txt +``` + +## 构建和打包 + +使用 `build.bat` 脚本进行构建和打包: + +```bash +build.bat +``` + +打包后的文件将在 `dist` 目录中生成。 + +## 许可证 + +版权所有 © 2024 听泉助手 \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..b77fe42 --- /dev/null +++ b/__init__.py @@ -0,0 +1,5 @@ +""" +听泉助手 - 脚本包 +""" + +__version__ = "4.0.0.1" \ No newline at end of file diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..e5795cc --- /dev/null +++ b/build.bat @@ -0,0 +1,140 @@ +@echo off +chcp 65001 +setlocal EnableDelayedExpansion +echo 开始正式版本打包... + +:: 设置工作目录为脚本所在目录 +cd /d "%~dp0" + +:: 激活虚拟环境 +if exist ".venv\Scripts\activate.bat" ( + echo 激活虚拟环境... + call .venv\Scripts\activate.bat +) else ( + echo 警告: 未找到虚拟环境,使用系统 Python +) + +:: 确保安装了必要的包 +echo Installing dependencies... +pip install -r requirements.txt + +:: 读取版本号并处理格式 +set /p VERSION=version.txt + +:: 清理旧的打包文件 +echo Cleaning old files... +taskkill /F /IM "听泉助手*.exe" >nul 2>&1 +timeout /t 2 /nobreak >nul +if exist "build" rd /s /q "build" +if exist "dist" rd /s /q "dist" + +:: 使用优化选项进行打包 +echo Starting packaging... +pyinstaller ^ + --noconfirm ^ + --clean ^ + --onefile ^ + --noconsole ^ + --icon=two.ico ^ + --name "听泉助手v!NEW_VERSION!" ^ + --hidden-import json ^ + --hidden-import sqlite3 ^ + --hidden-import winreg ^ + --hidden-import ctypes ^ + --hidden-import platform ^ + --hidden-import uuid ^ + --hidden-import hashlib ^ + --hidden-import datetime ^ + --hidden-import urllib3 ^ + --hidden-import requests ^ + --hidden-import PyQt5 ^ + --hidden-import PyQt5.sip ^ + --hidden-import psutil ^ + --hidden-import psutil._psutil_windows ^ + --hidden-import psutil._pswindows ^ + --collect-submodules psutil ^ + --add-data "version.txt;." ^ + --add-data "requirements.txt;." ^ + --add-data "two.ico;." ^ + --add-data "config.py;." ^ + --add-data "logger.py;." ^ + --add-data "common_utils.py;." ^ + --add-data "cursor_token_refresher.py;." ^ + --add-data "machine_resetter.py;." ^ + --add-data "update_disabler.py;." ^ + --add-data "exit_cursor.py;." ^ + --add-data "gui;gui" ^ + --add-data "services;services" ^ + --add-data "utils;utils" ^ + --exclude-module _tkinter ^ + --exclude-module tkinter ^ + --exclude-module PIL.ImageTk ^ + --exclude-module PIL.ImageWin ^ + --exclude-module numpy ^ + --exclude-module pandas ^ + --exclude-module matplotlib ^ + --exclude "__pycache__" ^ + --exclude "*.pyc" ^ + --exclude "*.pyo" ^ + --exclude "*.pyd" ^ + --version-file file_version_info.txt ^ + --uac-admin ^ + tingquan_assistant.py + +:: 检查打包结果 +set BUILD_FILE=dist\听泉助手v!NEW_VERSION!.exe + +if exist "!BUILD_FILE!" ( + echo Build completed! + echo Version: v!NEW_VERSION! + echo File location: !BUILD_FILE! + + :: 显示文件大小 + for %%I in ("!BUILD_FILE!") do ( + echo File size: %%~zI bytes + ) + + :: 更新 main_window.py 中的窗口标题 + set MAIN_WINDOW_FILE=gui\windows\main_window.py + if exist "!MAIN_WINDOW_FILE!" ( + echo Updating main_window.py... + powershell -Command "(Get-Content '!MAIN_WINDOW_FILE!') -replace '听泉助手 v\d+\.\d+\.\d+\.\d+', '听泉助手 v!NEW_VERSION!' | Set-Content '!MAIN_WINDOW_FILE!'" + ) else ( + echo Warning: main_window.py not found! + ) +) else ( + echo Error: Package failed, file does not exist + echo Expected file: !BUILD_FILE! + exit /b 1 +) + +:: 清理临时文件 +echo Cleaning temporary files... +if exist "build" rd /s /q "build" + +:: 退出虚拟环境 +if exist ".venv\Scripts\activate.bat" ( + echo Deactivating virtual environment... + deactivate +) + +echo. +echo Press any key to exit... +pause >nul \ No newline at end of file diff --git a/common_utils.py b/common_utils.py new file mode 100644 index 0000000..87ec8d2 --- /dev/null +++ b/common_utils.py @@ -0,0 +1,434 @@ +"""通用工具函数库""" + +import os +import sys +import hashlib +import subprocess +import platform +import json +import requests +import urllib3 +import uuid +import winreg +import sqlite3 +from typing import List, Tuple, Optional, Dict +from datetime import datetime + +from logger import logger +from config import config + +# 获取日志记录器 +_logger = logger.get_logger("CommonUtils") + +# 全局用户状态 +_global_user_state = { + "is_activated": False, + "expire_time": None, + "days_left": 0, + "total_days": 0, + "device_info": {}, + "status": "inactive" +} + +# 设备信息缓存 +_device_info_cache = None + +def _get_cached_device_info() -> Dict: + """ + 获取设备信息(使用缓存) + 只在首次调用时获取系统信息,后续使用缓存 + + Returns: + Dict: 设备基础信息 + """ + global _device_info_cache + + if _device_info_cache is None: + try: + # 基础系统信息 + device_info = { + "os": platform.system(), + "device_name": platform.node(), + "ip": "未知", + "location": "未知" + } + + # 获取IP和位置信息 + try: + # 使用ip-api.com的免费API获取IP和位置信息 + response = requests.get('http://ip-api.com/json/?lang=zh-CN', timeout=5) + if response.status_code == 200: + data = response.json() + if data.get('status') == 'success': + device_info.update({ + "ip": data.get('query', '未知'), + "location": f"{data.get('country', '')} {data.get('regionName', '')} {data.get('city', '')}" + }) + except Exception as e: + _logger.warning(f"获取IP和位置信息失败: {str(e)}") + + _device_info_cache = device_info + _logger.debug("已初始化设备信息缓存") + + except Exception as e: + _logger.error(f"获取设备信息失败: {str(e)}") + _device_info_cache = { + "os": "Windows", + "device_name": "未知", + "ip": "未知", + "location": "未知" + } + + return _device_info_cache.copy() + +def update_user_state(state_data: Dict) -> None: + """ + 更新全局用户状态 + Args: + state_data: 新的状态数据 + """ + global _global_user_state + + # 保留现有的设备信息 + current_device_info = _global_user_state.get("device_info", {}) + + # 更新状态数据 + _global_user_state.update(state_data) + + # 合并设备信息 + if "device_info" in state_data: + new_device_info = state_data["device_info"] + # 只更新可能变动的信息(IP和地区) + current_device_info.update({ + "ip": new_device_info.get("ip", current_device_info.get("ip", "未知")), + "location": new_device_info.get("location", current_device_info.get("location", "未知")) + }) + _global_user_state["device_info"] = current_device_info + + _logger.info(f"用户状态已更新: {state_data}") + +def get_user_state() -> Dict: + """ + 获取当前用户状态 + Returns: + Dict: 用户状态信息 + """ + return _global_user_state.copy() + +def check_user_state() -> bool: + """ + 检查用户是否处于可操作状态 + Returns: + bool: 是否可以操作 + """ + return _global_user_state.get("is_activated", False) + +def refresh_user_state(machine_id: str) -> Tuple[bool, str, Dict]: + """ + 检查设备激活状态 + + 通过设备ID向服务器请求验证设备的激活状态、剩余时间等信息。 + 服务器会进行以下检查: + 1. 设备ID是否合法 + 2. 是否已激活 + 3. 激活是否过期 + 4. 剩余使用时间 + + Args: + machine_id: 设备唯一标识 + + Returns: + Tuple[bool, str, Dict]: + - bool: 是否验证通过 + - str: 状态消息 + - Dict: 状态数据 + """ + try: + # 禁用SSL警告 + urllib3.disable_warnings() + + # 准备请求数据 + data = {"machine_id": machine_id} + + # 发送状态检查请求 + response = requests.post( + config.status_url, + json=data, + headers={"Content-Type": "application/json"}, + timeout=30, + verify=False + ) + + # 解析响应 + result = response.json() + + if result.get("code") in [1, 200]: + api_data = result.get("data", {}) + + # 获取缓存的设备信息 + device_info = _get_cached_device_info() + # 只更新可能变动的信息 + device_info.update({ + "ip": api_data.get("ip", device_info["ip"]), + "location": api_data.get("location", device_info["location"]) + }) + + # 更新状态数据 + state_data = { + "is_activated": api_data.get("status") == "active", + "status": api_data.get("status", "inactive"), + "expire_time": api_data.get("expire_time", ""), + "days_left": api_data.get("days_left", 0), + "total_days": api_data.get("total_days", 0), + "device_info": device_info + } + + # 更新全局状态 + update_user_state(state_data) + + # 根据状态返回对应消息 + if state_data["is_activated"]: + msg = f"设备已激活,剩余{state_data['days_left']}天" + else: + if api_data.get("status") == "expired": + msg = "设备授权已过期" + else: + msg = "设备未激活" + + return True, msg, state_data + + else: + error_msg = result.get("msg", "未知错误") + _logger.error(f"设备状态检查失败: {error_msg}") + return False, f"状态检查失败: {error_msg}", {} + + except Exception as e: + error_msg = str(e) + _logger.error(f"设备状态检查异常: {error_msg}") + return False, f"状态检查异常: {error_msg}", {} + +def get_hardware_id() -> str: + """获取硬件唯一标识 + 方案1: CPU ID + 主板序列号 + BIOS序列号 + 方案2: 系统盘序列号 + Windows安装时间 + 方案3: 计算机名(最后的备选方案) + + Returns: + str: 硬件ID的MD5哈希值 + + Raises: + RuntimeError: 当无法获取任何可用的硬件信息时 + """ + try: + # 创建startupinfo对象来隐藏命令行窗口 + startupinfo = None + if sys.platform == "win32": + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + startupinfo.wShowWindow = subprocess.SW_HIDE + + # 方案1: 尝试获取硬件信息 + try: + # 获取CPU ID + cpu_info = subprocess.check_output('wmic cpu get ProcessorId', startupinfo=startupinfo).decode() + cpu_id = cpu_info.split('\n')[1].strip() + + # 获取主板序列号 + board_info = subprocess.check_output('wmic baseboard get SerialNumber', startupinfo=startupinfo).decode() + board_id = board_info.split('\n')[1].strip() + + # 获取BIOS序列号 + bios_info = subprocess.check_output('wmic bios get SerialNumber', startupinfo=startupinfo).decode() + bios_id = bios_info.split('\n')[1].strip() + + # 如果所有信息都获取成功且有效 + if all([cpu_id, board_id, bios_id]) and not all(x in ['', '0', 'None', 'To be filled by O.E.M.'] for x in [cpu_id, board_id, bios_id]): + combined = f"{cpu_id}:{board_id}:{bios_id}" + hardware_id = hashlib.md5(combined.encode()).hexdigest() + _logger.info("使用硬件信息生成ID成功") + return hardware_id + + except Exception as e: + _logger.warning(f"方案1失败: {str(e)}") + + # 方案2: 系统盘序列号 + Windows安装时间 + try: + backup_info = [] + + # 获取系统盘序列号 + volume_info = subprocess.check_output('wmic logicaldisk where "DeviceID=\'C:\'" get VolumeSerialNumber', startupinfo=startupinfo).decode() + volume_serial = volume_info.split('\n')[1].strip() + if volume_serial and volume_serial not in ['', '0']: + backup_info.append(("volume", volume_serial)) + + # 获取Windows安装时间 + os_info = subprocess.check_output('wmic os get InstallDate', startupinfo=startupinfo).decode() + install_date = os_info.split('\n')[1].strip() + if install_date: + backup_info.append(("install", install_date)) + + if backup_info: + combined = "|".join(f"{k}:{v}" for k, v in sorted(backup_info)) + hardware_id = hashlib.md5(combined.encode()).hexdigest() + _logger.info("使用系统信息生成ID成功") + return hardware_id + + except Exception as e: + _logger.warning(f"方案2失败: {str(e)}") + + # 方案3: 使用计算机名(最后的备选方案) + computer_name = platform.node() + if computer_name: + hardware_id = hashlib.md5(computer_name.encode()).hexdigest() + _logger.info("使用计算机名生成ID成功") + return hardware_id + + raise ValueError("无法获取任何可用信息来生成硬件ID") + + except Exception as e: + error_msg = f"生成硬件ID失败: {str(e)}" + _logger.error(error_msg) + raise RuntimeError(error_msg) + +def verify_hardware_id(stored_id: str) -> bool: + """ + 验证当前硬件ID是否与存储的ID匹配 + + Args: + stored_id: 存储的硬件ID + + Returns: + bool: 如果匹配返回True,否则返回False + """ + try: + current_id = get_hardware_id() + return current_id == stored_id + except Exception as e: + _logger.error(f"验证硬件ID失败: {str(e)}") + return False + +def get_system_info() -> dict: + """ + 获取系统信息 + + Returns: + dict: 包含系统信息的字典 + """ + info = { + "os": platform.system(), + "os_version": platform.version(), + "machine": platform.machine(), + "processor": platform.processor(), + "node": platform.node() + } + + if sys.platform == "win32": + try: + # 获取更多Windows特定信息 + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + startupinfo.wShowWindow = subprocess.SW_HIDE + + # Windows产品ID + windows_info = subprocess.check_output('wmic os get SerialNumber', startupinfo=startupinfo).decode() + info["windows_id"] = windows_info.split('\n')[1].strip() + + except Exception as e: + _logger.warning(f"获取Windows特定信息失败: {str(e)}") + + return info + +def activate_device(machine_id: str, activation_code: str) -> Tuple[bool, str, Optional[Dict]]: + """ + 激活设备 + + Args: + machine_id: 设备ID + activation_code: 激活码 + + Returns: + Tuple[bool, str, Optional[Dict]]: + - bool: 是否成功 + - str: 消息 + - Optional[Dict]: 激活数据 + """ + try: + # 禁用SSL警告 + urllib3.disable_warnings() + + # 准备请求数据 + data = { + "machine_id": machine_id, + "code": activation_code + } + + # 发送激活请求 + response = requests.post( + config.activate_url, + json=data, + headers={ + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept": "*/*" + }, + timeout=30, + verify=False + ) + + # 解析响应 + result = response.json() + + if result.get("code") == 200: + api_data = result.get("data", {}) + + # 获取缓存的设备信息 + device_info = _get_cached_device_info() + + # 构造状态数据 + state_data = { + "is_activated": True, + "status": "active", + "expire_time": api_data.get("expire_time", ""), + "days_left": api_data.get("days_left", 0), + "total_days": api_data.get("total_days", 0), + "device_info": device_info + } + + # 更新全局状态 + update_user_state(state_data) + + return True, f"激活成功,剩余{state_data['days_left']}天", state_data + + elif result.get("code") == 400: + error_msg = result.get("msg", "激活码无效或已被使用") + _logger.warning(f"激活失败: {error_msg}") + return False, error_msg, None + + else: + error_msg = result.get("msg", "未知错误") + _logger.error(f"激活失败: {error_msg}") + return False, f"激活失败: {error_msg}", None + + except requests.exceptions.RequestException as e: + error_msg = f"网络连接失败: {str(e)}" + _logger.error(error_msg) + return False, error_msg, None + + except Exception as e: + error_msg = f"激活失败: {str(e)}" + _logger.error(error_msg) + return False, error_msg, None + +def get_current_version() -> str: + """ + 从 version.txt 文件中获取当前版本号。 + """ + try: + # 获取当前文件的目录 + current_dir = os.path.dirname(os.path.abspath(__file__)) + version_file_path = os.path.join(current_dir, 'version.txt') + with open(version_file_path, 'r', encoding='utf-8') as f: + return f.read().strip() + except Exception as e: + logger.error(f"读取版本号失败: {str(e)}") + return "0.0.0" \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..be1eb61 --- /dev/null +++ b/config.py @@ -0,0 +1,54 @@ +from typing import Dict + +class Config: + """配置管理类""" + + def __init__(self): + """初始化配置""" + # API基础URL + self.base_url = "https://cursorapi.nosqli.com" + + # API端点 + self.api_endpoints = { + "get_unused": f"{self.base_url}/admin/api.account/getUnused", + "activate": f"{self.base_url}/admin/api.member/activate", + "status": f"{self.base_url}/admin/api.member/status", + "heartbeat": f"{self.base_url}/admin/api.account/heartbeat" + } + + # API请求配置 + self.request_config = { + "timeout": 30, + "verify": False, + "headers": { + "Content-Type": "application/json" + } + } + + @property + def get_unused_url(self) -> str: + """获取未使用账号的API地址""" + return self.api_endpoints["get_unused"] + + @property + def activate_url(self) -> str: + """获取激活账号的API地址""" + return self.api_endpoints["activate"] + + @property + def status_url(self) -> str: + """获取状态检查的API地址""" + return self.api_endpoints["status"] + + @property + def heartbeat_url(self) -> str: + """获取心跳检测的API地址""" + return self.api_endpoints["heartbeat"] + + @property + def request_kwargs(self) -> Dict: + """获取请求配置""" + return self.request_config.copy() + +# 创建全局配置实例 +config = Config() \ No newline at end of file diff --git a/cursor_token_refresher.py b/cursor_token_refresher.py new file mode 100644 index 0000000..cbbe4e1 --- /dev/null +++ b/cursor_token_refresher.py @@ -0,0 +1,145 @@ +import os +import sys +import sqlite3 +import requests +import urllib3 +from typing import Dict, Tuple, Optional +from datetime import datetime +from config import config +from logger import logger + +class CursorTokenRefresher: + """Cursor Token 刷新器 - 简化版""" + + def __init__(self): + """初始化刷新器""" + # 设置数据库路径 + if sys.platform == "win32": + appdata = os.getenv("APPDATA") + if appdata is None: + raise EnvironmentError("APPDATA 环境变量未设置") + self.db_path = os.path.join(appdata, "Cursor", "User", "globalStorage", "state.vscdb") + else: + raise NotImplementedError(f"暂不支持的操作系统: {sys.platform}") + + # 获取日志记录器 + self.logger = logger.get_logger("TokenRefresh") + + def update_auth(self, email: Optional[str] = None, + access_token: Optional[str] = None, + refresh_token: Optional[str] = None) -> bool: + """ + 更新SQLite数据库中的认证信息 + :param email: 可选,新的邮箱地址 + :param access_token: 可选,新的访问令牌 + :param refresh_token: 可选,新的刷新令牌 + :return: 是否成功更新 + """ + updates = [] + # 登录状态 + updates.append(("cursorAuth/cachedSignUpType", "Auth_0")) + + if email is not None: + updates.append(("cursorAuth/cachedEmail", email)) + if access_token is not None: + updates.append(("cursorAuth/accessToken", access_token)) + if refresh_token is not None: + updates.append(("cursorAuth/refreshToken", refresh_token)) + + if not updates: + self.logger.warning("没有提供任何要更新的值") + return False + + conn = None + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + for key, value in updates: + # 如果没有更新任何行,说明key不存在,执行插入 + # 检查 key 是否存在 + check_query = "SELECT COUNT(*) FROM itemTable WHERE key = ?" + cursor.execute(check_query, (key,)) + if cursor.fetchone()[0] == 0: + insert_query = "INSERT INTO itemTable (key, value) VALUES (?, ?)" + cursor.execute(insert_query, (key, value)) + self.logger.info(f"已插入新记录: {key.split('/')[-1]}") + else: + update_query = "UPDATE itemTable SET value = ? WHERE key = ?" + cursor.execute(update_query, (value, key)) + if cursor.rowcount > 0: + self.logger.info(f"成功更新 {key.split('/')[-1]}") + else: + self.logger.warning(f"未找到 {key.split('/')[-1]} 或值未变化") + + conn.commit() + return True + + except sqlite3.Error as e: + self.logger.error(f"数据库错误: {str(e)}") + return False + except Exception as e: + self.logger.error(f"发生错误: {str(e)}") + return False + finally: + if conn: + conn.close() + + def refresh_token(self, machine_id: str) -> Tuple[bool, str, Optional[Dict]]: + """ + 刷新token(从API获取新账号并更新到数据库) + :param machine_id: 机器ID + :return: (是否成功, 消息, 可选的账号信息) + """ + try: + # 禁用SSL警告 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + # 发送请求 + response = requests.post( + config.get_unused_url, + json={"machine_id": machine_id}, + headers={"Content-Type": "application/json"}, + timeout=30, + verify=False + ) + + # 解析响应 + response_data = response.json() + + if response_data.get("code") == 200: + account_data = response_data.get("data", {}) + + # 获取账号信息 + email = account_data.get("email") + access_token = account_data.get("access_token") + refresh_token = account_data.get("refresh_token") + expire_time = account_data.get("expire_time") + days_left = account_data.get("days_left") + + if not all([email, access_token, refresh_token]): + return False, "获取账号信息不完整", None + + # 更新SQLite数据库 + if self.update_auth(email=email, + access_token=access_token, + refresh_token=refresh_token): + account_info = { + "email": email, + "expire_time": expire_time, + "days_left": days_left + } + return True, "认证信息更新成功", account_info + else: + return False, "更新认证信息失败", None + + elif response_data.get("code") == 404: + return False, "没有可用的未使用账号", None + else: + error_msg = response_data.get("msg", "未知错误") + return False, f"获取账号失败: {error_msg}", None + + except Exception as e: + error_msg = str(e) + self.logger.error(f"刷新token失败: {error_msg}") + return False, f"刷新token失败: {error_msg}", None \ No newline at end of file diff --git a/exit_cursor.py b/exit_cursor.py new file mode 100644 index 0000000..7a7620a --- /dev/null +++ b/exit_cursor.py @@ -0,0 +1,68 @@ +import psutil +import logging +import time + +def ExitCursor(timeout=5): + """ + 温和地关闭 Cursor 进程 + Args: + timeout (int): 等待进程自然终止的超时时间(秒) + Returns: + bool: 是否成功关闭所有进程 + """ + try: + logging.info("开始退出Cursor...") + cursor_processes = [] + + # 收集所有 Cursor 进程 + for proc in psutil.process_iter(['pid', 'name']): + try: + if proc.info['name'].lower() in ['cursor.exe', 'cursor']: + cursor_processes.append(proc) + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + + if not cursor_processes: + logging.info("未发现运行中的 Cursor 进程") + return True + + # 温和地请求进程终止 + for proc in cursor_processes: + try: + if proc.is_running(): + proc.terminate() # 发送终止信号 + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + + # 等待进程自然终止 + start_time = time.time() + while time.time() - start_time < timeout: + still_running = [] + for proc in cursor_processes: + try: + if proc.is_running(): + still_running.append(proc) + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + + if not still_running: + logging.info("所有 Cursor 进程已正常关闭") + return True + + # 等待一小段时间再检查 + time.sleep(0.5) + + # 如果超时后仍有进程在运行 + if still_running: + process_list = ", ".join([str(p.pid) for p in still_running]) + logging.warning(f"以下进程未能在规定时间内关闭: {process_list}") + return False + + return True + + except Exception as e: + logging.error(f"关闭 Cursor 进程时发生错误: {str(e)}") + return False + +if __name__ == "__main__": + ExitCursor() \ No newline at end of file diff --git a/file_version_info.txt b/file_version_info.txt new file mode 100644 index 0000000..bfae090 --- /dev/null +++ b/file_version_info.txt @@ -0,0 +1,43 @@ +# UTF-8 +# +# For more details about fixed file info 'ffi' see: +# http://msdn.microsoft.com/en-us/library/ms646997.aspx +VSVersionInfo( + ffi=FixedFileInfo( + # filevers and prodvers should be always a tuple with four items: (1, 2, 3, 4) + # Set not needed items to zero 0. + filevers=(4, 0, 0, 1), + prodvers=(4, 0, 0, 1), + # Contains a bitmask that specifies the valid bits 'flags'r + mask=0x3f, + # Contains a bitmask that specifies the Boolean attributes of the file. + flags=0x0, + # The operating system for which this file was designed. + # 0x4 - NT and there is no need to change it. + OS=0x40004, + # The general type of file. + # 0x1 - the file is an application. + fileType=0x1, + # The function of the file. + # 0x0 - the function is not defined for this fileType + subtype=0x0, + # Creation date and time stamp. + date=(0, 0) + ), + kids=[ + StringFileInfo( + [ + StringTable( + u'080404b0', + [StringStruct(u'CompanyName', u'听泉助手'), + StringStruct(u'FileDescription', u'听泉助手 - Cursor编辑器激活工具'), + StringStruct(u'FileVersion', u'4.0.0.1'), + StringStruct(u'InternalName', u'听泉助手'), + StringStruct(u'LegalCopyright', u'Copyright (C) 2024 听泉助手'), + StringStruct(u'OriginalFilename', u'听泉助手.exe'), + StringStruct(u'ProductName', u'听泉助手'), + StringStruct(u'ProductVersion', u'4.0.0.1')]) + ]), + VarFileInfo([VarStruct(u'Translation', [2052, 1200])]) + ] +) \ No newline at end of file diff --git a/gui/__init__.py b/gui/__init__.py new file mode 100644 index 0000000..672700e --- /dev/null +++ b/gui/__init__.py @@ -0,0 +1 @@ +"""GUI 包""" \ No newline at end of file diff --git a/gui/components/__init__.py b/gui/components/__init__.py new file mode 100644 index 0000000..64051bd --- /dev/null +++ b/gui/components/__init__.py @@ -0,0 +1 @@ +"""GUI 组件包""" \ No newline at end of file diff --git a/gui/components/widgets.py b/gui/components/widgets.py new file mode 100644 index 0000000..662d4bc --- /dev/null +++ b/gui/components/widgets.py @@ -0,0 +1,637 @@ +from typing import Dict +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, + QTextEdit, QLabel, QFrame, QLineEdit, QMessageBox, QApplication, QDialog, QStyle, QProgressBar +) +from PyQt5.QtCore import ( + Qt, QTimer, QPropertyAnimation, QEasingCurve, + QPoint, QRect, QSize, pyqtProperty +) +from PyQt5.QtGui import QPainter, QColor, QPen + +class ActivationStatusWidget(QFrame): + """激活状态显示组件""" + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout(self) + + # 状态标题 + title_label = QLabel("激活状态", self) + title_label.setStyleSheet("font-size: 16px; font-weight: bold;") + layout.addWidget(title_label) + + # 状态信息 + self.status_label = QLabel(self) + self.status_label.setStyleSheet("font-size: 14px;") + layout.addWidget(self.status_label) + + self.setStyleSheet(""" + QFrame { + background-color: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 4px; + padding: 10px; + } + """) + + def update_status(self, status: Dict): + """更新状态显示""" + if status["is_activated"]: + status_text = f"已激活 | 到期时间: {status['expire_time']} | 剩余天数: {status['days_left']}天" + self.status_label.setStyleSheet("color: #28a745;") + else: + status_text = "未激活" + self.status_label.setStyleSheet("color: #dc3545;") + self.status_label.setText(status_text) + +class ActivationWidget(QFrame): + """激活码输入组件 + + 职责: + 1. 提供激活码输入界面 + 2. 处理输入框和激活按钮的基础交互 + 3. 将激活请求传递给父窗口处理 + 4. 处理激活相关的提示信息 + + 属性: + code_input (QLineEdit): 激活码输入框 + activate_btn (QPushButton): 激活按钮 + + 信号流: + activate_btn.clicked -> activate() -> parent.handle_activation() + """ + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + # 连接回车键到激活功能 + self.code_input.returnPressed.connect(self.activate) + + def show_activation_required(self): + """显示需要激活的提示""" + msg = QDialog(self) + msg.setWindowTitle("提示") + msg.setFixedWidth(360) + msg.setWindowFlags(msg.windowFlags() & ~Qt.WindowContextHelpButtonHint) + + # 创建布局 + layout = QVBoxLayout() + layout.setSpacing(12) + layout.setContentsMargins(20, 15, 20, 15) + + # 创建顶部警告框 + header_frame = QFrame() + header_frame.setStyleSheet(""" + QFrame { + background-color: #fff3cd; + border: 1px solid #ffeeba; + border-radius: 4px; + } + QLabel { + background: transparent; + border: none; + } + """) + header_layout = QHBoxLayout(header_frame) + header_layout.setContentsMargins(12, 10, 12, 10) + header_layout.setSpacing(10) + + icon_label = QLabel() + icon_label.setPixmap(self.style().standardIcon(QStyle.SP_MessageBoxWarning).pixmap(20, 20)) + header_layout.addWidget(icon_label) + + text_label = QLabel("请输入激活码") + text_label.setStyleSheet("font-size: 14px; font-weight: bold; color: #856404; background: transparent; ") + header_layout.addWidget(text_label) + header_layout.addStretch() + + layout.addWidget(header_frame) + + # 添加提示文本 + info_text = QLabel( + "获取会员激活码,请通过以下方式:\n\n" + "官方自助网站:cursorpro.com.cn\n" + "微信客服号:behikcigar\n" + "商店铺:xxx\n\n" + "诚挚祝愿,欢迎加盟合作!" + ) + info_text.setStyleSheet(""" + QLabel { + font-size: 13px; + color: #333333; + margin: 5px 0; + } + """) + layout.addWidget(info_text) + + # 添加按钮区域 + btn_layout = QHBoxLayout() + btn_layout.setSpacing(8) + + # 复制网站按钮 + copy_web_btn = QPushButton("复制网址") + copy_web_btn.setCursor(Qt.PointingHandCursor) + copy_web_btn.clicked.connect(lambda: self._copy_to_clipboard("cursorpro.com.cn", "已复制官网地址")) + copy_web_btn.setStyleSheet(""" + QPushButton { + background-color: #0d6efd; + color: white; + border: none; + padding: 6px 16px; + border-radius: 3px; + font-size: 13px; + } + QPushButton:hover { + background-color: #0b5ed7; + } + """) + btn_layout.addWidget(copy_web_btn) + + # 复制微信按钮 + copy_wx_btn = QPushButton("复制微信") + copy_wx_btn.setCursor(Qt.PointingHandCursor) + copy_wx_btn.clicked.connect(lambda: self._copy_to_clipboard("bshkcigar", "已复制微信号")) + copy_wx_btn.setStyleSheet(""" + QPushButton { + background-color: #198754; + color: white; + border: none; + padding: 6px 16px; + border-radius: 3px; + font-size: 13px; + } + QPushButton:hover { + background-color: #157347; + } + """) + btn_layout.addWidget(copy_wx_btn) + + # 确定按钮 + ok_btn = QPushButton("确定") + ok_btn.setCursor(Qt.PointingHandCursor) + ok_btn.clicked.connect(msg.accept) + ok_btn.setStyleSheet(""" + QPushButton { + background-color: #6c757d; + color: white; + border: none; + padding: 6px 16px; + border-radius: 3px; + font-size: 13px; + } + QPushButton:hover { + background-color: #5c636a; + } + """) + btn_layout.addWidget(ok_btn) + + layout.addLayout(btn_layout) + + # 设置对话框样式和布局 + msg.setStyleSheet(""" + QDialog { + background-color: white; + } + """) + msg.setLayout(layout) + + # 显示对话框 + msg.exec_() + + def _copy_to_clipboard(self, text: str, status_msg: str): + """复制文本到剪贴板并更新状态栏""" + QApplication.clipboard().setText(text) + main_window = self.window() + if main_window and hasattr(main_window, 'status_bar'): + main_window.status_bar.set_status(status_msg) + + def setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 10) # 移除边距 + + # 标题 + title = QLabel("激活(听泉)会员,多个激活码可叠加整体时长") + title.setStyleSheet("color: #28a745; font-size: 14px; font-weight: bold; border: none;") # 绿色标题 + layout.addWidget(title) + + # 激活码输入区域 + input_layout = QHBoxLayout() + self.code_input = QLineEdit() + self.code_input.setPlaceholderText("请输入激活码") + self.code_input.setMinimumWidth(300) + self.code_input.setStyleSheet(""" + QLineEdit { + background-color: #f8f9fa; + border: 1px solid #ced4da; + border-radius: 4px; + padding: 8px; + color: #495057; + } + """) + input_layout.addWidget(self.code_input) + + # 激活按钮 + self.activate_btn = QPushButton("激活") + self.activate_btn.clicked.connect(self.activate) + self.activate_btn.setStyleSheet(""" + QPushButton { + background-color: #007bff; + color: white; + border: none; + padding: 8px 22px; + border-radius: 4px; + font-size: 13px; + } + QPushButton:hover { + background-color: #0056b3; + } + """) + input_layout.addWidget(self.activate_btn) + + layout.addLayout(input_layout) + + self.setStyleSheet(""" + QFrame { + background-color: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 4px; + padding: 10px; + } + """) + + def activate(self): + """处理激活""" + try: + code = self.code_input.text().strip() + + # 获取主窗口实例 + main_window = self.window() + + if not main_window: + print("错误:找不到主窗口") + return + + if not hasattr(main_window, 'handle_activation'): + print("错误:主窗口没有 handle_activation 方法") + return + + if not code: + print("警告:激活码为空") + self.show_activation_required() + return + + print(f"正在处理激活码:{code}") + main_window.handle_activation(code) + + except Exception as e: + print(f"激活处理发生错误:{str(e)}") + + def clear_input(self): + """清空输入框""" + self.code_input.clear() + +class LogWidget(QTextEdit): + """日志显示组件""" + def __init__(self, parent=None): + super().__init__(parent) + self.setReadOnly(True) + self.setStyleSheet(""" + QTextEdit { + background-color: #f5f5f5; + border: 1px solid #ddd; + border-radius: 4px; + padding: 8px; + font-family: 'Consolas', 'Microsoft YaHei', monospace; + font-size: 12px; + } + """) + + def append_log(self, text: str): + """添加日志并滚动到底部""" + self.append(text) + self.verticalScrollBar().setValue( + self.verticalScrollBar().maximum() + ) + +class StatusBar(QFrame): + """状态栏组件""" + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + def setup_ui(self): + """初始化UI""" + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 5, 10, 5) + + self.status_label = QLabel("就绪") + self.status_label.setStyleSheet("font-size: 14px; color: #333;") + layout.addWidget(self.status_label) + + def set_status(self, text: str): + """设置状态文本""" + self.status_label.setText(text) + +class ActionButton(QPushButton): + """操作按钮基类""" + def __init__(self, text: str, color: str, parent=None): + super().__init__(text, parent) + self.setStyleSheet(f""" + QPushButton {{ + background-color: {color}; + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + font-size: 14px; + min-height: 36px; + }} + QPushButton:hover {{ + background-color: {self._get_hover_color(color)}; + }} + QPushButton:disabled {{ + background-color: #ccc; + }} + """) + + def _get_hover_color(self, color: str) -> str: + """获取悬停颜色""" + # 简单的颜色加深处理 + if color == "#007bff": # 蓝色 + return "#0056b3" + elif color == "#28a745": # 绿色 + return "#218838" + return color + +class MemberStatusWidget(QFrame): + """会员状态显示组件 + + 职责: + 1. 显示会员状态信息 + 2. 显示设备信息 + 3. 根据不同状态显示不同样式 + + 属性: + status_text (QTextEdit): 状态信息显示区域 + + 显示信息: + - 会员状态(正常/未激活) + - 到期时间 + - 剩余天数 + - 设备信息(系统、设备名、IP、地区) + """ + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 10) + + # 创建一个容器 Frame + container = QFrame(self) + container.setObjectName("memberStatusContainer") + container_layout = QVBoxLayout(container) + container_layout.setSpacing(5) # 减小组件间距 + container_layout.setContentsMargins(0,0,0,0) # 减小内边距 + container.setFixedHeight(220) # 根据需要调整这个值 + + # 状态信息 + self.status_text = QTextEdit() + self.status_text.setReadOnly(True) + + self.status_text.setStyleSheet(""" + QTextEdit { + background-color: #f8fafc; + border: none; + border-radius: 6px; + padding: 20px; + color: #4a5568; + font-size: 15px; + line-height: 1.5; + } + /* 滚动条整体样式 */ + QScrollBar:vertical { + border: none; + background: #f8fafc; + width: 6px; + margin: 0px; + } + /* 滚动条滑块 */ + QScrollBar::handle:vertical { + background: #e2e8f0; + border-radius: 3px; + min-height: 20px; + } + QScrollBar::handle:vertical:hover { + background: #cbd5e1; + } + /* 滚动条上下按钮 */ + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { + border: none; + background: none; + height: 0px; + } + /* 滚动条背景 */ + QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { + background: none; + } + """) + container_layout.addWidget(self.status_text) + + # 设置容器样式 + container.setStyleSheet(""" + #memberStatusContainer { + background-color: white; + border: 1px solid #e2e8f0; + border-radius: 6px; + } + #titleContainer { + background: transparent; + } + """) + + layout.addWidget(container) + + def update_status(self, status: Dict): + """更新状态显示""" + try: + # 获取状态信息 + is_activated = status.get("is_activated", False) + expire_time = status.get("expire_time", "未知") + days_left = status.get("days_left", 0) + device_info = status.get("device_info", {}) + + # 构建状态文本 + status_text = [] + + # 会员状态 + status_color = "#10B981" if is_activated else "#EF4444" # 绿色或红色 + status_text.append(f'会员状态:{("正常" if is_activated else "未激活")}') + + # 到期时间和剩余天数 + if is_activated: + status_text.append(f"到期时间:{expire_time}") + # 根据剩余天数显示不同颜色 + days_color = "#10B981" if days_left > 30 else "#F59E0B" if days_left > 7 else "#EF4444" + status_text.append(f'剩余天数:{days_left}天') + + + + status_text.append("
") + # 设备信息 + status_text.append("\n设备信息:") + status_text.append(f"系统:{device_info.get('os', 'Windows')}") + status_text.append(f"设备名:{device_info.get('device_name', '未知')}") + status_text.append(f"IP地址:{device_info.get('ip', '未知')}") + status_text.append(f"地区:{device_info.get('location', '未知')}") + + # 更新文本 + self.status_text.setHtml("
".join(status_text)) + + except Exception as e: + # 如果发生异常,显示错误信息 + error_text = f'状态更新失败: {str(e)}' + self.status_text.setHtml(error_text) + +class LoadingSpinner(QWidget): + """自定义加载动画组件""" + def __init__(self, parent=None, size=40, line_width=3): + super().__init__(parent) + self.setFixedSize(size, size) + self.angle = 0 + self.line_width = line_width + self._size = size + self._speed = 30 # 每次更新转动的角度 + + # 设置动画 + self.animation = QPropertyAnimation(self, b"rotation") + self.animation.setDuration(800) # 缩短动画时间到800ms使其更流畅 + self.animation.setStartValue(0) + self.animation.setEndValue(360) + self.animation.setLoopCount(-1) # 无限循环 + + # 使用 InOutQuad 缓动曲线让动画更自然 + curve = QEasingCurve(QEasingCurve.InOutQuad) + self.animation.setEasingCurve(curve) + + # 创建定时器用于额外的动画效果 + self.timer = QTimer(self) + self.timer.timeout.connect(self.rotate) + self.timer.setInterval(50) # 20fps的刷新率 + + def paintEvent(self, event): + """绘制旋转的圆弧""" + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + # 计算圆的位置 + rect = QRect(self.line_width, self.line_width, + self._size - 2*self.line_width, + self._size - 2*self.line_width) + + # 设置画笔 + pen = QPen() + pen.setWidth(self.line_width) + pen.setCapStyle(Qt.RoundCap) + + # 绘制渐变圆弧 + segments = 8 + for i in range(segments): + # 计算每个段的透明度 + alpha = int(255 * (1 - i / segments)) + # 使用更鲜艳的蓝色 + pen.setColor(QColor(0, 122, 255, alpha)) + painter.setPen(pen) + + # 计算每个段的起始角度和跨度 + start_angle = (self.angle - i * (360 // segments)) % 360 + span = 30 # 每个段的跨度 + + # 转换角度为16分之一度(Qt的角度单位) + start_angle_16 = start_angle * 16 + span_16 = span * 16 + + painter.drawArc(rect, -start_angle_16, -span_16) + + def rotate(self): + """更新旋转角度""" + self.angle = (self.angle + self._speed) % 360 + self.update() + + @pyqtProperty(int) + def rotation(self): + return self.angle + + @rotation.setter + def rotation(self, angle): + self.angle = angle + self.update() + + def start(self): + """开始动画""" + self.animation.start() + self.timer.start() + + def stop(self): + """停止动画""" + self.animation.stop() + self.timer.stop() + +class LoadingDialog(QDialog): + """加载对话框组件""" + def __init__(self, parent=None, message="请稍候..."): + super().__init__(parent) + self.setWindowTitle("处理中") + self.setFixedSize(300, 120) + self.setWindowFlags(Qt.Dialog | Qt.CustomizeWindowHint | Qt.WindowTitleHint) + + layout = QVBoxLayout() + layout.setSpacing(15) + + # 消息标签 + self.message_label = QLabel(message) + self.message_label.setAlignment(Qt.AlignCenter) + self.message_label.setStyleSheet(""" + color: #0d6efd; + font-size: 14px; + font-weight: bold; + padding: 10px; + """) + layout.addWidget(self.message_label) + + # 加载动画 + self.spinner = LoadingSpinner(self, size=48, line_width=4) # 增大尺寸 + spinner_layout = QHBoxLayout() + spinner_layout.addStretch() + spinner_layout.addWidget(self.spinner) + spinner_layout.addStretch() + layout.addLayout(spinner_layout) + + self.setLayout(layout) + + # 设置样式 + self.setStyleSheet(""" + QDialog { + background-color: #f8f9fa; + border-radius: 8px; + border: 1px solid #dee2e6; + } + """) + + def showEvent(self, event): + """显示时启动动画""" + super().showEvent(event) + self.spinner.start() + + def hideEvent(self, event): + """隐藏时停止动画""" + self.spinner.stop() + super().hideEvent(event) + + def set_message(self, message: str): + """更新消息文本""" + self.message_label.setText(message) diff --git a/gui/components/workers.py b/gui/components/workers.py new file mode 100644 index 0000000..08c8f37 --- /dev/null +++ b/gui/components/workers.py @@ -0,0 +1,98 @@ +from services.cursor_service import CursorService +from common_utils import get_hardware_id +from exit_cursor import ExitCursor +from PyQt5.QtCore import QThread, pyqtSignal + +class BaseWorker(QThread): + """基础工作线程类""" + progress = pyqtSignal(dict) # 进度信号 + finished = pyqtSignal(tuple) # 完成信号 + error = pyqtSignal(str) # 错误信号 + + def __init__(self): + super().__init__() + self._is_running = True + + def stop(self): + """停止线程""" + self._is_running = False + self.wait() + +class RefreshTokenWorker(BaseWorker): + """刷新 Cursor Token 工作线程""" + def run(self): + try: + service = CursorService() + machine_id = get_hardware_id() + + # 更新进度 - 退出 Cursor + self.progress.emit({ + "status": "exiting", + "message": "正在关闭 Cursor 进程..." + }) + + # 退出 Cursor + if not ExitCursor(): + raise Exception("无法完全关闭 Cursor 进程,请手动关闭后重试") + + # 更新进度 - 开始刷新 + self.progress.emit({ + "status": "refreshing", + "message": "正在刷新 Cursor 授权..." + }) + + # 调用刷新服务 + success, msg, account_info = service.refresh_account(machine_id) + + if success and account_info: + # 更新进度 - 开始重置机器码 + self.progress.emit({ + "status": "resetting", + "message": "正在重置机器码..." + }) + + # 调用重置机器码 + reset_success, reset_msg, reset_data = service.reset_machine() + + if reset_success: + # 更新进度 + self.progress.emit({ + "status": "success", + "message": f"授权刷新成功: {account_info.get('email')}" + }) + + result_msg = ( + f"授权刷新成功\n" + f"账号: {account_info.get('email')}\n" + f"机器码重置成功\n" + f"请重新启动 Cursor 编辑器" + ) + else: + result_msg = f"授权刷新成功,但机器码重置失败: {reset_msg}" + else: + result_msg = f"授权刷新失败: {msg}" + + self.finished.emit(('refresh', (success, result_msg))) + + except Exception as e: + self.error.emit(str(e)) + +class ResetWorker(BaseWorker): + """重置机器码工作线程""" + def run(self): + try: + service = CursorService() + success, msg, data = service.reset_machine() + self.finished.emit(('reset', (success, msg, data))) + except Exception as e: + self.error.emit(str(e)) + +class DisableWorker(BaseWorker): + """禁用更新工作线程""" + def run(self): + try: + service = CursorService() + success, msg = service.disable_update() + self.finished.emit(('disable', (success, msg))) + except Exception as e: + self.error.emit(str(e)) \ No newline at end of file diff --git a/gui/windows/__init__.py b/gui/windows/__init__.py new file mode 100644 index 0000000..65076f6 --- /dev/null +++ b/gui/windows/__init__.py @@ -0,0 +1 @@ +"""GUI 窗口包""" \ No newline at end of file diff --git a/gui/windows/main_window.py b/gui/windows/main_window.py new file mode 100644 index 0000000..ccfca84 --- /dev/null +++ b/gui/windows/main_window.py @@ -0,0 +1,667 @@ +import json +from typing import Dict +from PyQt5.QtWidgets import ( + QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QMessageBox, QLabel, QLineEdit, QPushButton, + QFrame, QTextEdit, QDesktopWidget, QSystemTrayIcon, + QMenu, QAction +) +from PyQt5.QtCore import Qt, QTimer +from PyQt5.QtGui import QFont, QIcon, QDesktopServices +from PyQt5.QtCore import QUrl +from PyQt5.QtWidgets import QApplication +from pathlib import Path + +from services.cursor_service import CursorService +from gui.components.widgets import ( + LogWidget, StatusBar, ActionButton, + ActivationWidget, MemberStatusWidget, + ActivationStatusWidget, LoadingDialog +) +from gui.components.workers import ResetWorker, DisableWorker, RefreshTokenWorker +from common_utils import get_hardware_id, get_current_version +from utils.version_manager import VersionManager + +class DeviceIdWidget(QFrame): + """设备识别码显示组件""" + def __init__(self, device_id: str, parent=None): + super().__init__(parent) + self.setup_ui(device_id) + + def setup_ui(self, device_id: str): + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) # 移除边距 + + # 标签 + label = QLabel("设备识别码(勿动):") + label.setStyleSheet("color: #dc3545; font-weight: bold;") # 红色警示 + layout.addWidget(label) + + # 显示设备ID的文本框 + self.id_input = QLineEdit(device_id) + self.id_input.setReadOnly(True) + layout.addWidget(self.id_input) + + # 复制按钮 + copy_btn = QPushButton("复制ID") + copy_btn.clicked.connect(self.copy_device_id) + layout.addWidget(copy_btn) + + self.setStyleSheet(""" + QLineEdit { + background-color: #f8f9fa; + border: 1px solid #ced4da; + border-radius: 4px; + padding: 5px; + color: #495057; + } + QPushButton { + background-color: #6c757d; + color: white; + border: none; + padding: 5px 10px; + border-radius: 4px; + } + QPushButton:hover { + background-color: #5a6268; + } + """) + + def copy_device_id(self): + """复制设备ID到剪贴板""" + self.id_input.selectAll() + self.id_input.copy() + QMessageBox.information(self, "成功", "设备ID已复制到剪贴板") + +class InstructionsWidget(QFrame): + """使用步骤说明组件""" + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 10) # 移除边距 + + # 标题 + title = QLabel("使用步骤说明:") + title.setStyleSheet("color: #17a2b8; font-size: 14px; font-weight: bold;") # 青色标题 + layout.addWidget(title) + + # 步骤说明 + steps = [ + ("第一步", "输入激活码点击【激活】按钮完成激活"), + ("第二步", "点击【刷新Cursor编辑器授权】,一般情况下刷新即可正常使用"), + ("如果无法对话", "点击【实现Cursor0.45.x限制】,然后重新刷新授权"), + ("建议操作", "点击【禁用Cursor版本更新】,保持软件稳定运行") + ] + + for step_title, step_content in steps: + step_label = QLabel(f"{step_title}:{step_content}") + step_label.setWordWrap(True) + step_label.setStyleSheet(""" + QLabel { + color: #495057; + margin: 3px 0; + } + """) + layout.addWidget(step_label) + + # 给标题部分添加颜色 + text = step_label.text() + step_label.setText(f'{step_title}:{step_content}') + +class MainWindow(QMainWindow): + """主窗口""" + def __init__(self): + super().__init__() + self.cursor_service = CursorService() + self.version_manager = VersionManager() + self.current_worker = None + self.loading_dialog = None + + # 初始化托盘图标 + self.setup_tray() + + self.setup_ui() + + # 移除直接自检 + # self.auto_check() + + def setup_tray(self): + """初始化托盘图标""" + # 创建托盘图标 + self.tray_icon = QSystemTrayIcon(self) + + # 设置图标 + icon_path = Path(__file__).parent.parent.parent / "two.ico" + if icon_path.exists(): + self.tray_icon.setIcon(QIcon(str(icon_path))) + + # 创建托盘菜单 + self.tray_menu = QMenu() + + # 显示主窗口动作 + show_action = QAction("显示主窗口", self) + show_action.triggered.connect(self.show_main_window) + self.tray_menu.addAction(show_action) + + # 刷新授权动作 + refresh_action = QAction("刷新 Cursor 编辑器授权", self) + refresh_action.triggered.connect(lambda: self.start_task('refresh')) + self.tray_menu.addAction(refresh_action) + + # 退出动作 + quit_action = QAction("退出", self) + quit_action.triggered.connect(self.quit_application) + self.tray_menu.addAction(quit_action) + + # 设置托盘菜单 + self.tray_icon.setContextMenu(self.tray_menu) + + # 托盘图标双击事件 + self.tray_icon.activated.connect(self.tray_icon_activated) + + # 显示托盘图标 + self.tray_icon.show() + + def show_main_window(self): + """显示主窗口""" + self.show() + self.activateWindow() + + def quit_application(self): + """退出应用程序""" + # 停止所有任务 + if self.current_worker: + self.current_worker.stop() + # 移除托盘图标 + self.tray_icon.setVisible(False) + # 退出应用 + QApplication.quit() + + def tray_icon_activated(self, reason): + """托盘图标激活事件""" + if reason == QSystemTrayIcon.DoubleClick: + # 双击显示主窗口 + self.show_main_window() + + def showEvent(self, event): + """窗口显示事件""" + super().showEvent(event) + # 在窗口显示后执行自检 + QTimer.singleShot(500, self.auto_check) # 延迟500ms执行,确保界面完全显示 + + def show_loading(self, message: str): + """显示加载对话框""" + if not self.loading_dialog: + self.loading_dialog = LoadingDialog(self) + self.loading_dialog.set_message(message) + self.loading_dialog.show() + QApplication.processEvents() # 确保对话框立即显示 + + def hide_loading(self): + """隐藏加载对话框""" + if self.loading_dialog: + self.loading_dialog.hide() + + def auto_check(self): + """启动时自检""" + try: + # 1. 检查更新 + has_update, msg, update_info = self.version_manager.check_update() + + if has_update and update_info: + reply = QMessageBox.question( + self, + "发现新版本", + f"发现新版本: {update_info['version']}\n\n" + f"更新内容:\n{update_info['message']}\n\n" + "是否立即下载更新?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.Yes + ) + + if reply == QMessageBox.Yes: + QDesktopServices.openUrl(QUrl(update_info['download_url'])) + + # 2. 检查激活状态 + self.update_member_status() + + # 3. 恢复状态栏 + self.status_bar.set_status("就绪") + + except Exception as e: + self.status_bar.set_status("自检过程出现错误") + self.logger.error(f"自检失败: {str(e)}") + + def setup_ui(self): + """初始化UI""" + self.setWindowTitle(f"听泉助手 v{get_current_version()}") + self.setMinimumSize(600, 500) + + # 设置窗口图标 + icon_path = Path(__file__).parent.parent.parent / "two.ico" + if icon_path.exists(): + self.setWindowIcon(QIcon(str(icon_path))) + + # 设置窗口背景为白色 + self.setStyleSheet(""" + QMainWindow { + background-color: white; + } + QWidget { + background-color: white; + } + """) + + # 创建中心部件和布局 + central_widget = QWidget() + self.setCentralWidget(central_widget) + layout = QVBoxLayout(central_widget) + layout.setSpacing(15) + layout.setContentsMargins(15, 15, 15, 15) + + # 设备识别码区域 + device_id = get_hardware_id() # 使用common_utils中的函数 + self.device_id_widget = DeviceIdWidget(device_id) + layout.addWidget(self.device_id_widget) + + # 会员状态区域 + self.member_status = MemberStatusWidget() + layout.addWidget(self.member_status) + + # 激活区域 + self.activation_widget = ActivationWidget(self) # 使用 self 作为父组件 + layout.addWidget(self.activation_widget) + + # 使用说明区域 + self.instructions = InstructionsWidget() + layout.addWidget(self.instructions) + + # 功能按钮区域 + button_layout = QVBoxLayout() + button_layout.setSpacing(10) + + # 刷新授权按钮 - 蓝色 + self.refresh_button = QPushButton("刷新重置对话 Cursor 编辑器") + self.refresh_button.clicked.connect(lambda: self.start_task('refresh')) + self.refresh_button.setStyleSheet(""" + QPushButton { + background-color: #007bff; + color: white; + border: none; + padding: 12px; + border-radius: 4px; + font-size: 14px; + } + QPushButton:hover { + background-color: #0056b3; + } + QPushButton:disabled { + background-color: #ccc; + } + """) + button_layout.addWidget(self.refresh_button) + + # 实现限制按钮 - 绿色 + self.limit_button = QPushButton("实现 Cursor 0.45.x 限制") + self.limit_button.clicked.connect(lambda: self.start_task('limit')) + self.limit_button.setStyleSheet(""" + QPushButton { + background-color: #28a745; + color: white; + border: none; + padding: 12px; + border-radius: 4px; + font-size: 14px; + } + QPushButton:hover { + background-color: #218838; + } + QPushButton:disabled { + background-color: #ccc; + } + """) + button_layout.addWidget(self.limit_button) + + # 禁用更新按钮 - 红色 + self.disable_button = QPushButton("禁用 Cursor 版本更新") + self.disable_button.clicked.connect(lambda: self.start_task('disable')) + self.disable_button.setStyleSheet(""" + QPushButton { + background-color: #dc3545; + color: white; + border: none; + padding: 12px; + border-radius: 4px; + font-size: 14px; + } + QPushButton:hover { + background-color: #c82333; + } + QPushButton:disabled { + background-color: #ccc; + } + """) + button_layout.addWidget(self.disable_button) + + layout.addLayout(button_layout) + + # 检查更新按钮 - 灰色 + self.check_update_button = QPushButton("检查更新") + self.check_update_button.clicked.connect(self.check_update) + self.check_update_button.setStyleSheet(""" + QPushButton { + background-color: #6c757d; + color: white; + border: none; + padding: 8px; + border-radius: 4px; + margin-top: 5px; + font-size: 13px; + min-width: 100px; + } + QPushButton:hover { + background-color: #5a6268; + } + QPushButton:disabled { + background-color: #cccccc; + } + """) + layout.addWidget(self.check_update_button) + + # 状态栏 + self.status_bar = StatusBar() + layout.addWidget(self.status_bar) + + # 初始化状态 + self.update_member_status() + + # 检查是否有未完成的更新 + self.check_pending_update() + + def update_member_status(self): + """更新会员状态""" + status = self.cursor_service.check_activation_status() + self.member_status.update_status(status) + + # 根据激活状态更新按钮可用性 + buttons_enabled = status["is_activated"] + self.refresh_button.setEnabled(buttons_enabled) + self.limit_button.setEnabled(buttons_enabled) + self.disable_button.setEnabled(buttons_enabled) + + def handle_activation(self, code: str): + """处理激活码激活""" + try: + print(f"MainWindow 收到激活请求,激活码:{code}") + + # 更新状态栏 + print("更新状态栏:正在激活...") + self.status_bar.set_status("正在激活...") + + # 禁用激活按钮 + print("禁用激活按钮") + self.activation_widget.activate_btn.setEnabled(False) + + # 调用激活服务 + print("调用激活服务...") + success, msg = self.cursor_service.activate_with_code(code) + print(f"激活结果:success={success}, msg={msg}") + + if success: + QMessageBox.information(self, "成功", msg) + self.update_member_status() + else: + QMessageBox.warning(self, "失败", msg) + + # 清空输入框 + self.activation_widget.clear_input() + + except Exception as e: + print(f"激活过程发生错误:{str(e)}") + QMessageBox.critical(self, "错误", f"激活过程发生错误:{str(e)}") + finally: + # 恢复状态栏 + print("恢复状态栏和按钮状态") + self.status_bar.set_status("就绪") + # 恢复激活按钮 + self.activation_widget.activate_btn.setEnabled(True) + + def start_task(self, task_type: str): + """启动任务""" + # 检查激活状态 + if not self.cursor_service.check_activation_status()["is_activated"]: + self.activation_widget.show_activation_required() + return + + # 停止当前任务(如果有) + if self.current_worker: + self.current_worker.stop() + + # 禁用所有按钮 + self.refresh_button.setEnabled(False) + self.limit_button.setEnabled(False) + self.disable_button.setEnabled(False) + + # 创建并启动工作线程 + if task_type == 'refresh': + self.status_bar.set_status("正在刷新授权...") + worker = RefreshTokenWorker() + worker.progress.connect(self.update_progress) + worker.finished.connect(self.handle_result) + worker.error.connect(self.handle_error) + worker.start() + self.current_worker = worker + elif task_type == 'limit': + self.status_bar.set_status("正在设置版本限制...") + worker = ResetWorker() + worker.progress.connect(self.update_progress) + worker.finished.connect(self.handle_result) + worker.error.connect(self.handle_error) + worker.start() + self.current_worker = worker + elif task_type == 'disable': + self.status_bar.set_status("正在禁用更新...") + worker = DisableWorker() + worker.progress.connect(self.update_progress) + worker.finished.connect(self.handle_result) + worker.error.connect(self.handle_error) + worker.start() + self.current_worker = worker + + def update_progress(self, info: dict): + """更新进度信息""" + self.status_bar.set_status(info.get('message', '处理中...')) + + def handle_result(self, result: tuple): + """处理任务结果""" + task_type, data = result + success, msg = data if isinstance(data, tuple) else (False, str(data)) + + if success: + QMessageBox.information(self, "成功", msg) + else: + QMessageBox.warning(self, "失败", msg) + + # 重新启用按钮 + self.update_member_status() + self.status_bar.set_status("就绪") + + # 清理工作线程 + if self.current_worker: + self.current_worker.stop() + self.current_worker = None + + def handle_error(self, error_msg: str): + """处理错误""" + self.status_bar.set_status("发生错误") + QMessageBox.critical(self, "错误", f"操作失败:{error_msg}") + + # 重新启用按钮 + self.update_member_status() + + # 清理工作线程 + if self.current_worker: + self.current_worker.stop() + self.current_worker = None + + def check_update(self): + """手动检查更新""" + try: + # 禁用更新按钮 + self.check_update_button.setEnabled(False) + self.status_bar.set_status("正在检查更新...") + + # 检查更新 + has_update, msg, update_info = self.version_manager.check_update() + + if has_update and update_info: + # 创建自定义消息框 + msg_box = QMessageBox(self) + msg_box.setWindowTitle("发现新版本") + msg_box.setIcon(QMessageBox.Information) + + # 设置文本 + text = ( + f"

发现新版本: {update_info['version']}

" + f"

更新内容:

" + f"

{update_info['message']}

" + ) + msg_box.setText(text) + + # 设置按钮 + msg_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No) + yes_btn = msg_box.button(QMessageBox.Yes) + no_btn = msg_box.button(QMessageBox.No) + yes_btn.setText("立即下载") + no_btn.setText("暂不更新") + + # 设置样式 + msg_box.setStyleSheet(""" + QMessageBox { + background-color: white; + } + QPushButton { + min-width: 85px; + min-height: 24px; + padding: 4px 15px; + border-radius: 3px; + background-color: #007bff; + color: white; + border: none; + } + QPushButton:hover { + background-color: #0056b3; + } + QPushButton[text='暂不更新'] { + background-color: #6c757d; + } + QPushButton[text='暂不更新']:hover { + background-color: #5a6268; + } + """) + + # 设置最小宽度 + msg_box.setMinimumWidth(400) + + # 显示对话框 + if msg_box.exec_() == QMessageBox.Yes: + QDesktopServices.openUrl(QUrl(update_info['download_url'])) + else: + # 创建自定义消息框 + msg_box = QMessageBox(self) + msg_box.setWindowTitle("检查更新") + msg_box.setIcon(QMessageBox.Information) + msg_box.setText(f"

{msg}

") + msg_box.setStyleSheet(""" + QMessageBox { + background-color: white; + } + QPushButton { + min-width: 85px; + min-height: 24px; + padding: 4px 15px; + border-radius: 3px; + background-color: #007bff; + color: white; + border: none; + } + QPushButton:hover { + background-color: #0056b3; + } + """) + msg_box.setMinimumWidth(300) + msg_box.exec_() + + except Exception as e: + error_box = QMessageBox(self) + error_box.setWindowTitle("错误") + error_box.setIcon(QMessageBox.Warning) + error_box.setText(f"

检查更新失败: {str(e)}

") + error_box.setStyleSheet(""" + QMessageBox { + background-color: white; + } + QPushButton { + min-width: 85px; + min-height: 24px; + padding: 4px 15px; + border-radius: 3px; + background-color: #dc3545; + color: white; + border: none; + } + QPushButton:hover { + background-color: #c82333; + } + """) + error_box.setMinimumWidth(300) + error_box.exec_() + + finally: + # 恢复按钮状态和状态栏 + self.check_update_button.setEnabled(True) + self.status_bar.set_status("就绪") + + def check_pending_update(self): + """检查是否有未完成的更新""" + try: + update_info = self.version_manager.get_last_update_info() + if update_info: + reply = QMessageBox.question( + self, + "未完成的更新", + f"检测到未完成的更新: {update_info['version']}\n\n" + f"更新内容:\n{update_info['message']}\n\n" + "是否现在更新?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.Yes + ) + + if reply == QMessageBox.Yes: + # 打开下载链接 + QDesktopServices.openUrl(QUrl(update_info['download_url'])) + else: + # 清除更新信息 + self.version_manager.clear_update_info() + except Exception as e: + self.logger.error(f"检查未完成更新失败: {str(e)}") + + def closeEvent(self, event): + """窗口关闭事件""" + if self.tray_icon.isVisible(): + # 最小化到托盘 + QMessageBox.information( + self, + "提示", + "程序将继续在后台运行,双击托盘图标可以重新打开主窗口。" + ) + self.hide() + event.ignore() + else: + # 停止所有正在运行的任务 + if self.current_worker: + self.current_worker.stop() + event.accept() \ No newline at end of file diff --git a/logger.py b/logger.py new file mode 100644 index 0000000..5ca895a --- /dev/null +++ b/logger.py @@ -0,0 +1,67 @@ +import os +import logging +from datetime import datetime +from typing import Optional + +class Logger: + """统一的日志管理类""" + + _instance = None + _initialized = False + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + if not self._initialized: + # 创建日志目录 + self.log_dir = os.path.join(os.getenv('APPDATA'), 'Cursor', 'logs') + os.makedirs(self.log_dir, exist_ok=True) + + # 配置根日志记录器 + self.logger = logging.getLogger('CursorTools') + self.logger.setLevel(logging.INFO) + + # 日志格式 + self.formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + + # 添加处理器 + self._add_handlers() + + self._initialized = True + + def _add_handlers(self): + """添加日志处理器""" + if not self.logger.handlers: + # 文件处理器 + log_file = os.path.join( + self.log_dir, + f'cursor_tools_{datetime.now().strftime("%Y%m%d")}.log' + ) + fh = logging.FileHandler(log_file, encoding='utf-8') + fh.setLevel(logging.INFO) + fh.setFormatter(self.formatter) + self.logger.addHandler(fh) + + # 控制台处理器 + ch = logging.StreamHandler() + ch.setLevel(logging.INFO) + ch.setFormatter(self.formatter) + self.logger.addHandler(ch) + + def get_logger(self, name: Optional[str] = None) -> logging.Logger: + """ + 获取日志记录器 + :param name: 日志记录器名称 + :return: 日志记录器实例 + """ + if name: + return self.logger.getChild(name) + return self.logger + +# 创建全局日志管理器实例 +logger = Logger() \ No newline at end of file diff --git a/machine_resetter.py b/machine_resetter.py new file mode 100644 index 0000000..9c5734b --- /dev/null +++ b/machine_resetter.py @@ -0,0 +1,197 @@ +import os +import sys +import sqlite3 +import requests +import urllib3 +import uuid +import winreg +import ctypes +import shutil +import json +from datetime import datetime +from typing import Dict, Tuple, Any, Optional +from logger import logger +from exit_cursor import ExitCursor + +class MachineResetter: + """机器码重置核心类""" + + def __init__(self, storage_file: str = None, backup_dir: str = None): + """ + 初始化机器码重置器 + :param storage_file: 可选,storage.json文件路径 + :param backup_dir: 可选,备份目录路径 + """ + # 设置基础路径 + appdata = os.getenv('APPDATA') + if not appdata: + raise EnvironmentError("APPDATA 环境变量未设置") + + self.storage_file = storage_file or os.path.join(appdata, 'Cursor', 'User', 'globalStorage', 'storage.json') + self.backup_dir = backup_dir or os.path.join(os.path.dirname(self.storage_file), 'backups') + + # 确保备份目录存在 + os.makedirs(self.backup_dir, exist_ok=True) + + # 获取日志记录器 + self.logger = logger.get_logger("MachineReset") + + # 进度回调 + self._callback = None + + def set_progress_callback(self, callback) -> None: + """设置进度回调函数""" + self._callback = callback + + def _update_progress(self, status: str, message: str) -> None: + """更新进度信息""" + if self._callback: + self._callback({"status": status, "message": message}) + + @staticmethod + def is_admin() -> bool: + """检查是否具有管理员权限""" + try: + return ctypes.windll.shell32.IsUserAnAdmin() + except: + return False + + def _get_storage_content(self) -> Dict[str, Any]: + """读取storage.json的内容""" + try: + with open(self.storage_file, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + raise Exception(f"读取配置文件失败: {str(e)}") + + def _save_storage_content(self, content: Dict[str, Any]) -> None: + """保存内容到storage.json""" + try: + with open(self.storage_file, 'w', encoding='utf-8') as f: + json.dump(content, f, indent=2, ensure_ascii=False) + except Exception as e: + raise Exception(f"保存配置文件失败: {str(e)}") + + def backup_file(self) -> str: + """ + 备份配置文件 + :return: 备份文件路径 + """ + if not os.path.exists(self.storage_file): + raise FileNotFoundError(f"配置文件不存在:{self.storage_file}") + + backup_name = f"storage.json.backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + backup_path = os.path.join(self.backup_dir, backup_name) + shutil.copy2(self.storage_file, backup_path) + self.logger.info(f"配置已备份到: {backup_path}") + return backup_path + + def generate_new_ids(self) -> Dict[str, str]: + """ + 生成新的机器码系列 + :return: 包含新ID的字典 + """ + # 生成 auth0|user_ 前缀的十六进制 + prefix = "auth0|user_".encode('utf-8').hex() + # 生成32字节(64个十六进制字符)的随机数 + random_part = uuid.uuid4().hex + uuid.uuid4().hex + + return { + "machineId": f"{prefix}{random_part}", + "macMachineId": str(uuid.uuid4()), + "devDeviceId": str(uuid.uuid4()), + "sqmId": "{" + str(uuid.uuid4()).upper() + "}" + } + + def update_config(self, new_ids: Dict[str, str]) -> None: + """ + 更新配置文件 + :param new_ids: 新的ID字典 + """ + config_content = self._get_storage_content() + + # 更新配置 + config_content['telemetry.machineId'] = new_ids['machineId'] + config_content['telemetry.macMachineId'] = new_ids['macMachineId'] + config_content['telemetry.devDeviceId'] = new_ids['devDeviceId'] + config_content['telemetry.sqmId'] = new_ids['sqmId'] + + # 保存更新后的配置 + self._save_storage_content(config_content) + self.logger.info("配置文件更新成功") + + def update_machine_guid(self) -> str: + """ + 更新注册表中的MachineGuid + :return: 新的GUID + """ + reg_path = r"SOFTWARE\Microsoft\Cryptography" + try: + # 打开注册表键 + reg_key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, reg_path, 0, + winreg.KEY_WRITE | winreg.KEY_READ) + + # 获取当前GUID用于备份 + current_guid, _ = winreg.QueryValueEx(reg_key, "MachineGuid") + self.logger.info(f"当前MachineGuid: {current_guid}") + + # 生成新GUID + new_guid = str(uuid.uuid4()) + + # 更新注册表 + winreg.SetValueEx(reg_key, "MachineGuid", 0, winreg.REG_SZ, new_guid) + + # 验证更改 + updated_guid, _ = winreg.QueryValueEx(reg_key, "MachineGuid") + if updated_guid != new_guid: + raise Exception("注册表验证失败,值未成功更新") + + winreg.CloseKey(reg_key) + self.logger.info(f"新MachineGuid: {new_guid}") + return new_guid + + except Exception as e: + raise Exception(f"更新MachineGuid失败: {str(e)}") + + def run(self) -> Tuple[Dict[str, str], str]: + """ + 执行重置操作 + :return: (新ID字典, 新GUID) + """ + try: + # 检查管理员权限 + self._update_progress("checking", "检查管理员权限...") + if not self.is_admin(): + raise PermissionError("必须以管理员权限运行该程序") + + # 先退出 Cursor 进程 + self._update_progress("exiting", "正在关闭 Cursor 进程...") + if not ExitCursor(): + raise Exception("无法完全关闭 Cursor 进程,请手动关闭后重试") + + # 备份配置文件 + self._update_progress("backup", "备份配置文件...") + backup_path = self.backup_file() + self.logger.info(f"配置已备份到: {backup_path}") + + # 生成新机器码 + self._update_progress("generating", "生成新的机器码...") + new_ids = self.generate_new_ids() + + # 更新配置文件 + self._update_progress("updating", "更新配置文件...") + self.update_config(new_ids) + + # 更新注册表 + self._update_progress("registry", "更新注册表...") + new_guid = self.update_machine_guid() + + self._update_progress("complete", "重置完成") + self.logger.info("机器码重置成功") + + return new_ids, new_guid + + except Exception as e: + self.logger.error(f"重置失败: {str(e)}") + self._update_progress("error", f"操作失败: {str(e)}") + raise \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7d99f55 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +PyQt5==5.15.9 +requests==2.31.0 +urllib3==2.1.0 +pyinstaller==6.3.0 +Pillow==10.2.0 +psutil==5.9.8 \ No newline at end of file diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..2397053 --- /dev/null +++ b/services/__init__.py @@ -0,0 +1 @@ +"""服务层包""" \ No newline at end of file diff --git a/services/cursor_service.py b/services/cursor_service.py new file mode 100644 index 0000000..cb6a196 --- /dev/null +++ b/services/cursor_service.py @@ -0,0 +1,237 @@ +"""Cursor 服务管理类""" + +import os +import json +import hashlib +from datetime import datetime, timedelta +from typing import Dict, Optional, Tuple +import urllib3 +import requests + +from cursor_token_refresher import CursorTokenRefresher +from machine_resetter import MachineResetter +from update_disabler import UpdateDisabler +from logger import logger +from common_utils import ( + get_hardware_id, + get_user_state, + check_user_state, + refresh_user_state, + update_user_state, + activate_device +) + +class CursorService: + """Cursor 服务管理类""" + _instance = None + _initialized = False + _last_check_time = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + if not self._initialized: + # 基础组件初始化 + self.token_refresher = CursorTokenRefresher() + self.machine_resetter = MachineResetter() + self.update_disabler = UpdateDisabler() + self.logger = logger.get_logger("CursorService") + + # 配置文件路径 + self.config_dir = os.path.join(os.getenv('APPDATA'), 'TingquanAssistant') + self.config_file = os.path.join(self.config_dir, 'config.json') + + # 确保配置目录存在 + os.makedirs(self.config_dir, exist_ok=True) + + # 初始化配置 + self._init_config() + + # 首次初始化用户状态 + self._lazy_init_user_state() + + self._initialized = True + + def _init_config(self): + """初始化配置文件""" + # 默认配置 + self.default_config = { + "version": "4.0.0.1", + "last_check_update": None, + "auto_check_update": True, + "theme": "light" + } + + # 读取或创建配置文件 + if not os.path.exists(self.config_file): + with open(self.config_file, 'w', encoding='utf-8') as f: + json.dump(self.default_config, f, indent=2, ensure_ascii=False) + self.config = self.default_config + else: + try: + with open(self.config_file, 'r', encoding='utf-8') as f: + self.config = json.load(f) + except Exception as e: + self.logger.error(f"读取配置文件失败: {e}") + self.config = self.default_config + + def _lazy_init_user_state(self): + """ + 懒加载方式初始化用户状态 + 只在首次使用时初始化,避免频繁刷新 + """ + try: + if not self._last_check_time: + machine_id = get_hardware_id() + success, msg, state_data = refresh_user_state(machine_id) + if success: + self._last_check_time = datetime.now() + self.logger.info("用户状态初始化成功") + else: + self.logger.warning(f"用户状态初始化失败: {msg}") + except Exception as e: + self.logger.error(f"初始化用户状态异常: {e}") + + def _should_refresh_state(self) -> bool: + """ + 检查是否需要刷新状态 + 每5分钟检测一次设备状态 + + Returns: + bool: 是否需要刷新状态 + """ + try: + # 如果没有上次检查时间,需要刷新 + if not self._last_check_time: + return True + + # 计算距离上次检查的时间 + now = datetime.now() + time_diff = now - self._last_check_time + + # 如果超过5分钟,需要刷新 + if time_diff.total_seconds() >= 300: # 5分钟 = 300秒 + self.logger.debug("距离上次状态检查已超过5分钟") + return True + + return False + + except Exception as e: + self.logger.error(f"检查状态刷新时间异常: {e}") + # 发生异常时返回False,避免频繁刷新 + return False + + def check_activation_status(self) -> Dict: + """ + 检查当前激活状态 + 如果需要刷新则自动刷新状态 + + Returns: + Dict: 状态信息字典 + """ + try: + # 检查是否需要刷新状态 + if self._should_refresh_state(): + machine_id = get_hardware_id() + success, msg, state_data = refresh_user_state(machine_id) + if success: + self._last_check_time = datetime.now() + self.logger.info("设备状态检查完成") + else: + self.logger.warning(f"设备状态检查失败: {msg}") + except Exception as e: + self.logger.error(f"设备状态检查异常: {e}") + + # 无论是否刷新成功,都返回当前状态 + return get_user_state() + + def activate_with_code(self, activation_code: str) -> Tuple[bool, str]: + """ + 使用激活码激活 + + Args: + activation_code: 激活码 + + Returns: + Tuple[bool, str]: (是否成功, 消息) + """ + try: + self.logger.info(f"开始处理激活码: {activation_code}") + + # 获取机器ID + machine_id = get_hardware_id() + if not machine_id: + self.logger.error("获取机器ID失败") + return False, "获取机器ID失败" + + self.logger.info(f"获取到机器ID: {machine_id}") + + # 调用激活接口 + self.logger.info("正在调用激活接口...") + success, msg, data = activate_device(machine_id, activation_code) + + if success: + self.logger.info(f"激活成功: {msg}") + else: + self.logger.warning(f"激活失败: {msg}") + + return success, msg + + except Exception as e: + error_msg = f"激活失败: {str(e)}" + self.logger.error(error_msg) + return False, error_msg + + def refresh_account(self, machine_id: str) -> Tuple[bool, str, Optional[Dict]]: + """刷新账号""" + # 检查是否已激活 + if not check_user_state(): + return False, "请先激活软件", None + return self.token_refresher.refresh_token(machine_id) + + def reset_machine(self) -> Tuple[bool, str, Dict]: + """重置机器码""" + # 检查是否已激活 + if not check_user_state(): + return False, "请先激活软件", {} + + try: + new_ids, new_guid = self.machine_resetter.run() + return True, "重置成功", { + "new_ids": new_ids, + "new_guid": new_guid + } + except Exception as e: + return False, f"重置失败: {str(e)}", {} + + def disable_update(self) -> Tuple[bool, str]: + """禁用更新""" + # 检查是否已激活 + if not check_user_state(): + return False, "请先激活软件" + + success = self.update_disabler.disable() + return success, "禁用成功" if success else "禁用失败" + + def save_config(self, new_config: Dict) -> bool: + """ + 保存配置 + :param new_config: 新的配置信息 + :return: 是否成功 + """ + try: + self.config.update(new_config) + with open(self.config_file, 'w', encoding='utf-8') as f: + json.dump(self.config, f, indent=2, ensure_ascii=False) + return True + except Exception as e: + self.logger.error(f"保存配置失败: {e}") + return False + + def get_config(self) -> Dict: + """获取当前配置""" + return self.config.copy() + diff --git a/testbuild.bat b/testbuild.bat new file mode 100644 index 0000000..d19f49f --- /dev/null +++ b/testbuild.bat @@ -0,0 +1,123 @@ +@echo off +chcp 65001 +setlocal EnableDelayedExpansion +echo 开始测试打包... + +:: 设置工作目录为脚本所在目录 +cd /d "%~dp0" + +:: 激活虚拟环境 +if exist "venv\Scripts\activate.bat" ( + echo 激活虚拟环境... + call venv\Scripts\activate.bat +) else ( + echo 警告: 未找到虚拟环境,使用系统 Python +) + +:: 确保安装了必要的包 +echo 检查依赖包... +pip install -r requirements.txt + +:: 读取版本号 +set /p VERSION= 3.4) +for /f "tokens=1,2 delims=." %%a in ("!VERSION!") do ( + set MAJOR_VERSION=%%a.%%b +) +echo 主版本目录: !MAJOR_VERSION! + +:: 读取测试版本号(如果存在) +if exist testversion.txt ( + set /p TEST_VERSION=testversion.txt +echo 测试版本号: !TEST_VERSION! + +:: 组合完整版本号 +set FULL_VERSION=!VERSION!.!TEST_VERSION! +echo 完整版本号: !FULL_VERSION! + +:: 创建测试版本目录 +set TEST_DIR=dist\test\!MAJOR_VERSION! +if not exist "!TEST_DIR!" ( + mkdir "!TEST_DIR!" + echo 创建目录: !TEST_DIR! +) + +:: 清理 Python 缓存文件 +echo 清理Python缓存文件... +for /d /r . %%d in (__pycache__) do @if exist "%%d" rd /s /q "%%d" +del /s /q *.pyc >nul 2>&1 +del /s /q *.pyo >nul 2>&1 + +:: 清理旧的打包文件 +echo 清理旧文件... +if exist "build" rd /s /q "build" +if exist "*.spec" del /f /q "*.spec" + +:: 使用优化选项进行打包 +echo 开始打包... +pyinstaller ^ + --noconfirm ^ + --clean ^ + --onefile ^ + --noconsole ^ + --icon=icon/two.ico ^ + --name "听泉cursor助手_test" ^ + --add-data "icon;icon" ^ + --add-data "version.txt;." ^ + --add-data "testversion.txt;." ^ + --add-data "requirements.txt;." ^ + --exclude-module _tkinter ^ + --exclude-module tkinter ^ + --exclude-module PIL.ImageTk ^ + --exclude-module PIL.ImageWin ^ + --exclude-module numpy ^ + --exclude-module pandas ^ + --exclude-module matplotlib ^ + --exclude "__pycache__" ^ + --exclude "*.pyc" ^ + --exclude "*.pyo" ^ + --exclude "*.pyd" ^ + main.py + +:: 检查打包结果并移动文件 +set TEMP_FILE=dist\听泉cursor助手_test.exe +set TARGET_FILE=!TEST_DIR!\听泉cursor助手v!FULL_VERSION!.exe + +echo 检查文件: !TEMP_FILE! +if exist "!TEMP_FILE!" ( + echo 测试打包成功! + + :: 移动到版本目录 + echo 移动文件到: !TARGET_FILE! + move "!TEMP_FILE!" "!TARGET_FILE!" + + :: 显示文件大小 + for %%I in ("!TARGET_FILE!") do ( + echo 文件大小: %%~zI 字节 + ) + + echo. + echo 测试版本构建完成! + echo 版本号: v!FULL_VERSION! + echo 文件位置: !TARGET_FILE! +) else ( + echo 错误: 打包失败,文件不存在 +) + +:: 退出虚拟环境 +if exist "venv\Scripts\activate.bat" ( + echo 退出虚拟环境... + deactivate +) + +endlocal +pause \ No newline at end of file diff --git a/tingquan_assistant.py b/tingquan_assistant.py new file mode 100644 index 0000000..7e74185 --- /dev/null +++ b/tingquan_assistant.py @@ -0,0 +1,91 @@ +""" +听泉助手 - Cursor 激活管理工具 + +项目结构说明: +--------------- +├── services/ # 服务层 - 处理核心业务逻辑 +│ ├── cursor_service.py # Cursor服务管理类 - 处理激活、配置等核心功能 +│ └── __init__.py +│ +├── gui/ # GUI层 - 处理界面相关逻辑 +│ ├── components/ # 可复用的GUI组件 +│ │ ├── widgets.py # 基础UI组件(按钮、状态栏等) +│ │ ├── workers.py # 后台工作线程 +│ │ └── __init__.py +│ ├── windows/ # 窗口类 +│ │ ├── main_window.py # 主窗口实现 +│ │ └── __init__.py +│ └── __init__.py +│ +├── config.py # 配置管理 +├── logger.py # 日志管理 +├── machine_resetter.py # 机器码重置 +├── update_disabler.py # 更新禁用 +├── cursor_token_refresher.py # Token刷新 +└── tingquan_assistant.py # 程序入口文件 + +设计规范: +--------------- +1. 分层设计 + - services层: 处理核心业务逻辑,与界面解耦 + - gui层: 只处理界面相关逻辑,通过services层调用业务功能 + - 工具类: 独立的功能模块(如日志、配置等) + +2. 代码规范 + - 使用类型注解 + - 函数必须有文档字符串 + - 遵循PEP 8命名规范 + - 异常必须合理处理和记录日志 + +3. 界面设计 + - 使用PyQt5构建GUI + - 所有耗时操作必须在后台线程中执行 + - 界面组件需实现合理的状态管理 + +4. 配置管理 + - 用户配置存储在 %APPDATA%/TingquanAssistant/ + - 激活信息使用JSON格式存储 + - 配置文件需要权限控制 + +5. 日志规范 + - 所有关键操作必须记录日志 + - 日志按日期分文件存储 + - 包含足够的错误诊断信息 + +使用说明: +--------------- +1. 运行入口: python tingquan_assistant.py +2. 开发新功能流程: + - 在services层添加业务逻辑 + - 在gui/components添加必要的界面组件 + - 在gui/windows中整合界面 + - 更新配置和日志相关代码 +""" + +import sys +import os + +# 添加项目根目录到Python路径 +current_dir = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, current_dir) + +from PyQt5.QtWidgets import QApplication +from gui.windows.main_window import MainWindow + +def main(): + """程序入口""" + # 创建应用 + app = QApplication(sys.argv) + + # 设置应用样式 + app.setStyle('Fusion') + + # 创建主窗口 + window = MainWindow() + window.show() + + # 运行应用 + sys.exit(app.exec_()) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/two.ico b/two.ico new file mode 100644 index 0000000000000000000000000000000000000000..fcd403e95e79a91d8e2126e886c3d5b7a6ee3339 GIT binary patch literal 29016 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 None: + """设置进度回调函数""" + self._callback = callback + + def _update_progress(self, status: str, message: str) -> None: + """更新进度信息""" + if self._callback: + self._callback({"status": status, "message": message}) + + def _ensure_parent_dir_exists(self): + """确保父目录存在""" + parent_dir = os.path.dirname(self.updater_path) + if not os.path.exists(parent_dir): + os.makedirs(parent_dir, exist_ok=True) + + def disable(self) -> bool: + """ + 禁用自动更新 + :return: 是否成功 + """ + try: + self._update_progress("start", "开始禁用自动更新...") + + # 确保父目录存在 + self._ensure_parent_dir_exists() + + # 检查并删除现有文件或目录 + self._update_progress("checking", "检查现有文件...") + try: + if os.path.exists(self.updater_path): + self._update_progress("removing", "删除现有文件...") + if os.path.isfile(self.updater_path): + os.remove(self.updater_path) + else: + shutil.rmtree(self.updater_path, ignore_errors=True) + self.logger.info(f"已删除: {self.updater_path}") + else: + self.logger.info("目标路径不存在,将创建新文件") + except Exception as e: + self.logger.warning(f"删除操作异常: {str(e)}") + + # 等待文件系统操作完成 + time.sleep(1) + + # 创建阻止更新的文件 + self._update_progress("creating", "创建阻止文件...") + try: + # 再次确保路径不存在 + if os.path.exists(self.updater_path): + os.remove(self.updater_path) + + # 创建新文件 + with open(self.updater_path, 'w') as f: + f.write("") + self.logger.info("已创建阻止文件") + + # 验证文件是否成功创建 + if not os.path.isfile(self.updater_path): + raise Exception("文件创建验证失败") + + except Exception as e: + raise Exception(f"创建文件失败: {str(e)}") + + # 设置文件只读属性 + self._update_progress("setting", "设置文件属性...") + try: + os.chmod(self.updater_path, stat.S_IREAD) + except Exception as e: + self.logger.warning(f"设置只读属性失败: {str(e)}") + + # 设置文件权限 + self._update_progress("permission", "设置文件权限...") + try: + username = getpass.getuser() + result = subprocess.run( + ['icacls', self.updater_path, '/inheritance:r', '/grant:r', f"{username}:(R)"], + capture_output=True, + text=True + ) + + if result.returncode != 0: + self.logger.warning(f"设置权限返回值非0: {result.stderr}") + except Exception as e: + self.logger.warning(f"设置权限失败: {str(e)}") + + # 最终验证 + if not os.path.exists(self.updater_path): + raise Exception("最终验证失败:文件不存在") + + if os.path.isdir(self.updater_path): + raise Exception("最终验证失败:创建了目录而不是文件") + + self._update_progress("complete", "禁用自动更新完成") + self.logger.info("成功禁用自动更新") + return True + + except Exception as e: + self.logger.error(f"禁用自动更新失败: {str(e)}") + self._update_progress("error", f"操作失败: {str(e)}") + self.show_manual_guide() + return False + + def show_manual_guide(self) -> str: + """ + 显示手动操作指南 + :return: 指南内容 + """ + guide = ( + "自动禁用更新失败,请按以下步骤手动操作:\n" + "1. 以管理员身份打开 PowerShell\n" + "2. 执行以下命令:\n" + f" Remove-Item -Path \"{self.updater_path}\" -Force -Recurse -ErrorAction SilentlyContinue\n" + f" New-Item -Path \"{self.updater_path}\" -ItemType File -Force\n" + f" Set-ItemProperty -Path \"{self.updater_path}\" -Name IsReadOnly -Value $true\n" + f" icacls \"{self.updater_path}\" /inheritance:r /grant:r \"{getpass.getuser()}:(R)\"" + ) + + self.logger.warning("需要手动禁用更新") + self.logger.info(guide) + return guide \ No newline at end of file diff --git a/utils/version_manager.py b/utils/version_manager.py new file mode 100644 index 0000000..3b0b29a --- /dev/null +++ b/utils/version_manager.py @@ -0,0 +1,227 @@ +"""版本管理模块 + +职责: +1. 检查软件更新 +2. 管理版本信息 +3. 处理更新下载 +4. 维护更新配置 + +设计原则: +1. 单一职责 +2. 配置与逻辑分离 +3. 异常处理标准化 +4. 日志完整记录 +""" + +import os +import sys +import json +import requests +import urllib3 +from pathlib import Path +from typing import Dict, Tuple, Optional +from logger import logger +from config import config + +class VersionConfig: + """版本配置类""" + + def __init__(self): + """初始化版本配置""" + self.update_url = f"{config.base_url}/admin/api.version/check" + self.config_dir = os.path.join(os.getenv('APPDATA'), 'TingquanAssistant') + self.config_file = os.path.join(self.config_dir, 'version.json') + + # 确保配置目录存在 + os.makedirs(self.config_dir, exist_ok=True) + + # 获取版本号 + self.current_version = self._get_version() + + def _get_version(self) -> str: + """ + 从version.txt文件获取当前版本号 + 如果是打包后的程序,从打包目录读取 + 如果是开发环境,从根目录读取 + """ + try: + # 确定version.txt的路径 + if getattr(sys, 'frozen', False): + # 如果是打包后的程序 + base_path = Path(sys._MEIPASS) + else: + # 如果是开发环境,获取根目录路径 + current_file = Path(__file__) + base_path = current_file.parent.parent # utils -> root + + version_file = base_path / "version.txt" + + # 读取版本号 + if version_file.exists(): + with open(version_file, "r", encoding="utf-8") as f: + version = f.read().strip() + logger.get_logger().info(f"当前版本: {version}") + return version + else: + logger.get_logger().error(f"版本文件不存在: {version_file}") + return "0.0.0" + + except Exception as e: + logger.get_logger().error(f"读取版本号失败: {str(e)}") + return "0.0.0" + +class VersionManager: + """版本管理器""" + + def __init__(self): + """初始化版本管理器""" + self.config = VersionConfig() + + # 禁用SSL警告 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + def _load_config(self) -> Dict: + """加载版本配置""" + try: + if os.path.exists(self.config.config_file): + with open(self.config.config_file, 'r', encoding='utf-8') as f: + return json.load(f) + return {} + except Exception as e: + logger.get_logger().error(f"加载版本配置失败: {str(e)}") + return {} + + def _save_config(self, config_data: Dict) -> bool: + """保存版本配置""" + try: + with open(self.config.config_file, 'w', encoding='utf-8') as f: + json.dump(config_data, f, indent=2, ensure_ascii=False) + return True + except Exception as e: + logger.get_logger().error(f"保存版本配置失败: {str(e)}") + return False + + def check_update(self) -> Tuple[bool, str, Optional[Dict]]: + """ + 检查更新 + + Returns: + Tuple[bool, str, Optional[Dict]]: + - bool: 是否有更新 + - str: 消息 + - Optional[Dict]: 更新信息 + """ + try: + log = logger.get_logger() + log.info(f"开始检查更新,当前版本: {self.config.current_version}") + + # 准备请求数据 + data = { + "version": self.config.current_version, + "platform": "windows" + } + + log.info(f"请求URL: {self.config.update_url}") + log.info(f"请求数据: {json.dumps(data, ensure_ascii=False)}") + log.info(f"请求头: {json.dumps(config.request_config['headers'], ensure_ascii=False)}") + + # 发送更新检查请求 + response = requests.post( + self.config.update_url, + json=data, + headers=config.request_config["headers"], + timeout=config.request_config["timeout"], + verify=config.request_config["verify"] + ) + + log.info(f"响应状态码: {response.status_code}") + log.info(f"响应内容: {response.text}") + + # 解析响应 + result = response.json() + + # 检查响应码(0或1都是正常响应) + if result.get("code") in [0, 1]: + update_info = result.get("data", {}) + + if update_info.get("has_update"): + # 有更新可用 + version_info = update_info.get("version_info", {}) + new_version = version_info.get("version") + update_msg = version_info.get("update_msg", "") + download_url = version_info.get("download_url", "") + is_force = update_info.get("is_force", 0) + + log.info(f"发现新版本: {new_version}") + log.info(f"更新信息: {json.dumps(version_info, ensure_ascii=False)}") + + update_data = { + "version": new_version, + "message": update_msg, + "download_url": download_url, + "is_force": bool(is_force), + "check_time": update_info.get("check_time") + } + + # 更新配置 + config_data = self._load_config() + config_data["last_check_update"] = update_data + self._save_config(config_data) + + return True, f"发现新版本: {new_version}", update_data + else: + log.info("当前已是最新版本") + return False, "当前已是最新版本", None + + else: + error_msg = result.get("msg", "检查更新失败") + log.error(f"检查更新失败: {error_msg}") + log.error(f"错误响应: {json.dumps(result, ensure_ascii=False)}") + return False, f"检查更新失败: {error_msg}", None + + except requests.exceptions.RequestException as e: + error_msg = f"网络连接失败: {str(e)}" + logger.get_logger().error(error_msg) + return False, error_msg, None + + except json.JSONDecodeError as e: + error_msg = f"解析响应失败: {str(e)}" + logger.get_logger().error(error_msg) + logger.get_logger().error(f"无法解析的响应内容: {response.text}") + return False, error_msg, None + + except Exception as e: + error_msg = f"检查更新异常: {str(e)}" + logger.get_logger().error(error_msg) + return False, error_msg, None + + def get_last_update_info(self) -> Optional[Dict]: + """获取上次检查的更新信息""" + try: + config_data = self._load_config() + update_info = config_data.get("last_check_update") + + if update_info: + logger.get_logger().info(f"获取到未完成的更新信息: {update_info['version']}") + + return update_info + + except Exception as e: + logger.get_logger().error(f"获取更新信息失败: {str(e)}") + return None + + def clear_update_info(self) -> bool: + """清除更新信息""" + try: + config_data = self._load_config() + if "last_check_update" in config_data: + del config_data["last_check_update"] + success = self._save_config(config_data) + if success: + logger.get_logger().info("更新信息已清除") + return success + return True + + except Exception as e: + logger.get_logger().error(f"清除更新信息失败: {str(e)}") + return False \ No newline at end of file diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..7636e75 --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +4.0.5 diff --git a/听泉助手v4.0.5.spec b/听泉助手v4.0.5.spec new file mode 100644 index 0000000..ffd0f12 --- /dev/null +++ b/听泉助手v4.0.5.spec @@ -0,0 +1,44 @@ +# -*- mode: python ; coding: utf-8 -*- +from PyInstaller.utils.hooks import collect_submodules + +hiddenimports = ['json', 'sqlite3', 'winreg', 'ctypes', 'platform', 'uuid', 'hashlib', 'datetime', 'urllib3', 'requests', 'PyQt5', 'PyQt5.sip', 'psutil', 'psutil._psutil_windows', 'psutil._pswindows'] +hiddenimports += collect_submodules('psutil') + + +a = Analysis( + ['tingquan_assistant.py'], + pathex=[], + binaries=[], + datas=[('version.txt', '.'), ('requirements.txt', '.'), ('two.ico', '.'), ('config.py', '.'), ('logger.py', '.'), ('common_utils.py', '.'), ('cursor_token_refresher.py', '.'), ('machine_resetter.py', '.'), ('update_disabler.py', '.'), ('exit_cursor.py', '.'), ('gui', 'gui'), ('services', 'services'), ('utils', 'utils')], + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=['_tkinter', 'tkinter', 'PIL.ImageTk', 'PIL.ImageWin', 'numpy', 'pandas', 'matplotlib', '__pycache__', '*.pyc', '*.pyo', '*.pyd'], + noarchive=False, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='听泉助手v4.0.5', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + version='file_version_info.txt', + uac_admin=True, + icon=['two.ico'], +)