蜂鸟Pro v2.0.1 - 基础框架版本 (待完善)

## 当前状态
- 插件界面已完成重命名 (cursorpro → hummingbird)
- 双账号池 UI 已实现 (Auto/Pro 卡片)
- 后端已切换到 MySQL 数据库
- 添加了 Cursor 官方用量 API 文档

## 已知问题 (待修复)
1. 激活时检查账号导致无账号时激活失败
2. 未启用无感换号时不应获取账号
3. 账号用量模块不显示 (seamless 未启用时应隐藏)
4. 积分显示为 0 (后端未正确返回)
5. Auto/Pro 双密钥逻辑混乱,状态不同步
6. 账号添加后无自动分析功能

## 下一版本计划
- 重构数据模型,优化账号状态管理
- 实现 Cursor API 自动分析账号
- 修复激活流程,不依赖账号
- 启用无感时才分配账号
- 完善账号用量实时显示

## 文件说明
- docs/系统设计文档.md - 完整架构设计
- cursor 官方用量接口.md - Cursor API 文档
- 参考计费/ - Vibeviewer 开源项目参考

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
ccdojox-crypto
2025-12-18 11:21:52 +08:00
parent f310ca7b97
commit 73a71f198f
202 changed files with 19142 additions and 252 deletions

View File

@@ -0,0 +1,42 @@
{
"originHash" : "96a5b396a796a589b3f9c8f01a168bba37961921fe4ecfafe1b8e1f5c5a26ef8",
"pins" : [
{
"identity" : "alamofire",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Alamofire/Alamofire.git",
"state" : {
"revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5",
"version" : "5.10.2"
}
},
{
"identity" : "moya",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Moya/Moya.git",
"state" : {
"revision" : "c263811c1f3dbf002be9bd83107f7cdc38992b26",
"version" : "15.0.3"
}
},
{
"identity" : "reactiveswift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ReactiveCocoa/ReactiveSwift.git",
"state" : {
"revision" : "c43bae3dac73fdd3cb906bd5a1914686ca71ed3c",
"version" : "6.7.0"
}
},
{
"identity" : "rxswift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ReactiveX/RxSwift.git",
"state" : {
"revision" : "5dd1907d64f0d36f158f61a466bab75067224893",
"version" : "6.9.0"
}
}
],
"version" : 3
}

View File

@@ -0,0 +1,42 @@
// swift-tools-version: 5.10
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "VibeviewerAppEnvironment",
platforms: [
.macOS(.v14)
],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "VibeviewerAppEnvironment",
targets: ["VibeviewerAppEnvironment"]
)
],
dependencies: [
.package(path: "../VibeviewerAPI"),
.package(path: "../VibeviewerModel"),
.package(path: "../VibeviewerStorage"),
.package(path: "../VibeviewerCore"),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "VibeviewerAppEnvironment",
dependencies: [
"VibeviewerAPI",
"VibeviewerModel",
"VibeviewerStorage",
"VibeviewerCore",
]
),
.testTarget(
name: "VibeviewerAppEnvironmentTests",
dependencies: ["VibeviewerAppEnvironment"],
path: "Tests/VibeviewerAppEnvironmentTests"
),
]
)

View File

@@ -0,0 +1,13 @@
import SwiftUI
import VibeviewerAPI
private struct CursorServiceKey: EnvironmentKey {
static let defaultValue: CursorService = DefaultCursorService()
}
public extension EnvironmentValues {
var cursorService: CursorService {
get { self[CursorServiceKey.self] }
set { self[CursorServiceKey.self] = newValue }
}
}

View File

@@ -0,0 +1,13 @@
import SwiftUI
import VibeviewerStorage
private struct CursorStorageKey: EnvironmentKey {
static let defaultValue: any CursorStorageService = DefaultCursorStorageService()
}
public extension EnvironmentValues {
var cursorStorage: any CursorStorageService {
get { self[CursorStorageKey.self] }
set { self[CursorStorageKey.self] = newValue }
}
}

View File

