Compare commits

...

3 Commits

12 changed files with 961 additions and 65 deletions

189
.github/workflows/build.yml vendored Normal file
View File

@ -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: ${{ matrix.os }}
strategy:
matrix:
os: [macos-13, 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 Pillow
- name: Set Architecture Name
run: |
if [ "${{ matrix.os }}" = "macos-13" ]; then
echo "ARCH_NAME=Intel" >> $GITHUB_ENV
else
echo "ARCH_NAME=AppleSilicon" >> $GITHUB_ENV
fi
shell: bash
- name: Build App Bundle
run: |
python -m PyInstaller build.spec --clean
- name: Zip macOS App
run: |
cd dist
zip -r CursorTokenLogin-macOS-${{ env.ARCH_NAME }}.zip CursorTokenLogin.app
- name: Upload macOS Artifact
uses: actions/upload-artifact@v4
with:
name: CursorTokenLogin-macOS-${{ env.ARCH_NAME }}
path: dist/CursorTokenLogin-macOS-${{ env.ARCH_NAME }}.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 <<EOF > 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 <<EOF > 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 并上传 5 个包
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 All Artifacts
uses: actions/download-artifact@v4
with:
path: ./release-files
merge-multiple: true
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: |
release-files/CursorTokenLogin.exe
release-files/CursorTokenLogin-macOS-Intel.zip
release-files/CursorTokenLogin-macOS-AppleSilicon.zip
release-files/cursortokenlogin.deb
release-files/cursortokenlogin.rpm
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -1,13 +1,85 @@
python -m PyInstaller -w -F main.py
# 多平台编译与打包指南
<br />
在 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 仓库** (在本地项目终端运行)
```bash
# 关联远程 GitHub 仓库,并将其命名为 github (实现双仓库并存)
git remote add github https://github.com/hero920103/cursorlogin.git
# 将本地的 master 分支推送到 GitHub
git push -u github master
```
2. **推送 Tag 触发 4 端自动打包** (后续每次需要打包发布时,直接运行下面两行)
```bash
# 1. 本地打上版本 tag
git tag vx.x.x
# 2. 将 tag 单独推送到 github 触发云端自动构建
git push github vx.x.x
```
3. GitHub Actions 收到 Tag 后会自动触发构建,云端会启动多个编译任务,最终产出:
- 💻 **Windows**:编译出 `CursorTokenLogin.exe`
- 🍏 **macOS (M1/M2/M3 芯片)**:编译出 `CursorTokenLogin-macOS-AppleSilicon.zip` (解压即得 `.app`)
- 🍏 **macOS (Intel 芯片)**:编译出 `CursorTokenLogin-macOS-Intel.zip` (解压即得 `.app`)
- 🐧 **Linux**:编译出 Linux 运行文件,并自动打包为 `cursortokenlogin.deb``cursortokenlogin.rpm`
4. **下载成品**:构建完成后(耗时约 3~5 分钟GitHub 会自动在您的仓库右侧 **Releases** 栏目中生成一个名为 `v1.0.0` 的发布页,进入即可直接下载编译好的 5 个安装包!
---
## 🛠️ 本地手动构建说明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
<br />
# 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 <<EOF > 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
```

Binary file not shown.

Binary file not shown.

58
_inspect_state_vscdb.py Normal file
View File

@ -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()

View File

@ -13,7 +13,7 @@ if not os.path.isfile(_LOGO_ICO):
": %s\n logo.ico build.spec " % _LOGO_ICO
)
_LOGO_ICO_ABS = os.path.abspath(_LOGO_ICO)
print("[build.spec] EXE :", _LOGO_ICO_ABS)
print("[build.spec] EXE icon path:", _LOGO_ICO_ABS)
block_cipher = None
@ -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',
)

View File

@ -623,6 +623,11 @@
<string>关于软件</string>
</property>
</item>
<item>
<property name="text">
<string>无感检测</string>
</property>
</item>
</widget>
</item>
<item>
@ -826,6 +831,69 @@
</item>
</layout>
</widget>
<widget class="QWidget" name="pageHelpSilentDetect">
<layout class="QVBoxLayout" name="verticalLayout_helpSilentDetect">
<item>
<widget class="QGroupBox" name="groupHelpSilentDetect">
<property name="title">
<string>无感检测</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_helpSilentDetectContent">
<item>
<widget class="QLabel" name="lblHelpSilentDetectDesc">
<property name="text">
<string>无感检测:不关闭 Cursor 程序,直接写入 Token 和自动生成的随机邮箱地址,方便快速检测 Token 是否可用。</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="lblSilentToken">
<property name="text">
<string>请输入新 Token (兼容纯 token / Cookie 串)</string>
</property>
</widget>
</item>
<item>
<widget class="QTextEdit" name="txtSilentToken">
<property name="placeholderText">
<string>在此粘贴 Token格式支持 Workos... 或原始 token</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="btnSilentChange">
<property name="minimumSize">
<size>
<width>0</width>
<height>42</height>
</size>
</property>
<property name="text">
<string>无感换号</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="verticalSpacer_helpSilentDetect">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</widget>
</item>
</layout>

582
main.py
View File

@ -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:
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 ""

View File

@ -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'],
)

BIN
state.vscdb Normal file

Binary file not shown.

BIN
state1.vscdb Normal file

Binary file not shown.

BIN
state_test.vscdb Normal file

Binary file not shown.