diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..4837783 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,189 @@ +name: Build Multi-Platform Release + +on: + push: + tags: + - 'v*' # 当推送形如 v1.0.0 的 tag 时触发构建 + workflow_dispatch: # 支持手动触发构建 + +jobs: + # 1. 编译 Windows 端 (.exe) + build-windows: + runs-on: windows-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pyinstaller + + - name: Build EXE + run: | + python -m PyInstaller build.spec --clean + + - name: Upload Windows Artifact + uses: actions/upload-artifact@v4 + with: + name: CursorTokenLogin-Windows + path: dist/CursorTokenLogin.exe + + # 2. 编译 macOS 端 (.app) + build-macos: + runs-on: macos-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pyinstaller + + - name: Build App Bundle + run: | + python -m PyInstaller build.spec --clean + + - name: Zip macOS App + run: | + # macOS app 实质上是文件夹,发布前需要打包成 zip + cd dist + zip -r CursorTokenLogin-macOS.zip CursorTokenLogin.app + + - name: Upload macOS Artifact + uses: actions/upload-artifact@v4 + with: + name: CursorTokenLogin-macOS + path: dist/CursorTokenLogin-macOS.zip + + # 3. 编译 Linux 端并打包为 .deb 和 .rpm + build-linux: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pyinstaller + # 安装打包 deb 和 rpm 所需的系统工具 + sudo apt-get update + sudo apt-get install -y dpkg rpm alien + + - name: Build Linux Binary + run: | + python -m PyInstaller build.spec --clean + + - name: Pack DEB Package + run: | + # 创建 deb 包目录结构 + mkdir -p deb-package/DEBIAN + mkdir -p deb-package/usr/bin + mkdir -p deb-package/usr/share/applications + mkdir -p deb-package/usr/share/pixmaps + + # 拷贝编译好的二进制文件 + cp dist/CursorTokenLogin deb-package/usr/bin/cursortokenlogin + chmod +x deb-package/usr/bin/cursortokenlogin + + # 拷贝图标 + cp logo.png deb-package/usr/share/pixmaps/cursortokenlogin.png + + # 创建控制文件 + cat < deb-package/DEBIAN/control + Package: cursortokenlogin + Version: 1.0.0 + Section: utils + Priority: optional + Architecture: amd64 + Maintainer: Yunzer + Description: Cursor Token Login Helper + EOF + + # 创建桌面启动快捷方式 + cat < deb-package/usr/share/applications/cursortokenlogin.desktop + [Desktop Entry] + Name=CursorTokenLogin + Comment=Cursor Token Login Helper + Exec=/usr/bin/cursortokenlogin + Icon=cursortokenlogin + Terminal=false + Type=Application + Categories=Utility;Development; + EOF + + # 构建 deb + dpkg-deb --build deb-package cursortokenlogin.deb + + - name: Pack RPM Package (Convert from DEB using alien) + run: | + # 使用 alien 工具将 deb 快速转换为 rpm,避免编写复杂的 spec 构建脚本 + sudo alien --to-rpm --scripts cursortokenlogin.deb + # 重命名生成的 rpm 文件方便识别 + mv *.rpm cursortokenlogin.rpm + + - name: Upload Linux Artifacts + uses: actions/upload-artifact@v4 + with: + name: CursorTokenLogin-Linux + path: | + cursortokenlogin.deb + cursortokenlogin.rpm + + # 4. 自动创建 GitHub Release 并上传 4 个包 + create-release: + needs: [build-windows, build-macos, build-linux] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') # 仅在推送 tag 时执行 Release 发布 + steps: + - name: Download Windows Artifact + uses: actions/download-artifact@v4 + with: + name: CursorTokenLogin-Windows + path: ./release-files + + - name: Download macOS Artifact + uses: actions/download-artifact@v4 + with: + name: CursorTokenLogin-macOS + path: ./release-files + + - name: Download Linux Artifacts + uses: actions/download-artifact@v4 + with: + name: CursorTokenLogin-Linux + path: ./release-files + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: | + release-files/CursorTokenLogin.exe + release-files/CursorTokenLogin-macOS.zip + release-files/cursortokenlogin.deb + release-files/cursortokenlogin.rpm + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 6c899dd..f99b131 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,74 @@ -python -m PyInstaller -w -F main.py +# 多平台编译与打包指南 -
+在 Windows 系统下,由于 PyInstaller 的工作机制是直接打包当前平台的 Python 解析器和系统动态库,**它不支持交叉编译**(即在 Windows 本机无法编译出 macOS 的 `.app` 苹果程序,也无法直接打包 Linux 的 `.deb` 或 `.rpm` 安装包)。 -# 编译成exe +为了解决这个问题,并在一台电脑上实现 4 端程序(Windows `.exe`、macOS `.app`、Debian/Ubuntu `.deb`、RedHat/Fedora `.rpm`)的统一打包,我们已经在项目中配置了 **GitHub Actions 自动化流水线**。 + +--- + +## 🚀 推荐方案:使用 GitHub Actions 自动打包 4 端程序 + +我们已经在项目中创建了自动化打包脚本:[.github/workflows/build.yml](file:///.github/workflows/build.yml)。 + +您只需将代码托管在 GitHub 仓库中,GitHub 就会为您调用免费的 Windows、macOS 和 Linux 虚拟服务器,一键自动构建出所有安装包! + +### 操作步骤: +1. **将代码推送到您的 GitHub 仓库**。 +2. 当需要发布新版本时,在本地打上版本 tag 并推送: + ```bash + git tag v1.0.0 + git push origin v1.0.0 + ``` +3. GitHub Actions 会自动触发构建任务,并行启动三个环境: + - 💻 **Windows**:编译出 `CursorTokenLogin.exe` + - 🍏 **macOS**:编译出 `CursorTokenLogin-macOS.zip` (内含 `.app` 程序) + - 🐧 **Linux**:编译出 Linux 二进制,并分别打包为 `cursortokenlogin.deb` 和 `cursortokenlogin.rpm` +4. 构建完成后,GitHub 会自动创建一个 Release 版本,并将这 4 个编译好的安装包上传到 Release 中,您可以直接下载分发! + +--- + +## 🛠️ 本地手动构建说明(Windows/Linux/Mac) + +如果您不想使用 GitHub 托管,也可以在本地不同的系统环境中分别运行打包: + +### 1. 编译 Windows 程序 (`.exe`) +在 Windows 命令行中运行: +```bash +python -m PyInstaller build.spec --clean +``` + +### 2. 编译 macOS 程序 (`.app`) +在 macOS 终端中运行: +```bash +python -m PyInstaller build.spec --clean +``` +*(编译生成 `.app` 文件目录,拷贝分发前建议压缩为 `.zip`)* + +### 3. 编译 Linux 安装包 (`.deb` / `.rpm`) +在 Ubuntu / Debian 终端下,可以使用以下命令快速打包: +```bash +# 1. 编译 Linux 原生可执行文件 python -m PyInstaller build.spec --clean -
+# 2. 组织 DEB 包目录结构并打包 +mkdir -p deb-package/DEBIAN +mkdir -p deb-package/usr/bin +cp dist/CursorTokenLogin deb-package/usr/bin/cursortokenlogin +chmod +x deb-package/usr/bin/cursortokenlogin -# 关于 Win + Mac 同时构建(后续) -- PyInstaller 不能在 Windows 本机直接产出 macOS 程序。 -- 当前命令在 Windows 只能生成 `.exe`,在 macOS 才能生成 `.app`。 -- 后续可用 GitHub Actions 做双平台构建:本地打 Windows,云端 `macos-latest` 打 macOS。 +# 创建 control 配置文件 +cat < deb-package/DEBIAN/control +Package: cursortokenlogin +Version: 1.0.0 +Architecture: amd64 +Maintainer: Yunzer +Description: Cursor Token Login Helper +EOF + +# 生成 deb 安装包 +dpkg-deb --build deb-package cursortokenlogin.deb + +# 3. 使用 alien 将 deb 包转换为 rpm 包 +sudo apt-get install alien +sudo alien --to-rpm --scripts cursortokenlogin.deb +``` diff --git a/__pycache__/main.cpython-312.pyc b/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000..48deba4 Binary files /dev/null and b/__pycache__/main.cpython-312.pyc differ diff --git a/__pycache__/main.cpython-313.pyc b/__pycache__/main.cpython-313.pyc index 3d647d5..342e5d5 100644 Binary files a/__pycache__/main.cpython-313.pyc and b/__pycache__/main.cpython-313.pyc differ diff --git a/_inspect_state_vscdb.py b/_inspect_state_vscdb.py new file mode 100644 index 0000000..56ab755 --- /dev/null +++ b/_inspect_state_vscdb.py @@ -0,0 +1,58 @@ +import sqlite3 +from pathlib import Path + +p = Path("state.vscdb") +print("db_exists=", p.exists(), p.resolve()) + +conn = sqlite3.connect(p) +cur = conn.cursor() + +tables = cur.execute( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" +).fetchall() +print("tables=", tables) + +try: + schema = cur.execute("PRAGMA table_info(ItemTable)").fetchall() + print("ItemTable schema=", schema) + + rows = cur.execute( + """ + SELECT key, value + FROM ItemTable + WHERE lower(key) LIKE '%auth%' + OR lower(key) LIKE '%token%' + OR lower(key) LIKE '%cursoraccount%' + OR lower(key) LIKE '%cursor%' + ORDER BY key + """ + ).fetchall() + + print("matched_rows=", len(rows)) + for k, v in rows: + s = "" if v is None else str(v) + masked = (s[:18] + "..." + s[-12:]) if len(s) > 40 else s + print(f"{k} len={len(s)} value={masked}") + + print("\ncursorDiskKV schema=", cur.execute("PRAGMA table_info(cursorDiskKV)").fetchall()) + disk_rows = cur.execute( + """ + SELECT key, value + FROM cursorDiskKV + WHERE lower(key) LIKE '%auth%' + OR lower(key) LIKE '%token%' + OR lower(key) LIKE '%cursor%' + OR lower(key) LIKE '%account%' + ORDER BY key + """ + ).fetchall() + print("cursorDiskKV matched_rows=", len(disk_rows)) + for k, v in disk_rows: + if isinstance(v, bytes): + s = v.decode("utf-8", "ignore") + else: + s = "" if v is None else str(v) + masked = (s[:18] + "..." + s[-12:]) if len(s) > 40 else s + print(f"{k} len={len(s)} value={masked}") +finally: + conn.close() diff --git a/build.spec b/build.spec index 4d8bb2f..324522c 100644 --- a/build.spec +++ b/build.spec @@ -66,3 +66,12 @@ exe = EXE( # 必须用绝对路径;资源管理器若仍显示旧图标多半是 Windows 图标缓存,可改 exe 文件名或清 IconCache icon=_LOGO_ICO_ABS, ) + +import sys +if sys.platform == 'darwin': + app = BUNDLE( + exe, + name='CursorTokenLogin.app', + icon='logo.png', + bundle_identifier='com.yunzer.cursortokenlogin', + ) diff --git a/layout/main1.ui b/layout/main1.ui index 0c4e115..2e6cc8c 100644 --- a/layout/main1.ui +++ b/layout/main1.ui @@ -623,6 +623,11 @@ 关于软件 + + + 无感检测 + + @@ -826,6 +831,69 @@ + + + + + + 无感检测 + + + + + + 无感检测:不关闭 Cursor 程序,直接写入 Token 和自动生成的随机邮箱地址,方便快速检测 Token 是否可用。 + + + true + + + + + + + 请输入新 Token (兼容纯 token / Cookie 串): + + + + + + + 在此粘贴 Token,格式支持 Workos... 或原始 token + + + + + + + + 0 + 42 + + + + 无感换号 + + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + diff --git a/main.py b/main.py index d759d5e..d78e4a1 100644 --- a/main.py +++ b/main.py @@ -265,7 +265,7 @@ def get_cursor_config_path(): def update_vsdb_token(config_dir, new_token, new_email, log_callback): - """更新 Cursor 数据库中的token和email""" + """更新 Cursor 数据库中的 token / email / auth 缓存字段。""" db_path = config_dir / "state.vscdb" if not db_path.exists(): log_callback("⚠️ 未找到 Cursor库 文件,跳过") @@ -281,14 +281,27 @@ def update_vsdb_token(config_dir, new_token, new_email, log_callback): shutil.copy2(db_path, db_backup) # log_callback(f"📁 数据库已备份到: {db_backup}") - conn = sqlite3.connect(db_path) + # 使用 timeout=5 避免瞬间锁导致失败 + conn = sqlite3.connect(db_path, timeout=5.0) cursor = conn.cursor() - # 更新 token 和 email + # 强制合并并清除 WAL 缓存(切换为 DELETE 模式,这样会自动合并并删除 -wal 和 -shm 文件) + try: + cursor.execute("PRAGMA journal_mode=delete") + except Exception as wal_err: + log_callback(f"⚠️ 无法合并 WAL 缓存 (可能是 Cursor 进程未完全关闭): {wal_err}") + + # 当前 Cursor 版本主要从 ItemTable 的 cursorAuth/* 读取登录态。 + # 不要只写 accessToken/refreshToken;补齐已登录库里常见的认证缓存字段, + # 避免客户端启动时因为缓存状态不完整回落到 Log in。 + onboarding_date = datetime.utcnow().isoformat(timespec="milliseconds") + "Z" updates = [ ("cursorAuth/accessToken", new_token), ("cursorAuth/refreshToken", new_token), ("cursorAuth/cachedEmail", new_email), + ("cursorAuth/cachedSignUpType", "Auth_0"), + ("cursorAuth/onboardingDate", onboarding_date), + ("cursorAuth/stripeMembershipType", "pro"), ] for key, value in updates: @@ -312,8 +325,16 @@ def update_vsdb_token(config_dir, new_token, new_email, log_callback): # log_callback(f"✓ 插入了 {key}") conn.commit() + + # 恢复 WAL 模式以便 Cursor 正常工作 + try: + cursor.execute("PRAGMA journal_mode=wal") + conn.commit() + except Exception as wal_err: + log_callback(f"⚠️ 无法还原为 WAL 模式: {wal_err}") + conn.close() - log_callback("✅ Cursor库 更新成功") + log_callback(f"✅ Cursor库 更新成功,已同步 {len(updates)} 个认证字段") return True except Exception as e: @@ -687,10 +708,16 @@ class ChangeTokenThread(QThread): content = f.read() data = json.loads(content) if content.strip() else {} - # 显示原邮箱 - if "cursorAuth" in data and "cachedEmail" in data.get("cursorAuth", {}): - old_email = data["cursorAuth"]["cachedEmail"] + # 显示原邮箱。access token 本身通常不包含 email claim, + # 因此优先保留 Cursor 原缓存邮箱,避免写入随机邮箱导致登录缓存状态不一致。 + old_email = ( + data.get("cursorAuth", {}).get("cachedEmail") + or data.get("cursorAccount", {}).get("email") + or "" + ) + if old_email: self.log_signal.emit(f"📧 原邮箱: {old_email}") + self.new_email = str(old_email).strip() # 备份原文件(不显示日志) shutil.copy2(storage_file, backup_subdir / "storage.json.bak") @@ -702,6 +729,8 @@ class ChangeTokenThread(QThread): data["cursorAuth"]["accessToken"] = self.new_token data["cursorAuth"]["refreshToken"] = self.new_token data["cursorAuth"]["cachedEmail"] = self.new_email + data["cursorAuth"]["cachedSignUpType"] = data["cursorAuth"].get("cachedSignUpType", "Auth_0") + data["cursorAuth"]["onboardingDate"] = datetime.utcnow().isoformat(timespec="milliseconds") + "Z" data["cursorAuth"]["plan"] = "pro" data["cursorAuth"]["stripeMembershipType"] = "pro" data["cursorAuth"]["membershipType"] = "pro" @@ -733,7 +762,11 @@ class ChangeTokenThread(QThread): # 更新 Cursor 数据库 self.log_signal.emit("📦 更新 Cursor 数据库...") - update_vsdb_token(config_dir, self.new_token, self.new_email, self.log_signal.emit) + db_ok = update_vsdb_token(config_dir, self.new_token, self.new_email, self.log_signal.emit) + if not db_ok: + self.log_signal.emit("❌ 更新 Cursor 数据库失败,换号未完成!") + self.finished_signal.emit(False, "更新 Cursor 数据库失败,可能是 Cursor 进程未完全关闭,请在任务管理器中结束所有 Cursor 进程,或尝试以管理员身份运行本软件。") + return self.log_signal.emit("✅ 换号完成!") self.finished_signal.emit(True, str(backup_subdir)) @@ -743,6 +776,312 @@ class ChangeTokenThread(QThread): self.finished_signal.emit(False, str(e)) +class OneClickRenewalThread(QThread): + log_signal = Signal(str) + finished_signal = Signal(bool, str, str) # (success, message/backup_path, new_email) + + def run(self): + try: + self.log_signal.emit("🔄 正在从服务端获取账号...") + url = "https://api.yunzer.cn/api/getcard?type=local&module=cursor&data_type=tk" + + response_data = None + try: + response = requests.get(url, timeout=15) + response_data = response.text.strip() + except requests.exceptions.ProxyError: + with requests.Session() as session: + session.trust_env = False + response = session.get(url, timeout=15) + response_data = response.text.strip() + + if not response_data or not response_data.startswith("ey"): + try: + err_json = json.loads(response_data) + msg = err_json.get("msg") or err_json.get("message") or response_data + except Exception: + msg = response_data + self.finished_signal.emit(False, f"获取账号 Token 失败: {msg[:200]}", "") + return + + self.log_signal.emit("✅ 成功提取 Token!正在执行本地更换...") + new_token = response_data + new_email = generate_random_email() + + config_dir = get_cursor_config_path() + if not config_dir.exists(): + self.finished_signal.emit(False, "未找到Cursor配置目录", "") + return + + backup_dir = config_dir / "backup" + backup_dir.mkdir(exist_ok=True) + timestamp = random.randint(100000, 999999) + backup_subdir = backup_dir / f"backup_{timestamp}" + backup_subdir.mkdir(exist_ok=True) + + storage_file = config_dir / "storage.json" + if not storage_file.exists(): + self.finished_signal.emit(False, "未找到 storage.json 文件", "") + return + + if not os.access(storage_file, os.W_OK): + self.log_signal.emit("🔓 检测到文件只读,正在解除只读属性...") + import stat + storage_file.chmod(storage_file.stat().st_mode | stat.S_IWRITE) + + self.log_signal.emit("📖 读取配置文件...") + with open(storage_file, "r", encoding="utf-8") as f: + content = f.read() + data = json.loads(content) if content.strip() else {} + + old_email = ( + data.get("cursorAuth", {}).get("cachedEmail") + or data.get("cursorAccount", {}).get("email") + or "" + ) + if old_email: + self.log_signal.emit(f"📧 原邮箱: {old_email}") + new_email = str(old_email).strip() + + shutil.copy2(storage_file, backup_subdir / "storage.json.bak") + + self.log_signal.emit("🔑 替换 cursorAuth...") + if "cursorAuth" not in data: + data["cursorAuth"] = {} + data["cursorAuth"]["accessToken"] = new_token + data["cursorAuth"]["refreshToken"] = new_token + data["cursorAuth"]["cachedEmail"] = new_email + data["cursorAuth"]["cachedSignUpType"] = data["cursorAuth"].get("cachedSignUpType", "Auth_0") + data["cursorAuth"]["onboardingDate"] = datetime.utcnow().isoformat(timespec="milliseconds") + "Z" + data["cursorAuth"]["plan"] = "pro" + data["cursorAuth"]["stripeMembershipType"] = "pro" + data["cursorAuth"]["membershipType"] = "pro" + + self.log_signal.emit("🔑 替换 cursorAccount...") + if "cursorAccount" not in data: + data["cursorAccount"] = {} + data["cursorAccount"]["token"] = new_token + data["cursorAccount"]["email"] = new_email + data["cursorAccount"]["plan"] = "pro" + + self.log_signal.emit("🔧 刷新机器ID...") + new_machine_id = generate_machine_id() + data["telemetryMacMachineId"] = new_machine_id + data["telemetryDevDeviceId"] = new_machine_id + data["workspaceIdentifier"] = new_machine_id + data["membershipType"] = "pro" + + self.log_signal.emit(f"📧 新邮箱: {new_email}") + + self.log_signal.emit("💾 保存配置文件...") + with open(storage_file, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + self.log_signal.emit("📦 更新 Cursor 数据库...") + db_ok = update_vsdb_token(config_dir, new_token, new_email, self.log_signal.emit) + if not db_ok: + self.log_signal.emit("❌ 更新 Cursor 数据库失败,换号未完成!") + self.finished_signal.emit(False, "更新 Cursor 数据库失败,可能是 Cursor 进程未完全关闭,请在任务管理器中结束所有 Cursor 进程,或尝试以管理员身份运行本软件。", "") + return + + self.log_signal.emit("✅ 一键续杯换号完成!") + self.finished_signal.emit(True, str(backup_subdir), new_email) + + except Exception as e: + self.log_signal.emit(f"❌ 续杯换号失败: {str(e)}") + self.finished_signal.emit(False, str(e), "") + + +def get_device_info() -> str: + """获取 CPU/RAM/磁盘等设备信息""" + try: + cpu_name = _get_cpu_id() + + virtual_mem = psutil.virtual_memory() + total_ram_gb = round(virtual_mem.total / (1024 ** 3), 2) + + try: + disk_path = "C:\\" if sys.platform == "win32" else "/" + disk_usage = psutil.disk_usage(disk_path) + total_disk_gb = round(disk_usage.total / (1024 ** 3), 2) + free_disk_gb = round(disk_usage.free / (1024 ** 3), 2) + disk_str = f"Disk: {total_disk_gb} GB (Free: {free_disk_gb} GB)" + except Exception: + disk_str = "Disk: 获取失败" + + cpu_count = psutil.cpu_count(logical=False) + cpu_logical = psutil.cpu_count(logical=True) + + info = f"CPU: {cpu_name} ({cpu_count}核/{cpu_logical}线程) | RAM: {total_ram_gb} GB | {disk_str}" + return info + except Exception as e: + return f"获取设备信息失败: {str(e)}" + + +def read_current_cursor_email_helper() -> str: + """从 Cursor 的 storage.json 读取当前 email。""" + try: + config_dir = get_cursor_config_path() + storage_file = config_dir / "storage.json" + if not storage_file.exists(): + return "" + with open(storage_file, "r", encoding="utf-8") as f: + content = f.read() + data = json.loads(content) if content.strip() else {} + email = ( + data.get("cursorAuth", {}).get("cachedEmail") + or data.get("cursorAccount", {}).get("email") + or "" + ) + return str(email).strip() + except Exception: + return "" + + +def report_equipment_data(bind_account=None): + """上报设备数据,返回 (data, error) 元组""" + try: + url = "https://api.yunzer.cn/api/cursor/equipment/report" + machine_code = get_renewal_device_id() + device_info = get_device_info() + system = "Windows" if sys.platform == "win32" else ("macOS" if sys.platform == "darwin" else "Linux") + version = __VERSION__ + + if not bind_account: + bind_account = read_current_cursor_email_helper() + + payload = { + "machineCode": machine_code, + "deviceInfo": device_info, + "system": system, + "version": version, + "bindAccount": bind_account, + "remark": "登录器上报" + } + + headers = { + "Content-Type": "application/json" + } + + response_data = None + # 优先走代理 fallback + try: + with requests.post(url, json=payload, headers=headers, timeout=10) as response: + response.raise_for_status() + response_data = response.json() + except requests.exceptions.ProxyError: + with requests.Session() as session: + session.trust_env = False + with session.post(url, json=payload, headers=headers, timeout=10) as response: + response.raise_for_status() + response_data = response.json() + + if response_data and response_data.get("code") == 200: + return (response_data.get("data", {}), None) + elif response_data: + return (None, response_data.get("msg", "未知错误")) + else: + return (None, "服务器未返回有效数据") + + except requests.exceptions.Timeout: + return (None, "连接超时,请检查网络") + except requests.exceptions.ConnectionError as e: + return (None, f"网络连接失败: {str(e)}") + except requests.exceptions.RequestException as e: + return (None, f"请求失败: {str(e)}") + except Exception as e: + return (None, f"数据上报失败: {str(e)}") + + +class ReportEquipmentThread(QThread): + """上报设备信息线程""" + finished_signal = Signal(bool, dict, str) + + def __init__(self, bind_account=None): + super().__init__() + self.bind_account = bind_account + + def run(self): + data, error = report_equipment_data(self.bind_account) + if error: + self.finished_signal.emit(False, {}, error) + else: + self.finished_signal.emit(True, data, "") + + +def activate_by_code(activation_code, bind_account=None): + """ + 使用激活码激活设备,返回 (data, error) 元组 + """ + try: + url = "https://api.yunzer.cn/api/cursor/equipment/activateByCode" + machine_code = get_renewal_device_id() + device_info = get_device_info() + system = "Windows" if sys.platform == "win32" else ("macOS" if sys.platform == "darwin" else "Linux") + version = __VERSION__ + + if not bind_account: + bind_account = read_current_cursor_email_helper() + + payload = { + "activationCode": activation_code, + "machineCode": machine_code, + "deviceInfo": device_info, + "system": system, + "version": version, + "bindAccount": bind_account, + "remark": "登录器激活" + } + + headers = { + "Content-Type": "application/json" + } + + response_data = None + # 优先走代理 fallback + try: + with requests.post(url, json=payload, headers=headers, timeout=10) as response: + response_data = response.json() + except requests.exceptions.ProxyError: + with requests.Session() as session: + session.trust_env = False + with session.post(url, json=payload, headers=headers, timeout=10) as response: + response_data = response.json() + + if response_data and response_data.get("code") == 200: + return (response_data.get("data", {}), None) + elif response_data: + return (None, response_data.get("msg", "未知错误")) + else: + return (None, "服务器未返回有效数据") + + except requests.exceptions.Timeout: + return (None, "连接超时,请检查网络") + except requests.exceptions.ConnectionError as e: + return (None, f"网络连接失败: {str(e)}") + except requests.exceptions.RequestException as e: + return (None, f"请求失败: {str(e)}") + except Exception as e: + return (None, f"激活失败: {str(e)}") + + +class ActivateCodeThread(QThread): + """激活码激活线程""" + finished_signal = Signal(bool, dict, str) + + def __init__(self, activation_code, bind_account=None): + super().__init__() + self.activation_code = activation_code + self.bind_account = bind_account + + def run(self): + data, error = activate_by_code(self.activation_code, self.bind_account) + if error: + self.finished_signal.emit(False, {}, error) + else: + self.finished_signal.emit(True, data, "") + + class CheckUpdateThread(QThread): """检查更新线程""" update_available = Signal(dict) @@ -889,6 +1228,7 @@ class MainWindow(QMainWindow): self.cursor_path = get_default_cursor_path() self.backup_path = "" + self.member_status = 0 self._update_download_thread = None self._emergency_dialog = None self._usage_guide_dialog = None @@ -994,7 +1334,7 @@ class MainWindow(QMainWindow): if self.btnBuyActivationCode: self.btnBuyActivationCode.clicked.connect(self.on_open_online_shop_clicked) if self.btnRefreshMemberStatus: - self.btnRefreshMemberStatus.clicked.connect(self.on_refresh_member_status_clicked) + self.btnRefreshMemberStatus.clicked.connect(lambda: self.on_refresh_member_status_clicked(show_popup=True)) if self.btnOneClickRenewal: self.btnOneClickRenewal.clicked.connect(self.on_one_click_renewal_clicked) if self.actionExit: @@ -1030,6 +1370,9 @@ class MainWindow(QMainWindow): self.check_update_thread.error.connect(self.on_update_error) self.check_update_thread.start() + # 启动设备上报并同步状态 + self.on_refresh_member_status_clicked(show_popup=False) + def _restore_check_update_btn(self): if self.btnCheckUpdate: self.btnCheckUpdate.setEnabled(True) @@ -1394,10 +1737,64 @@ class MainWindow(QMainWindow): if not activation_code: QMessageBox.warning(self, "提示", "请输入激活码。") return - self.log(f"🔑 正在激活一键续杯,设备号:{device_id}") - self.log("ℹ️ 激活码已提交,请根据服务端接口补充实际激活逻辑。") - QMessageBox.information(self, "提示", "激活码已提交,当前版本尚未配置服务端激活接口。") - self.on_refresh_member_status_clicked() + + self.log(f"🔑 正在激活,设备号:{device_id},激活码:{activation_code}") + + if self.btnActivateRenewal: + self.btnActivateRenewal.setEnabled(False) + self.btnActivateRenewal.setText("🔄 激活中...") + + self._activate_thread = ActivateCodeThread(activation_code) + self._activate_thread.finished_signal.connect(self.on_activate_finished) + self._activate_thread.start() + + def on_activate_finished(self, success, data, error_msg): + if self.btnActivateRenewal: + self.btnActivateRenewal.setEnabled(True) + self.btnActivateRenewal.setText("🔑 提交激活码") + + if success: + self.log("✅ 激活成功!已成功同步有效期及状态") + + status = data.get("status", 1) + self.member_status = status + status_text = "已激活" if status == 1 else "已过期" + + if self.lblMemberStatus: + self.lblMemberStatus.setText(f"当前状态:{status_text}") + if self.lblMemberLevel: + self.lblMemberLevel.setText(status_text) + + act_time = data.get("activatedAt") or data.get("activationAt") or "-" + exp_time = data.get("expireTime") or data.get("expiredAt") or "-" + + def format_time_str(t_str): + if not t_str or t_str == "-": + return "-" + t_str = str(t_str).replace("T", " ") + if "+" in t_str: + t_str = t_str.split("+")[0] + if "Z" in t_str: + t_str = t_str.replace("Z", "") + if "." in t_str: + t_str = t_str.split(".")[0] + return t_str.strip() + + if self.lblActivatedAt: + self.lblActivatedAt.setText(format_time_str(act_time)) + if self.lblExpiredAt: + self.lblExpiredAt.setText(format_time_str(exp_time)) + + duration = data.get("durationDays", 0) + self.log(f"🎉 激活成功!增加天数:{duration} 天,到期时间:{format_time_str(exp_time)}") + QMessageBox.information( + self, + "激活成功", + f"激活码校验通过!\n绑定机器码成功,已续期 {duration} 天。\n到期时间:{format_time_str(exp_time)}" + ) + else: + self.log(f"❌ 激活失败: {error_msg}") + QMessageBox.critical(self, "激活失败", f"无法激活设备:{error_msg}") def _reset_member_status_display(self): """重置会员状态展示。""" @@ -1412,33 +1809,124 @@ class MainWindow(QMainWindow): if self.lblExpiredAt: self.lblExpiredAt.setText("-") - def on_refresh_member_status_clicked(self): + def start_equipment_report(self, bind_account=None, show_popup=False): + """开始上报设备信息""" + self._report_thread = ReportEquipmentThread(bind_account) + self._report_thread.finished_signal.connect( + lambda success, data, error_msg: self.on_equipment_report_finished(success, data, error_msg, show_popup) + ) + self._report_thread.start() + + def on_equipment_report_finished(self, success, data, error_msg, show_popup=False): + if success: + # self.log("✅ 设备数据已成功上报到平台") + status = data.get("status", 0) + self.member_status = status + status_text = "未激活" + if status == 1: + status_text = "已激活" + elif status == 2: + status_text = "已过期" + elif status == 3: + status_text = "已禁用" + + if self.lblMemberStatus: + self.lblMemberStatus.setText(f"当前状态:{status_text}") + if self.lblMemberLevel: + self.lblMemberLevel.setText(status_text) + + act_time = data.get("activationTime") or data.get("activation_time") or "-" + exp_time = data.get("expireTime") or data.get("expire_time") or "-" + + def format_time_str(t_str): + if not t_str or t_str == "-": + return "-" + t_str = str(t_str).replace("T", " ") + if "+" in t_str: + t_str = t_str.split("+")[0] + if "Z" in t_str: + t_str = t_str.replace("Z", "") + if "." in t_str: + t_str = t_str.split(".")[0] + return t_str.strip() + + if self.lblActivatedAt: + self.lblActivatedAt.setText(format_time_str(act_time)) + if self.lblExpiredAt: + self.lblExpiredAt.setText(format_time_str(exp_time)) + + owner_name = data.get("ownerUserName") or data.get("owner_user_name") + if owner_name: + if self.lblAccountType: + self.lblAccountType.setText(f"绑定用户:{owner_name}") + else: + if self.lblAccountType: + self.lblAccountType.setText("普通设备") + + if show_popup: + QMessageBox.information(self, "刷新成功", f"会员状态已同步!当前状态:{status_text}") + else: + self.log(f"⚠️ 设备数据同步失败: {error_msg}") + if show_popup: + QMessageBox.warning(self, "刷新失败", f"无法同步设备状态:{error_msg}") + + def on_refresh_member_status_clicked(self, show_popup=False): """刷新会员状态展示。""" device_id = self.txtDeviceId.text().strip() if self.txtDeviceId else "" if not device_id: - QMessageBox.warning(self, "提示", "设备号为空,无法刷新会员状态。") + if show_popup: + QMessageBox.warning(self, "提示", "设备号为空,无法刷新会员状态。") return if self.lblMemberStatus: - self.lblMemberStatus.setText("当前状态:待接入服务端") - if self.lblMemberLevel: - self.lblMemberLevel.setText("未激活") - if self.lblAccountType: - self.lblAccountType.setText("普通账号") - if self.lblActivatedAt: - self.lblActivatedAt.setText("-") - if self.lblExpiredAt: - self.lblExpiredAt.setText("-") + self.lblMemberStatus.setText("当前状态:正在同步平台...") - self.log("🔄 已刷新会员状态展示,当前版本尚未配置服务端查询接口。") + self.log("🔄 正在从平台同步设备与会员状态...") + self.start_equipment_report(show_popup=show_popup) def on_one_click_renewal_clicked(self): """执行一键续杯。""" - device_id = self.txtDeviceId.text().strip() if self.txtDeviceId else "" - if not device_id: - QMessageBox.warning(self, "提示", "设备号为空,无法执行一键续杯。") + # 1. 检测是否激活 + is_active = False + if hasattr(self, 'member_status') and self.member_status == 1: + is_active = True + elif self.lblMemberStatus and "已激活" in self.lblMemberStatus.text(): + is_active = True + + if not is_active: + QMessageBox.warning(self, "提示", "本设备未激活,请输入激活码激活。") + self.log("⚠️ 续杯失败:本设备未激活,请输入激活码激活。") return + # 2. 检测 Cursor 是否正在运行 + if is_cursor_running(): + msg_box = QMessageBox(self) + msg_box.setWindowTitle("Cursor正在运行") + msg_box.setText("Cursor正在运行中!\n请先保存代码并手动关闭Cursor。") + msg_box.setIcon(QMessageBox.Warning) + btn_close = msg_box.addButton("💀 强制关闭", QMessageBox.ActionRole) + btn_cancel = msg_box.addButton("取消", QMessageBox.RejectRole) + msg_box.setDefaultButton(btn_cancel) + msg_box.exec_() + + if msg_box.clickedButton() == btn_close: + self.log("💀 正在强制关闭Cursor...") + if kill_cursor(): + self.log("✅ Cursor已关闭") + else: + self.log("⚠️ 未找到运行中的Cursor进程") + QMessageBox.warning(self, "警告", "未找到运行中的Cursor进程") + return + else: + return + + # 3. 检查 Cursor 路径 + cursor_path = self.txtCursorPath.text().strip() if self.txtCursorPath else "" + if not cursor_path or not Path(cursor_path).exists(): + QMessageBox.warning(self, "警告", "请设置正确的Cursor路径!") + return + + # 4. 再次确认续杯 confirm = QMessageBox.question( self, "确认一键续杯", @@ -1452,10 +1940,35 @@ class MainWindow(QMainWindow): self.log("ℹ️ 已取消一键续杯。") return - self.log(f"☕ 正在执行一键续杯,设备号:{device_id}") - self.log("ℹ️ 一键续杯请求已提交,请根据服务端接口补充实际续杯逻辑。") - QMessageBox.information(self, "提示", "一键续杯请求已提交,当前版本尚未配置服务端续杯接口。") - self.on_refresh_member_status_clicked() + # 5. 禁用按钮并启动续杯换号后台线程 + if self.btnOneClickRenewal: + self.btnOneClickRenewal.setEnabled(False) + self.btnOneClickRenewal.setText("🔄 续杯中...") + + self._renewal_thread = OneClickRenewalThread() + self._renewal_thread.log_signal.connect(self.log) + self._renewal_thread.finished_signal.connect(self.on_one_click_renewal_finished) + self._renewal_thread.start() + + @Slot(bool, str, str) + def on_one_click_renewal_finished(self, success, message, new_email): + if self.btnOneClickRenewal: + self.btnOneClickRenewal.setEnabled(True) + self.btnOneClickRenewal.setText("一键续杯") + + if success: + self.backup_path = message + self.log("✅ 一键续杯完成") + + # 上报新换号绑定的设备信息(使用生成的新 email) + self.start_equipment_report(bind_account=new_email) + + self._show_change_success_countdown_dialog(4) + self.log("🚀 正在打开Cursor...") + cursor_path = self.txtCursorPath.text().strip() if self.txtCursorPath else "" + self._launch_cursor(cursor_path) + else: + QMessageBox.critical(self, "失败", message) def _extract_session_token(self, raw_value: str) -> str: """从输入文本中提取 SessionToken,兼容纯 token / Cookie 串。""" @@ -1513,7 +2026,8 @@ class MainWindow(QMainWindow): dialog.exec() def on_change_clicked(self): - token = self.txtToken.toPlainText().strip() if self.txtToken else "" + raw_token = self.txtToken.toPlainText().strip() if self.txtToken else "" + token = self._extract_session_token(raw_token) cursor_path = self.txtCursorPath.text().strip() if self.txtCursorPath else "" if not token: @@ -1943,6 +2457,10 @@ class MainWindow(QMainWindow): if success: self.backup_path = message self.log("✅ 换号完成") + + # 上报新换号绑定的设备信息(使用生成的新 email) + self.start_equipment_report(bind_account=self.thread.new_email) + self._show_change_success_countdown_dialog(4) self.log("🚀 正在打开Cursor...") cursor_path = self.txtCursorPath.text().strip() if self.txtCursorPath else "" diff --git a/main.spec b/main.spec index ee499fc..e5b83ad 100644 --- a/main.spec +++ b/main.spec @@ -1,29 +1,11 @@ # -*- mode: python ; coding: utf-8 -*- -import os -try: - _SPEC_ROOT = os.path.dirname(os.path.abspath(SPEC)) -except NameError: - _SPEC_ROOT = os.getcwd() -_LOGO_ICO = os.path.normpath(os.path.join(_SPEC_ROOT, "logo.ico")) -if not os.path.isfile(_LOGO_ICO): - raise FileNotFoundError( - "找不到图标文件: %s\n请将 logo.ico 放在与 main.spec 同一目录后重新打包。" % _LOGO_ICO - ) -_LOGO_ICO_ABS = os.path.abspath(_LOGO_ICO) - -# 须与 build.spec 一致,否则 onefile 内无 layout/main.ui,会出现「找不到 UI 文件」 -_datas = [ - (os.path.join(os.getcwd(), 'layout'), 'layout'), - (os.path.join(os.getcwd(), 'assets'), 'assets'), - (_LOGO_ICO_ABS, "."), -] a = Analysis( ['main.py'], - pathex=[os.getcwd()], + pathex=[], binaries=[], - datas=_datas, + datas=[], hiddenimports=[], hookspath=[], hooksconfig={}, @@ -44,14 +26,14 @@ exe = EXE( debug=False, bootloader_ignore_signals=False, strip=False, - upx=False, + upx=True, upx_exclude=[], - runtime_tmpdir=r'%LOCALAPPDATA%\CursorTokenLogin\runtime', + runtime_tmpdir=None, console=False, disable_windowed_traceback=False, argv_emulation=False, target_arch=None, codesign_identity=None, entitlements_file=None, - icon=_LOGO_ICO_ABS, + icon=['logo.ico'], ) diff --git a/state.vscdb b/state.vscdb new file mode 100644 index 0000000..2ca47d0 Binary files /dev/null and b/state.vscdb differ diff --git a/state1.vscdb b/state1.vscdb new file mode 100644 index 0000000..3f263e3 Binary files /dev/null and b/state1.vscdb differ diff --git a/state_test.vscdb b/state_test.vscdb new file mode 100644 index 0000000..a26fcc2 Binary files /dev/null and b/state_test.vscdb differ