@@ -0,0 +1,23 @@
import SwiftUI
private struct DashboardRefreshServiceKey: EnvironmentKey {
static let defaultValue: any DashboardRefreshService = NoopDashboardRefreshService()
}
private struct ScreenPowerStateServiceKey: EnvironmentKey {
static let defaultValue: any ScreenPowerStateService = NoopScreenPowerStateService()
}
public extension EnvironmentValues {
var dashboardRefreshService: any DashboardRefreshService {
get { self[DashboardRefreshServiceKey.self] }
set { self[DashboardRefreshServiceKey.self] = newValue }
}
var screenPowerStateService: any ScreenPowerStateService {
get { self[ScreenPowerStateServiceKey.self] }
set { self[ScreenPowerStateServiceKey.self] = newValue }
}
}

View File

@@ -0,0 +1,13 @@
import SwiftUI
import VibeviewerCore
private struct LaunchAtLoginServiceKey: EnvironmentKey {
static let defaultValue: any LaunchAtLoginService = DefaultLaunchAtLoginService()
}
public extension EnvironmentValues {
var launchAtLoginService: any LaunchAtLoginService {
get { self[LaunchAtLoginServiceKey.self] }
set { self[LaunchAtLoginServiceKey.self] = newValue }
}
}

View File

@@ -0,0 +1,14 @@
import SwiftUI
private struct LoginServiceKey: EnvironmentKey {
static let defaultValue: any LoginService = NoopLoginService()
}
public extension EnvironmentValues {
var loginService: any LoginService {
get { self[LoginServiceKey.self] }
set { self[LoginServiceKey.self] = newValue }
}
}

View File

@@ -0,0 +1,13 @@
import SwiftUI
private struct UpdateServiceKey: EnvironmentKey {
static let defaultValue: any UpdateService = NoopUpdateService()
}
public extension EnvironmentValues {
var updateService: any UpdateService {
get { self[UpdateServiceKey.self] }
set { self[UpdateServiceKey.self] = newValue }
}
}

View File

@@ -0,0 +1,289 @@
import Foundation
import Observation
import VibeviewerAPI
import VibeviewerModel
import VibeviewerStorage
import VibeviewerCore
///
public protocol DashboardRefreshService: Sendable {
@MainActor var isRefreshing: Bool { get }
@MainActor var isPaused: Bool { get }
@MainActor func start() async
@MainActor func stop()
@MainActor func pause()
@MainActor func resume() async
@MainActor func refreshNow() async
}
/// 便 Environment
public struct NoopDashboardRefreshService: DashboardRefreshService {
public init() {}
public var isRefreshing: Bool { false }
public var isPaused: Bool { false }
@MainActor public func start() async {}
@MainActor public func stop() {}
@MainActor public func pause() {}
@MainActor public func resume() async {}
@MainActor public func refreshNow() async {}
}
@MainActor
@Observable
public final class DefaultDashboardRefreshService: DashboardRefreshService {
private let api: CursorService
private let storage: any CursorStorageService
private let settings: AppSettings
private let session: AppSession
private var loopTask: Task<Void, Never>?
public private(set) var isRefreshing: Bool = false
public private(set) var isPaused: Bool = false
public init(
api: CursorService,
storage: any CursorStorageService,
settings: AppSettings,
session: AppSession
) {
self.api = api
self.storage = storage
self.settings = settings
self.session = session
}
public func start() async {
await self.bootstrapIfNeeded()
await self.refreshNow()
self.loopTask?.cancel()
self.loopTask = Task { [weak self] in
guard let self else { return }
while !Task.isCancelled {
//
if self.isPaused {
try? await Task.sleep(for: .seconds(30)) // 30
continue
}
await self.refreshNow()
// 5
try? await Task.sleep(for: .seconds(5 * 60))
}
}
}
public func stop() {
self.loopTask?.cancel()
self.loopTask = nil
}
public func pause() {
self.isPaused = true
}
public func resume() async {
self.isPaused = false
//
await self.refreshNow()
}
public func refreshNow() async {
if self.isRefreshing || self.isPaused { return }
self.isRefreshing = true
defer { self.isRefreshing = false }
await self.bootstrapIfNeeded()
guard let creds = self.session.credentials else { return }
do {
//
let (analyticsStartMs, analyticsEndMs) = self.analyticsDateRangeMs()
// 使 async let API
async let usageSummary = try await self.api.fetchUsageSummary(
cookieHeader: creds.cookieHeader
)
async let history = try await self.api.fetchFilteredUsageEvents(
startDateMs: analyticsStartMs,
endDateMs: analyticsEndMs,
userId: creds.userId,
page: 1,
cookieHeader: creds.cookieHeader
)
async let billingCycleMs = try? await self.api.fetchCurrentBillingCycleMs(
cookieHeader: creds.cookieHeader
)
// usageSummary
let usageSummaryValue = try await usageSummary
// Pro 使 filtered usage events 700
// Team/Enterprise 使 models analytics API
let modelsUsageChart = try? await self.fetchModelsUsageChartForUser(
usageSummary: usageSummaryValue,
creds: creds,
analyticsStartMs: analyticsStartMs,
analyticsEndMs: analyticsEndMs
)
//
let billingCycleValue = await billingCycleMs
// totalRequestsAllModels 使API
let totalAll = 0 // 0使
let current = self.session.snapshot
// Team Plan free usage usageSummary
func computeFreeCents() async -> Int {
if usageSummaryValue.membershipType == .enterprise && creds.isEnterpriseUser == false {
return (try? await self.api.fetchTeamFreeUsageCents(
teamId: creds.teamId,
userId: creds.userId,
cookieHeader: creds.cookieHeader
)) ?? 0
}
return 0
}
let freeCents = await computeFreeCents()
// 使 Pro Team
func fetchModelsUsageSummaryIfNeeded(billingCycleStartMs: String) async -> VibeviewerModel.ModelsUsageSummary? {
// Pro Pro / Pro+ / Ultra Team / Enterprise
let isProAccount = usageSummaryValue.membershipType.isProSeries
guard isProAccount else { return nil }
// 使
let startDateMs = Int64(billingCycleStartMs) ?? 0
let aggregated = try? await self.api.fetchAggregatedUsageEvents(
teamId: -1,
startDate: startDateMs,
cookieHeader: creds.cookieHeader
)
return aggregated.map { VibeviewerModel.ModelsUsageSummary(from: $0) }
}
var modelsUsageSummary: VibeviewerModel.ModelsUsageSummary? = nil
if let billingCycleStartMs = billingCycleValue?.startDateMs {
modelsUsageSummary = await fetchModelsUsageSummaryIfNeeded(billingCycleStartMs: billingCycleStartMs)
}
// 使 UI
let overview = DashboardSnapshot(
email: creds.email,
totalRequestsAllModels: totalAll,
spendingCents: usageSummaryValue.individualUsage.plan.used,
hardLimitDollars: usageSummaryValue.individualUsage.plan.limit / 100,
usageEvents: current?.usageEvents ?? [],
requestToday: current?.requestToday ?? 0,
requestYestoday: current?.requestYestoday ?? 0,
usageSummary: usageSummaryValue,
freeUsageCents: freeCents,
modelsUsageChart: current?.modelsUsageChart,
modelsUsageSummary: modelsUsageSummary,
billingCycleStartMs: billingCycleValue?.startDateMs,
billingCycleEndMs: billingCycleValue?.endDateMs
)
self.session.snapshot = overview
try? await self.storage.saveDashboardSnapshot(overview)
//
let historyValue = try await history
let (reqToday, reqYesterday) = self.splitTodayAndYesterdayCounts(from: historyValue.events)
let merged = DashboardSnapshot(
email: overview.email,
totalRequestsAllModels: overview.totalRequestsAllModels,
spendingCents: overview.spendingCents,
hardLimitDollars: overview.hardLimitDollars,
usageEvents: historyValue.events,
requestToday: reqToday,
requestYestoday: reqYesterday,
usageSummary: usageSummaryValue,
freeUsageCents: overview.freeUsageCents,
modelsUsageChart: modelsUsageChart,
modelsUsageSummary: modelsUsageSummary,
billingCycleStartMs: billingCycleValue?.startDateMs,
billingCycleEndMs: billingCycleValue?.endDateMs
)
self.session.snapshot = merged
try? await self.storage.saveDashboardSnapshot(merged)
} catch {
//
}
}
private func bootstrapIfNeeded() async {
if self.session.snapshot == nil, let cached = await self.storage.loadDashboardSnapshot() {
self.session.snapshot = cached
}
if self.session.credentials == nil {
self.session.credentials = await self.storage.loadCredentials()
}
}
private func yesterdayToNowRangeMs() -> (String, String) {
let (start, end) = VibeviewerCore.DateUtils.yesterdayToNowRange()
return (VibeviewerCore.DateUtils.millisecondsString(from: start), VibeviewerCore.DateUtils.millisecondsString(from: end))
}
private func analyticsDateRangeMs() -> (String, String) {
let days = self.settings.analyticsDataDays
let (start, end) = VibeviewerCore.DateUtils.daysAgoToNowRange(days: days)
return (VibeviewerCore.DateUtils.millisecondsString(from: start), VibeviewerCore.DateUtils.millisecondsString(from: end))
}
private func splitTodayAndYesterdayCounts(from events: [UsageEvent]) -> (Int, Int) {
let calendar = Calendar.current
var today = 0
var yesterday = 0
for e in events {
guard let date = VibeviewerCore.DateUtils.date(fromMillisecondsString: e.occurredAtMs) else { continue }
if calendar.isDateInToday(date) {
today += e.requestCostCount
} else if calendar.isDateInYesterday(date) {
yesterday += e.requestCostCount
}
}
return (today, yesterday)
}
/// 使
private func modelsAnalyticsDateRange() -> (start: String, end: String) {
let days = self.settings.analyticsDataDays
return VibeviewerCore.DateUtils.daysAgoToTodayRange(days: days)
}
/// 使
/// - Team Pro / Pro+ / Ultra / Free 使 filtered usage events700
/// - Team Plan 使 models analytics API/api/v2/analytics/team/models
private func fetchModelsUsageChartForUser(
usageSummary: VibeviewerModel.UsageSummary,
creds: Credentials,
analyticsStartMs: String,
analyticsEndMs: String
) async throws -> VibeviewerModel.ModelsUsageChartData {
// Team Plan team analytics
// - 使 membershipType = .enterprise + isEnterpriseUser = false Team Plan
let isTeamPlanAccount = (usageSummary.membershipType == .enterprise && creds.isEnterpriseUser == false)
// Team 使 filtered usage events /api/v2/analytics/team/
guard isTeamPlanAccount else {
return try await self.api.fetchModelsUsageChartFromEvents(
startDateMs: analyticsStartMs,
endDateMs: analyticsEndMs,
userId: creds.userId,
cookieHeader: creds.cookieHeader
)
}
// Team Plan 使 models analytics API
let dateRange = self.modelsAnalyticsDateRange()
return try await self.api.fetchModelsAnalytics(
startDate: dateRange.start,
endDate: dateRange.end,
c: creds.workosId,
cookieHeader: creds.cookieHeader
)
}
}

View File

@@ -0,0 +1,79 @@
import Foundation
import VibeviewerAPI
import VibeviewerModel
import VibeviewerStorage
public enum LoginServiceError: Error, Equatable {
case fetchAccountFailed
case saveCredentialsFailed
case initialRefreshFailed
}
public protocol LoginService: Sendable {
/// Cookie Dashboard
@MainActor
func login(with cookieHeader: String) async throws
}
/// Environment
public struct NoopLoginService: LoginService {
public init() {}
@MainActor
public func login(with cookieHeader: String) async throws {}
}
@MainActor
public final class DefaultLoginService: LoginService {
private let api: CursorService
private let storage: any CursorStorageService
private let refresher: any DashboardRefreshService
private let session: AppSession
public init(
api: CursorService,
storage: any CursorStorageService,
refresher: any DashboardRefreshService,
session: AppSession
) {
self.api = api
self.storage = storage
self.refresher = refresher
self.session = session
}
public func login(with cookieHeader: String) async throws {
//
let previousCredentials = self.session.credentials
let previousSnapshot = self.session.snapshot
// 1. 使 Cookie
let me: Credentials
do {
me = try await self.api.fetchMe(cookieHeader: cookieHeader)
} catch {
throw LoginServiceError.fetchAccountFailed
}
// 2.
do {
try await self.storage.saveCredentials(me)
self.session.credentials = me
} catch {
throw LoginServiceError.saveCredentialsFailed
}
// 3. Dashboard
await self.refresher.start()
// 4. snapshot
if previousCredentials == nil, previousSnapshot == nil, self.session.snapshot == nil {
await self.storage.clearCredentials()
await self.storage.clearDashboardSnapshot()
self.session.credentials = nil
self.session.snapshot = nil
throw LoginServiceError.initialRefreshFailed
}
}
}

View File

@@ -0,0 +1,59 @@
import Foundation
import Observation
///
@MainActor
@Observable
public final class PowerAwareDashboardRefreshService: DashboardRefreshService {
private let refreshService: DefaultDashboardRefreshService
private let screenPowerService: DefaultScreenPowerStateService
public var isRefreshing: Bool { refreshService.isRefreshing }
public var isPaused: Bool { refreshService.isPaused }
public init(
refreshService: DefaultDashboardRefreshService,
screenPowerService: DefaultScreenPowerStateService
) {
self.refreshService = refreshService
self.screenPowerService = screenPowerService
//
screenPowerService.setOnScreenSleep { [weak self] in
Task { @MainActor in
self?.refreshService.pause()
}
}
screenPowerService.setOnScreenWake { [weak self] in
Task { @MainActor in
await self?.refreshService.resume()
}
}
}
public func start() async {
//
screenPowerService.startMonitoring()
//
await refreshService.start()
}
public func stop() {
refreshService.stop()
screenPowerService.stopMonitoring()
}
public func pause() {
refreshService.pause()
}
public func resume() async {
await refreshService.resume()
}
public func refreshNow() async {
await refreshService.refreshNow()
}
}

View File

@@ -0,0 +1,77 @@
import Foundation
import Cocoa
///
public protocol ScreenPowerStateService: Sendable {
@MainActor var isScreenAwake: Bool { get }
@MainActor func startMonitoring()
@MainActor func stopMonitoring()
@MainActor func setOnScreenSleep(_ handler: @escaping @Sendable () -> Void)
@MainActor func setOnScreenWake(_ handler: @escaping @Sendable () -> Void)
}
///
@MainActor
public final class DefaultScreenPowerStateService: ScreenPowerStateService, ObservableObject {
public private(set) var isScreenAwake: Bool = true
private var onScreenSleep: (@Sendable () -> Void)?
private var onScreenWake: (@Sendable () -> Void)?
public init() {}
public func setOnScreenSleep(_ handler: @escaping @Sendable () -> Void) {
self.onScreenSleep = handler
}
public func setOnScreenWake(_ handler: @escaping @Sendable () -> Void) {
self.onScreenWake = handler
}
public func startMonitoring() {
NotificationCenter.default.addObserver(
forName: NSWorkspace.willSleepNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.handleScreenSleep()
}
}
NotificationCenter.default.addObserver(
forName: NSWorkspace.didWakeNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.handleScreenWake()
}
}
}
public func stopMonitoring() {
NotificationCenter.default.removeObserver(self, name: NSWorkspace.willSleepNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: NSWorkspace.didWakeNotification, object: nil)
}
private func handleScreenSleep() {
isScreenAwake = false
onScreenSleep?()
}
private func handleScreenWake() {
isScreenAwake = true
onScreenWake?()
}
}
/// 便 Environment
public struct NoopScreenPowerStateService: ScreenPowerStateService {
public init() {}
public var isScreenAwake: Bool { true }
public func startMonitoring() {}
public func stopMonitoring() {}
public func setOnScreenSleep(_ handler: @escaping @Sendable () -> Void) {}
public func setOnScreenWake(_ handler: @escaping @Sendable () -> Void) {}
}

View File

@@ -0,0 +1,54 @@
import Foundation
///
public protocol UpdateService: Sendable {
///
@MainActor func checkForUpdates()
///
@MainActor func checkForUpdatesInBackground()
///
@MainActor var isCheckingForUpdates: Bool { get }
///
@MainActor var updateAvailable: Bool { get }
///
var currentVersion: String { get }
///
@MainActor var latestVersion: String? { get }
///
@MainActor var lastUpdateCheckDate: Date? { get }
///
@MainActor var updateStatusDescription: String { get }
}
/// 便 Environment
public struct NoopUpdateService: UpdateService {
public init() {}
@MainActor public func checkForUpdates() {}
@MainActor public func checkForUpdatesInBackground() {}
@MainActor public var isCheckingForUpdates: Bool { false }
@MainActor public var updateAvailable: Bool { false }
public var currentVersion: String {
// 使 Bundle.main
if let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String, !version.isEmpty {
return version
}
// Fallback: CFBundleVersion
if let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String, !version.isEmpty {
return version
}
//
return "1.1.9"
}
@MainActor public var latestVersion: String? { nil }
@MainActor public var lastUpdateCheckDate: Date? { nil }
@MainActor public var updateStatusDescription: String { "更新服务不可用" }
}

View File

@@ -0,0 +1,8 @@
@testable import VibeviewerCore
import XCTest
final class VibeviewerAppEnvironmentTests: XCTestCase {
func testExample() {
XCTAssertTrue(true)
}
}