蜂鸟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" : "1c8e9c91c686aa90c1a15c428e52c1d8c1ad02100fe3069d87feb1d4fafef7d1",
"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,34 @@
// swift-tools-version:5.10
import PackageDescription
let package = Package(
name: "VibeviewerAPI",
platforms: [
.macOS(.v14)
],
products: [
.library(name: "VibeviewerAPI", targets: ["VibeviewerAPI"])
],
dependencies: [
.package(path: "../VibeviewerCore"),
.package(path: "../VibeviewerModel"),
.package(url: "https://github.com/Moya/Moya.git", .upToNextMajor(from: "15.0.0")),
.package(url: "https://github.com/Alamofire/Alamofire.git", .upToNextMajor(from: "5.8.0")),
],
targets: [
.target(
name: "VibeviewerAPI",
dependencies: [
"VibeviewerCore",
"VibeviewerModel",
.product(name: "Moya", package: "Moya"),
.product(name: "Alamofire", package: "Alamofire"),
]
),
.testTarget(
name: "VibeviewerAPITests",
dependencies: ["VibeviewerAPI"]
),
]
)

View File

@@ -0,0 +1,54 @@
import Foundation
/// Cursor API 使 DTO
struct CursorAggregatedUsageEventsResponse: Decodable, Sendable, Equatable {
let aggregations: [CursorModelAggregation]
let totalInputTokens: String
let totalOutputTokens: String
let totalCacheWriteTokens: String
let totalCacheReadTokens: String
let totalCostCents: Double
init(
aggregations: [CursorModelAggregation],
totalInputTokens: String,
totalOutputTokens: String,
totalCacheWriteTokens: String,
totalCacheReadTokens: String,
totalCostCents: Double
) {
self.aggregations = aggregations
self.totalInputTokens = totalInputTokens
self.totalOutputTokens = totalOutputTokens
self.totalCacheWriteTokens = totalCacheWriteTokens
self.totalCacheReadTokens = totalCacheReadTokens
self.totalCostCents = totalCostCents
}
}
/// DTO
struct CursorModelAggregation: Decodable, Sendable, Equatable {
let modelIntent: String
let inputTokens: String?
let outputTokens: String?
let cacheWriteTokens: String?
let cacheReadTokens: String?
let totalCents: Double
init(
modelIntent: String,
inputTokens: String?,
outputTokens: String?,
cacheWriteTokens: String?,
cacheReadTokens: String?,
totalCents: Double
) {
self.modelIntent = modelIntent
self.inputTokens = inputTokens
self.outputTokens = outputTokens
self.cacheWriteTokens = cacheWriteTokens
self.cacheReadTokens = cacheReadTokens
self.totalCents = totalCents
}
}

View File

@@ -0,0 +1,16 @@
import Foundation
/// Cursor API DTO
struct CursorCurrentBillingCycleResponse: Decodable, Sendable, Equatable {
let startDateEpochMillis: String
let endDateEpochMillis: String
init(
startDateEpochMillis: String,
endDateEpochMillis: String
) {
self.startDateEpochMillis = startDateEpochMillis
self.endDateEpochMillis = endDateEpochMillis
}
}

View File

@@ -0,0 +1,57 @@
import Foundation
struct CursorTokenUsage: Decodable, Sendable, Equatable {
let outputTokens: Int?
let inputTokens: Int?
let totalCents: Double?
let cacheWriteTokens: Int?
let cacheReadTokens: Int?
init(
outputTokens: Int?,
inputTokens: Int?,
totalCents: Double?,
cacheWriteTokens: Int?,
cacheReadTokens: Int?
) {
self.outputTokens = outputTokens
self.inputTokens = inputTokens
self.totalCents = totalCents
self.cacheWriteTokens = cacheWriteTokens
self.cacheReadTokens = cacheReadTokens
}
}
struct CursorFilteredUsageEvent: Decodable, Sendable, Equatable {
let timestamp: String
let model: String
let kind: String
let requestsCosts: Double?
let usageBasedCosts: String
let isTokenBasedCall: Bool
let owningUser: String
let cursorTokenFee: Double
let tokenUsage: CursorTokenUsage
init(
timestamp: String,
model: String,
kind: String,
requestsCosts: Double?,
usageBasedCosts: String,
isTokenBasedCall: Bool,
owningUser: String,
cursorTokenFee: Double,
tokenUsage: CursorTokenUsage
) {
self.timestamp = timestamp
self.model = model
self.kind = kind
self.requestsCosts = requestsCosts
self.usageBasedCosts = usageBasedCosts
self.isTokenBasedCall = isTokenBasedCall
self.owningUser = owningUser
self.cursorTokenFee = cursorTokenFee
self.tokenUsage = tokenUsage
}
}

View File

@@ -0,0 +1,11 @@
import Foundation
struct CursorFilteredUsageResponse: Decodable, Sendable, Equatable {
let totalUsageEventsCount: Int?
let usageEventsDisplay: [CursorFilteredUsageEvent]?
init(totalUsageEventsCount: Int? = nil, usageEventsDisplay: [CursorFilteredUsageEvent]? = nil) {
self.totalUsageEventsCount = totalUsageEventsCount
self.usageEventsDisplay = usageEventsDisplay
}
}

View File

@@ -0,0 +1,19 @@
import Foundation
struct CursorMeResponse: Decodable, Sendable {
let authId: String
let userId: Int
let email: String
let workosId: String
let teamId: Int?
let isEnterpriseUser: Bool
init(authId: String, userId: Int, email: String, workosId: String, teamId: Int?, isEnterpriseUser: Bool) {
self.authId = authId
self.userId = userId
self.email = email
self.workosId = workosId
self.teamId = teamId
self.isEnterpriseUser = isEnterpriseUser
}
}

View File

@@ -0,0 +1,11 @@
import Foundation
struct CursorModelUsage: Decodable, Sendable {
let numTokens: Int
let maxTokenUsage: Int?
init(numTokens: Int, maxTokenUsage: Int?) {
self.numTokens = numTokens
self.maxTokenUsage = maxTokenUsage
}
}

View File

@@ -0,0 +1,51 @@
import Foundation
/// Cursor API DTO
public struct CursorTeamModelsAnalyticsResponse: Codable, Sendable, Equatable {
public let meta: [Meta]
public let data: [DataItem]
public init(meta: [Meta], data: [DataItem]) {
self.meta = meta
self.data = data
}
}
///
public struct Meta: Codable, Sendable, Equatable {
public let name: String
public let type: String
public init(name: String, type: String) {
self.name = name
self.type = type
}
}
///
public struct DataItem: Codable, Sendable, Equatable {
public let date: String
public let modelBreakdown: [String: ModelStats]
enum CodingKeys: String, CodingKey {
case date
case modelBreakdown = "model_breakdown"
}
public init(date: String, modelBreakdown: [String: ModelStats]) {
self.date = date
self.modelBreakdown = modelBreakdown
}
}
///
public struct ModelStats: Codable, Sendable, Equatable {
public let requests: UInt64
public let users: UInt64?
public init(requests: UInt64, users: UInt64) {
self.requests = requests
self.users = users
}
}

View File

@@ -0,0 +1,30 @@
import Foundation
struct CursorTeamSpendResponse: Decodable, Sendable, Equatable {
let teamMemberSpend: [CursorTeamMemberSpend]
let subscriptionCycleStart: String
let totalMembers: Int
let totalPages: Int
let totalByRole: [CursorRoleCount]
let nextCycleStart: String
let limitedUserCount: Int
let maxUserSpendCents: Int?
let subscriptionLimitedUsers: Int
}
struct CursorTeamMemberSpend: Decodable, Sendable, Equatable {
let userId: Int
let email: String
let role: String
let hardLimitOverrideDollars: Int?
let includedSpendCents: Int?
let spendCents: Int?
let fastPremiumRequests: Int?
}
struct CursorRoleCount: Decodable, Sendable, Equatable {
let role: String
let count: Int
}

View File

@@ -0,0 +1,84 @@
import Foundation
struct CursorUsageSummaryResponse: Decodable, Sendable, Equatable {
let billingCycleStart: String
let billingCycleEnd: String
let membershipType: String
let limitType: String
let individualUsage: CursorIndividualUsage
let teamUsage: CursorTeamUsage?
init(
billingCycleStart: String,
billingCycleEnd: String,
membershipType: String,
limitType: String,
individualUsage: CursorIndividualUsage,
teamUsage: CursorTeamUsage? = nil
) {
self.billingCycleStart = billingCycleStart
self.billingCycleEnd = billingCycleEnd
self.membershipType = membershipType
self.limitType = limitType
self.individualUsage = individualUsage
self.teamUsage = teamUsage
}
}
struct CursorIndividualUsage: Decodable, Sendable, Equatable {
let plan: CursorPlanUsage
let onDemand: CursorOnDemandUsage?
init(plan: CursorPlanUsage, onDemand: CursorOnDemandUsage? = nil) {
self.plan = plan
self.onDemand = onDemand
}
}
struct CursorPlanUsage: Decodable, Sendable, Equatable {
let used: Int
let limit: Int
let remaining: Int
let breakdown: CursorPlanBreakdown
init(used: Int, limit: Int, remaining: Int, breakdown: CursorPlanBreakdown) {
self.used = used
self.limit = limit
self.remaining = remaining
self.breakdown = breakdown
}
}
struct CursorPlanBreakdown: Decodable, Sendable, Equatable {
let included: Int
let bonus: Int
let total: Int
init(included: Int, bonus: Int, total: Int) {
self.included = included
self.bonus = bonus
self.total = total
}
}
struct CursorOnDemandUsage: Decodable, Sendable, Equatable {
let used: Int
let limit: Int?
let remaining: Int?
let enabled: Bool
init(used: Int, limit: Int?, remaining: Int?, enabled: Bool) {
self.used = used
self.limit = limit
self.remaining = remaining
self.enabled = enabled
}
}
struct CursorTeamUsage: Decodable, Sendable, Equatable {
let onDemand: CursorOnDemandUsage?
init(onDemand: CursorOnDemandUsage? = nil) {
self.onDemand = onDemand
}
}

View File

@@ -0,0 +1,111 @@
import Alamofire
import Foundation
import Moya
struct RequestErrorWrapper {
let moyaError: MoyaError
var afError: AFError? {
if case let .underlying(error as AFError, _) = moyaError {
return error
}
return nil
}
var nsError: NSError? {
if case let .underlying(error as NSError, _) = moyaError {
return error
} else if let afError {
return afError.underlyingError as? NSError
}
return nil
}
var isRequestCancelled: Bool {
if case .explicitlyCancelled = self.afError {
return true
}
return false
}
var defaultErrorMessage: String? {
if self.nsError?.code == NSURLErrorTimedOut {
"加载数据失败,请稍后重试"
} else if self.nsError?.code == NSURLErrorNotConnectedToInternet {
"无网络连接,请检查网络"
} else {
"加载数据失败,请稍后重试"
}
}
}
protocol RequestErrorHandlable {
var errorHandlingType: RequestErrorHandlingPlugin.RequestErrorHandlingType { get }
}
extension RequestErrorHandlable {
var errorHandlingType: RequestErrorHandlingPlugin.RequestErrorHandlingType {
.all
}
}
class RequestErrorHandlingPlugin {
enum RequestErrorHandlingType {
enum FilterResult {
case handledByPlugin(message: String?)
case shouldNotHandledByPlugin
}
case connectionError //
case all
case allWithFilter(filter: (RequestErrorWrapper) -> FilterResult)
func handleError(_ error: RequestErrorWrapper, handler: (_ shouldHandle: Bool, _ message: String?) -> Void) {
switch self {
case .connectionError:
if error.nsError?.code == NSURLErrorTimedOut {
handler(true, error.defaultErrorMessage)
} else if error.nsError?.code == NSURLErrorNotConnectedToInternet {
handler(true, error.defaultErrorMessage)
}
case .all:
handler(true, error.defaultErrorMessage)
case let .allWithFilter(filter):
switch filter(error) {
case let .handledByPlugin(messsage):
handler(true, messsage ?? error.defaultErrorMessage)
case .shouldNotHandledByPlugin:
handler(false, nil)
}
}
handler(false, nil)
}
}
}
extension RequestErrorHandlingPlugin: PluginType {
func prepare(_ request: URLRequest, target: TargetType) -> URLRequest {
var request = request
request.timeoutInterval = 30
return request
}
func didReceive(_ result: Result<Response, MoyaError>, target: TargetType) {
let requestErrorHandleSubject: RequestErrorHandlable? =
((target as? MultiTarget)?.target as? RequestErrorHandlable)
?? (target as? RequestErrorHandlable)
guard let requestErrorHandleSubject, case let .failure(moyaError) = result else { return }
let errorWrapper = RequestErrorWrapper(moyaError: moyaError)
if errorWrapper.isRequestCancelled {
return
}
requestErrorHandleSubject.errorHandlingType.handleError(errorWrapper) { shouldHandle, message in
if shouldHandle, let message, !message.isEmpty {
// show error
}
}
}
}

View File

@@ -0,0 +1,30 @@
import Foundation
import Moya
final class RequestHeaderConfigurationPlugin: PluginType {
static let shared: RequestHeaderConfigurationPlugin = .init()
var header: [String: String] = [:]
// MARK: Plugin
func prepare(_ request: URLRequest, target: TargetType) -> URLRequest {
var request = request
request.allHTTPHeaderFields?.merge(self.header) { _, new in new }
return request
}
func setAuthorization(_ token: String) {
self.header["Authorization"] = "Bearer "
}
func clearAuthorization() {
self.header["Authorization"] = ""
}
init() {
self.header = [
"Authorization": "Bearer "
]
}
}

View File

@@ -0,0 +1,65 @@
import Foundation
import Moya
import VibeviewerCore
final class SimpleNetworkLoggerPlugin {}
// MARK: - PluginType
extension SimpleNetworkLoggerPlugin: PluginType {
func didReceive(_ result: Result<Moya.Response, MoyaError>, target: TargetType) {
var loggings: [String] = []
let targetType: TargetType.Type = if let multiTarget = target as? MultiTarget {
type(of: multiTarget.target)
} else {
type(of: target)
}
loggings.append("Request: \(targetType) [\(Date())]")
switch result {
case let .success(success):
loggings
.append("URL: \(success.request?.url?.absoluteString ?? target.baseURL.absoluteString + target.path)")
loggings.append("Method: \(target.method.rawValue)")
if let output = success.request?.httpBody?.toPrettyPrintedJSONString() {
loggings.append("Request body: \n\(output)")
}
loggings.append("Status Code: \(success.statusCode)")
if let output = success.data.toPrettyPrintedJSONString() {
loggings.append("Response: \n\(output)")
} else if let string = String(data: success.data, encoding: .utf8) {
loggings.append("Response: \(string)")
} else {
loggings.append("Response: \(success.data)")
}
case let .failure(failure):
loggings
.append("URL: \(failure.response?.request?.url?.absoluteString ?? target.baseURL.absoluteString + target.path)")
loggings.append("Method: \(target.method.rawValue)")
if let output = failure.response?.request?.httpBody?.toPrettyPrintedJSONString() {
loggings.append("Request body: \n\(output)")
}
if let errorResponseCode = failure.response?.statusCode {
loggings.append("Error Code: \(errorResponseCode)")
} else {
loggings.append("Error Code: \(failure.errorCode)")
}
if let errorOutput = failure.response?.data.toPrettyPrintedJSONString() {
loggings.append("Error Response: \n\(errorOutput)")
}
loggings.append("Error detail: \(failure.localizedDescription)")
}
loggings = loggings.map { "🔵 " + $0 }
let seperator = "==================================================================="
loggings.insert(seperator, at: 0)
loggings.append(seperator)
loggings.forEach { print($0) }
}
}

View File

@@ -0,0 +1,28 @@
import Foundation
enum APIConfig {
static let baseURL = URL(string: "https://cursor.com")!
static let dashboardReferer = "https://cursor.com/dashboard"
}
enum APIHeadersBuilder {
static func jsonHeaders(cookieHeader: String?) -> [String: String] {
var h: [String: String] = [
"accept": "*/*",
"content-type": "application/json",
"origin": "https://cursor.com",
"referer": APIConfig.dashboardReferer
]
if let cookieHeader, !cookieHeader.isEmpty { h["Cookie"] = cookieHeader }
return h
}
static func basicHeaders(cookieHeader: String?) -> [String: String] {
var h: [String: String] = [
"accept": "*/*",
"referer": APIConfig.dashboardReferer
]
if let cookieHeader, !cookieHeader.isEmpty { h["Cookie"] = cookieHeader }
return h
}
}

View File

@@ -0,0 +1,582 @@
import Foundation
import Moya
import VibeviewerModel
import VibeviewerCore
public enum CursorServiceError: Error {
case sessionExpired
}
protocol CursorNetworkClient {
func decodableRequest<T: DecodableTargetType>(
_ target: T,
decodingStrategy: JSONDecoder.KeyDecodingStrategy
) async throws -> T
.ResultType
}
struct DefaultCursorNetworkClient: CursorNetworkClient {
init() {}
func decodableRequest<T>(_ target: T, decodingStrategy: JSONDecoder.KeyDecodingStrategy) async throws -> T
.ResultType where T: DecodableTargetType
{
try await HttpClient.decodableRequest(target, decodingStrategy: decodingStrategy)
}
}
public protocol CursorService {
func fetchMe(cookieHeader: String) async throws -> Credentials
func fetchUsageSummary(cookieHeader: String) async throws -> VibeviewerModel.UsageSummary
/// Team Plan 使 free usageincludedSpendCents - hardLimitOverrideDollars*10000
func fetchTeamFreeUsageCents(teamId: Int, userId: Int, cookieHeader: String) async throws -> Int
func fetchFilteredUsageEvents(
startDateMs: String,
endDateMs: String,
userId: Int,
page: Int,
cookieHeader: String
) async throws -> VibeviewerModel.FilteredUsageHistory
func fetchModelsAnalytics(
startDate: String,
endDate: String,
c: String,
cookieHeader: String
) async throws -> VibeviewerModel.ModelsUsageChartData
/// 使 Pro Team
/// - Parameters:
/// - teamId: IDPro nil
/// - startDate:
/// - cookieHeader: Cookie
func fetchAggregatedUsageEvents(
teamId: Int?,
startDate: Int64,
cookieHeader: String
) async throws -> VibeviewerModel.AggregatedUsageEvents
///
/// - Parameter cookieHeader: Cookie
func fetchCurrentBillingCycle(cookieHeader: String) async throws -> VibeviewerModel.BillingCycle
///
/// - Parameter cookieHeader: Cookie
/// - Returns: (startDateMs: String, endDateMs: String)
func fetchCurrentBillingCycleMs(cookieHeader: String) async throws -> (startDateMs: String, endDateMs: String)
/// Filtered Usage Events 使Pro
/// - Parameters:
/// - startDateMs:
/// - endDateMs:
/// - userId: ID
/// - cookieHeader: Cookie
/// - Returns: 使
func fetchModelsUsageChartFromEvents(
startDateMs: String,
endDateMs: String,
userId: Int,
cookieHeader: String
) async throws -> VibeviewerModel.ModelsUsageChartData
}
public struct DefaultCursorService: CursorService {
private let network: CursorNetworkClient
private let decoding: JSONDecoder.KeyDecodingStrategy
// Public initializer that does not expose internal protocol types
public init(decoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys) {
self.network = DefaultCursorNetworkClient()
self.decoding = decoding
}
// Internal injectable initializer for tests
init(network: any CursorNetworkClient, decoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys) {
self.network = network
self.decoding = decoding
}
private func performRequest<T: DecodableTargetType>(_ target: T) async throws -> T.ResultType {
do {
return try await self.network.decodableRequest(target, decodingStrategy: self.decoding)
} catch {
if let moyaError = error as? MoyaError,
case let .statusCode(response) = moyaError,
[401, 403].contains(response.statusCode)
{
throw CursorServiceError.sessionExpired
}
throw error
}
}
public func fetchMe(cookieHeader: String) async throws -> Credentials {
let dto: CursorMeResponse = try await self.performRequest(CursorGetMeAPI(cookieHeader: cookieHeader))
return Credentials(
userId: dto.userId,
workosId: dto.workosId,
email: dto.email,
teamId: dto.teamId ?? 0,
cookieHeader: cookieHeader,
isEnterpriseUser: dto.isEnterpriseUser
)
}
public func fetchUsageSummary(cookieHeader: String) async throws -> VibeviewerModel.UsageSummary {
let dto: CursorUsageSummaryResponse = try await self.performRequest(CursorUsageSummaryAPI(cookieHeader: cookieHeader))
//
let dateFormatter = ISO8601DateFormatter()
let billingCycleStart = dateFormatter.date(from: dto.billingCycleStart) ?? Date()
let billingCycleEnd = dateFormatter.date(from: dto.billingCycleEnd) ?? Date()
// 使
let planUsage = VibeviewerModel.PlanUsage(
used: dto.individualUsage.plan.used,
limit: dto.individualUsage.plan.limit,
remaining: dto.individualUsage.plan.remaining,
breakdown: VibeviewerModel.PlanBreakdown(
included: dto.individualUsage.plan.breakdown.included,
bonus: dto.individualUsage.plan.breakdown.bonus,
total: dto.individualUsage.plan.breakdown.total
)
)
// 使
let onDemandUsage: VibeviewerModel.OnDemandUsage? = {
guard let individualOnDemand = dto.individualUsage.onDemand else { return nil }
if individualOnDemand.used > 0 || (individualOnDemand.limit ?? 0) > 0 {
return VibeviewerModel.OnDemandUsage(
used: individualOnDemand.used,
limit: individualOnDemand.limit,
remaining: individualOnDemand.remaining,
enabled: individualOnDemand.enabled
)
}
return nil
}()
// 使
let individualUsage = VibeviewerModel.IndividualUsage(
plan: planUsage,
onDemand: onDemandUsage
)
// 使
let teamUsage: VibeviewerModel.TeamUsage? = {
guard let teamUsageData = dto.teamUsage,
let teamOnDemand = teamUsageData.onDemand else { return nil }
if teamOnDemand.used > 0 || (teamOnDemand.limit ?? 0) > 0 {
return VibeviewerModel.TeamUsage(
onDemand: VibeviewerModel.OnDemandUsage(
used: teamOnDemand.used,
limit: teamOnDemand.limit,
remaining: teamOnDemand.remaining,
enabled: teamOnDemand.enabled
)
)
}
return nil
}()
//
let membershipType = VibeviewerModel.MembershipType(rawValue: dto.membershipType) ?? .free
return VibeviewerModel.UsageSummary(
billingCycleStart: billingCycleStart,
billingCycleEnd: billingCycleEnd,
membershipType: membershipType,
limitType: dto.limitType,
individualUsage: individualUsage,
teamUsage: teamUsage
)
}
public func fetchFilteredUsageEvents(
startDateMs: String,
endDateMs: String,
userId: Int,
page: Int,
cookieHeader: String
) async throws -> VibeviewerModel.FilteredUsageHistory {
let dto: CursorFilteredUsageResponse = try await self.performRequest(
CursorFilteredUsageAPI(
startDateMs: startDateMs,
endDateMs: endDateMs,
userId: userId,
page: page,
cookieHeader: cookieHeader
)
)
let events: [VibeviewerModel.UsageEvent] = (dto.usageEventsDisplay ?? []).map { e in
let tokenUsage = VibeviewerModel.TokenUsage(
outputTokens: e.tokenUsage.outputTokens,
inputTokens: e.tokenUsage.inputTokens,
totalCents: e.tokenUsage.totalCents ?? 0.0,
cacheWriteTokens: e.tokenUsage.cacheWriteTokens,
cacheReadTokens: e.tokenUsage.cacheReadTokens
)
// token 使 token 1
let requestCount = Self.calculateRequestCount(from: e.tokenUsage)
return VibeviewerModel.UsageEvent(
occurredAtMs: e.timestamp,
modelName: e.model,
kind: e.kind,
requestCostCount: requestCount,
usageCostDisplay: e.usageBasedCosts,
usageCostCents: Self.parseCents(fromDollarString: e.usageBasedCosts),
isTokenBased: e.isTokenBasedCall,
userDisplayName: e.owningUser,
cursorTokenFee: e.cursorTokenFee,
tokenUsage: tokenUsage
)
}
return VibeviewerModel.FilteredUsageHistory(totalCount: dto.totalUsageEventsCount ?? 0, events: events)
}
public func fetchTeamFreeUsageCents(teamId: Int, userId: Int, cookieHeader: String) async throws -> Int {
let dto: CursorTeamSpendResponse = try await self.performRequest(
CursorGetTeamSpendAPI(
teamId: teamId,
page: 1,
// pageSize is hardcoded to 100
sortBy: "name",
sortDirection: "asc",
cookieHeader: cookieHeader
)
)
guard let me = dto.teamMemberSpend.first(where: { $0.userId == userId }) else {
return 0
}
let included = me.includedSpendCents ?? 0
let overrideDollars = me.hardLimitOverrideDollars ?? 0
let freeCents = max(included - overrideDollars * 100, 0)
return freeCents
}
public func fetchModelsAnalytics(
startDate: String,
endDate: String,
c: String,
cookieHeader: String
) async throws -> VibeviewerModel.ModelsUsageChartData {
let dto: CursorTeamModelsAnalyticsResponse = try await self.performRequest(
CursorTeamModelsAnalyticsAPI(
startDate: startDate,
endDate: endDate,
c: c,
cookieHeader: cookieHeader
)
)
return mapToModelsUsageChartData(dto)
}
public func fetchAggregatedUsageEvents(
teamId: Int?,
startDate: Int64,
cookieHeader: String
) async throws -> VibeviewerModel.AggregatedUsageEvents {
let dto: CursorAggregatedUsageEventsResponse = try await self.performRequest(
CursorAggregatedUsageEventsAPI(
teamId: teamId,
startDate: startDate,
cookieHeader: cookieHeader
)
)
return mapToAggregatedUsageEvents(dto)
}
public func fetchCurrentBillingCycle(cookieHeader: String) async throws -> VibeviewerModel.BillingCycle {
let dto: CursorCurrentBillingCycleResponse = try await self.performRequest(
CursorCurrentBillingCycleAPI(cookieHeader: cookieHeader)
)
return mapToBillingCycle(dto)
}
public func fetchCurrentBillingCycleMs(cookieHeader: String) async throws -> (startDateMs: String, endDateMs: String) {
let dto: CursorCurrentBillingCycleResponse = try await self.performRequest(
CursorCurrentBillingCycleAPI(cookieHeader: cookieHeader)
)
return (startDateMs: dto.startDateEpochMillis, endDateMs: dto.endDateEpochMillis)
}
public func fetchModelsUsageChartFromEvents(
startDateMs: String,
endDateMs: String,
userId: Int,
cookieHeader: String
) async throws -> VibeviewerModel.ModelsUsageChartData {
// 700 7 100
var allEvents: [VibeviewerModel.UsageEvent] = []
let maxPages = 7
//
try await withThrowingTaskGroup(of: (page: Int, history: VibeviewerModel.FilteredUsageHistory).self) { group in
for page in 1...maxPages {
group.addTask {
let history = try await self.fetchFilteredUsageEvents(
startDateMs: startDateMs,
endDateMs: endDateMs,
userId: userId,
page: page,
cookieHeader: cookieHeader
)
return (page: page, history: history)
}
}
//
var results: [(page: Int, history: VibeviewerModel.FilteredUsageHistory)] = []
for try await result in group {
results.append(result)
}
results.sort { $0.page < $1.page }
//
for result in results {
allEvents.append(contentsOf: result.history.events)
}
}
// ModelsUsageChartData
return convertEventsToModelsUsageChart(events: allEvents, startDateMs: startDateMs, endDateMs: endDateMs)
}
/// DTO
private func mapToBillingCycle(_ dto: CursorCurrentBillingCycleResponse) -> VibeviewerModel.BillingCycle {
let startDate = Date.fromMillisecondsString(dto.startDateEpochMillis) ?? Date()
let endDate = Date.fromMillisecondsString(dto.endDateEpochMillis) ?? Date()
return VibeviewerModel.BillingCycle(
startDate: startDate,
endDate: endDate
)
}
/// 使 DTO
private func mapToAggregatedUsageEvents(_ dto: CursorAggregatedUsageEventsResponse) -> VibeviewerModel.AggregatedUsageEvents {
let aggregations = dto.aggregations.map { agg in
VibeviewerModel.ModelAggregation(
modelIntent: agg.modelIntent,
inputTokens: Int(agg.inputTokens ?? "0") ?? 0,
outputTokens: Int(agg.outputTokens ?? "0") ?? 0,
cacheWriteTokens: Int(agg.cacheWriteTokens ?? "0") ?? 0,
cacheReadTokens: Int(agg.cacheReadTokens ?? "0") ?? 0,
totalCents: agg.totalCents
)
}
return VibeviewerModel.AggregatedUsageEvents(
aggregations: aggregations,
totalInputTokens: Int(dto.totalInputTokens) ?? 0,
totalOutputTokens: Int(dto.totalOutputTokens) ?? 0,
totalCacheWriteTokens: Int(dto.totalCacheWriteTokens) ?? 0,
totalCacheReadTokens: Int(dto.totalCacheReadTokens) ?? 0,
totalCostCents: dto.totalCostCents
)
}
/// DTO
private func mapToModelsUsageChartData(_ dto: CursorTeamModelsAnalyticsResponse) -> VibeviewerModel.ModelsUsageChartData {
let formatter = DateFormatter()
formatter.locale = .current
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = "yyyy-MM-dd"
// DTO 便
var dataDict: [String: VibeviewerModel.ModelsUsageChartData.DataPoint] = [:]
for item in dto.data {
let dateLabel = formatDateLabelForChart(from: item.date)
let modelUsages = item.modelBreakdown
.map { (modelName, stats) in
VibeviewerModel.ModelsUsageChartData.ModelUsage(
modelName: modelName,
requests: Int(stats.requests)
)
}
.sorted { $0.requests > $1.requests }
dataDict[item.date] = VibeviewerModel.ModelsUsageChartData.DataPoint(
date: item.date,
dateLabel: dateLabel,
modelUsages: modelUsages
)
}
// 7
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
var allDates: [Date] = []
for i in (0..<7).reversed() {
if let date = calendar.date(byAdding: .day, value: -i, to: today) {
allDates.append(date)
}
}
//
let dataPoints = allDates.map { date -> VibeviewerModel.ModelsUsageChartData.DataPoint in
let dateString = formatter.string(from: date)
// 使
if let existingData = dataDict[dateString] {
return existingData
} else {
let dateLabel = formatDateLabelForChart(from: dateString)
return VibeviewerModel.ModelsUsageChartData.DataPoint(
date: dateString,
dateLabel: dateLabel,
modelUsages: []
)
}
}
return VibeviewerModel.ModelsUsageChartData(dataPoints: dataPoints)
}
/// YYYY-MM-DD MM/dd
private func formatDateLabelForChart(from dateString: String) -> String {
let formatter = DateFormatter()
formatter.locale = .current
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = "yyyy-MM-dd"
guard let date = formatter.date(from: dateString) else {
return dateString
}
let labelFormatter = DateFormatter()
labelFormatter.locale = .current
labelFormatter.timeZone = TimeZone(secondsFromGMT: 0)
labelFormatter.dateFormat = "MM/dd"
return labelFormatter.string(from: date)
}
/// 使使
/// - Parameters:
/// - events: 使
/// - startDateMs:
/// - endDateMs:
/// - Returns: 使7
private func convertEventsToModelsUsageChart(
events: [VibeviewerModel.UsageEvent],
startDateMs: String,
endDateMs: String
) -> VibeviewerModel.ModelsUsageChartData {
let formatter = DateFormatter()
formatter.locale = .current
// 使UTC
formatter.timeZone = .current
formatter.dateFormat = "yyyy-MM-dd"
//
guard let startMs = Int64(startDateMs),
let endMs = Int64(endDateMs) else {
return VibeviewerModel.ModelsUsageChartData(dataPoints: [])
}
let startDate = Date(timeIntervalSince1970: TimeInterval(startMs) / 1000.0)
let originalEndDate = Date(timeIntervalSince1970: TimeInterval(endMs) / 1000.0)
let calendar = Calendar.current
// X 24 25
// 00:00
//
let startOfToday = calendar.startOfDay(for: Date())
let endDate: Date = originalEndDate > startOfToday ? startOfToday : originalEndDate
// startDate endDate
var allDates: [Date] = []
var currentDate = startDate
while currentDate <= endDate {
allDates.append(currentDate)
guard let nextDate = calendar.date(byAdding: .day, value: 1, to: currentDate) else { break }
currentDate = nextDate
}
// 77
if allDates.count < 7 {
let today = calendar.startOfDay(for: Date())
allDates = []
for i in (0..<7).reversed() {
if let date = calendar.date(byAdding: .day, value: -i, to: today) {
allDates.append(date)
}
}
}
//
// dateString -> modelName -> requestCount
var dateModelStats: [String: [String: Int]] = [:]
//
for date in allDates {
let dateString = formatter.string(from: date)
dateModelStats[dateString] = [:]
}
//
for event in events {
guard let eventMs = Int64(event.occurredAtMs) else { continue }
let eventDate = Date(timeIntervalSince1970: TimeInterval(eventMs) / 1000.0)
let dateString = formatter.string(from: eventDate)
//
if dateModelStats[dateString] != nil {
let modelName = event.modelName
let currentCount = dateModelStats[dateString]?[modelName] ?? 0
dateModelStats[dateString]?[modelName] = currentCount + event.requestCostCount
}
}
// DataPoint
let dataPoints = allDates.map { date -> VibeviewerModel.ModelsUsageChartData.DataPoint in
let dateString = formatter.string(from: date)
let dateLabel = formatDateLabelForChart(from: dateString)
let modelStats = dateModelStats[dateString] ?? [:]
let modelUsages = modelStats
.map { (modelName, requests) in
VibeviewerModel.ModelsUsageChartData.ModelUsage(
modelName: modelName,
requests: requests
)
}
.sorted { $0.requests > $1.requests } //
return VibeviewerModel.ModelsUsageChartData.DataPoint(
date: dateString,
dateLabel: dateLabel,
modelUsages: modelUsages
)
}
return VibeviewerModel.ModelsUsageChartData(dataPoints: dataPoints)
}
}
private extension DefaultCursorService {
static func parseCents(fromDollarString s: String) -> Int {
// "$0.04" -> 4
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
guard let idx = trimmed.firstIndex(where: { ($0 >= "0" && $0 <= "9") || $0 == "." }) else { return 0 }
let numberPart = trimmed[idx...]
guard let value = Double(numberPart) else { return 0 }
return Int((value * 100.0).rounded())
}
static func calculateRequestCount(from tokenUsage: CursorTokenUsage) -> Int {
// token 使
// output tokens input tokens
let hasOutputTokens = (tokenUsage.outputTokens ?? 0) > 0
let hasInputTokens = (tokenUsage.inputTokens ?? 0) > 0
if hasOutputTokens || hasInputTokens {
// token 使 1
return 1
} else {
// token 使
return 1
}
}
}

View File

@@ -0,0 +1,209 @@
import Alamofire
import Foundation
import Moya
@available(iOS 13, macOS 10.15, tvOS 13, *)
enum HttpClient {
private static var _provider: MoyaProvider<MultiTarget>?
static var provider: MoyaProvider<MultiTarget> {
if _provider == nil {
_provider = createProvider()
}
return _provider!
}
private static func createProvider() -> MoyaProvider<MultiTarget> {
var plugins: [PluginType] = []
plugins.append(SimpleNetworkLoggerPlugin())
plugins.append(RequestErrorHandlingPlugin())
// SSL
let configuration = URLSessionConfiguration.af.default
let session = Session(
configuration: configuration,
serverTrustManager: nil
)
return MoyaProvider<MultiTarget>(session: session, plugins: plugins)
}
// mockprovider
private static var _mockProvider: MoyaProvider<MultiTarget>!
static func mockProvider(_ reponseType: MockResponseType) -> MoyaProvider<MultiTarget> {
let plugins = [NetworkLoggerPlugin(configuration: .init(logOptions: .successResponseBody))]
let endpointClosure: (MultiTarget) -> Endpoint =
switch reponseType {
case let .success(data):
{ (target: MultiTarget) -> Endpoint in
Endpoint(
url: URL(target: target).absoluteString,
sampleResponseClosure: { .networkResponse(200, data ?? target.sampleData) },
method: target.method,
task: target.task,
httpHeaderFields: target.headers
)
}
case let .failure(error):
{ (target: MultiTarget) -> Endpoint in
Endpoint(
url: URL(target: target).absoluteString,
sampleResponseClosure: {
.networkError(error ?? NSError(domain: "mock error", code: -1))
},
method: target.method,
task: target.task,
httpHeaderFields: target.headers
)
}
}
let provider = MoyaProvider<MultiTarget>(
endpointClosure: endpointClosure,
stubClosure: MoyaProvider.delayedStub(2),
plugins: plugins
)
self._mockProvider = provider
return provider
}
enum MockResponseType {
case success(Data?)
case failure(NSError?)
}
enum ProviderType {
case normal
case mockSuccess(Data?)
case mockFailure(NSError?)
}
@discardableResult
static func decodableRequest<T: DecodableTargetType>(
providerType: ProviderType = .normal,
decodingStrategy: JSONDecoder
.KeyDecodingStrategy = .useDefaultKeys,
_ target: T,
callbackQueue: DispatchQueue? = nil,
completion: @escaping (_ result: Result<T.ResultType, Error>)
-> Void
) -> Moya.Cancellable {
let provider: MoyaProvider<MultiTarget> =
switch providerType {
case .normal:
self.provider
case let .mockSuccess(data):
self.mockProvider(.success(data))
case let .mockFailure(error):
self.mockProvider(.failure(error))
}
return provider.decodableRequest(
target,
decodingStrategy: decodingStrategy,
callbackQueue: callbackQueue,
completion: completion
)
}
@discardableResult
static func request(
providerType: ProviderType = .normal,
_ target: some TargetType,
callbackQueue: DispatchQueue? = nil,
progressHandler: ProgressBlock? = nil,
completion: @escaping (_ result: Result<Data, Error>) -> Void
) -> Moya.Cancellable {
let provider: MoyaProvider<MultiTarget> =
switch providerType {
case .normal:
self.provider
case let .mockSuccess(data):
self.mockProvider(.success(data))
case let .mockFailure(error):
self.mockProvider(.failure(error))
}
return
provider
.request(MultiTarget(target), callbackQueue: callbackQueue, progress: progressHandler) {
result in
switch result {
case let .success(rsp):
completion(.success(rsp.data))
case let .failure(error):
completion(.failure(error))
}
}
}
@discardableResult
static func request(
providerType: ProviderType = .normal,
_ target: some TargetType,
callbackQueue: DispatchQueue? = nil,
progressHandler: ProgressBlock? = nil,
completion: @escaping (_ result: Result<Response, Error>) -> Void
) -> Moya.Cancellable {
let provider: MoyaProvider<MultiTarget> =
switch providerType {
case .normal:
self.provider
case let .mockSuccess(data):
self.mockProvider(.success(data))
case let .mockFailure(error):
self.mockProvider(.failure(error))
}
return
provider
.request(MultiTarget(target), callbackQueue: callbackQueue, progress: progressHandler) {
result in
switch result {
case let .success(rsp):
completion(.success(rsp))
case let .failure(error):
completion(.failure(error))
}
}
}
// Async
static func decodableRequest<T: DecodableTargetType>(
_ target: T,
decodingStrategy: JSONDecoder
.KeyDecodingStrategy = .useDefaultKeys
) async throws -> T
.ResultType
{
try await withCheckedThrowingContinuation { continuation in
HttpClient.decodableRequest(decodingStrategy: decodingStrategy, target, callbackQueue: nil) {
result in
switch result {
case let .success(response):
continuation.resume(returning: response)
case let .failure(error):
continuation.resume(throwing: error)
}
}
}
}
@discardableResult
static func request(_ target: some TargetType, progressHandler: ProgressBlock? = nil)
async throws -> Data?
{
try await withCheckedThrowingContinuation { continuation in
HttpClient.request(target, callbackQueue: nil, progressHandler: progressHandler) {
result in
switch result {
case let .success(response):
continuation.resume(returning: response)
case let .failure(error):
continuation.resume(throwing: error)
}
}
}
}
}

View File

@@ -0,0 +1,9 @@
import Foundation
enum HttpClientError: Error {
case missingParams
case invalidateParams
}

View File

@@ -0,0 +1,39 @@
import Foundation
import Moya
@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
extension MoyaProvider where Target == MultiTarget {
func decodableRequest<T: DecodableTargetType>(
_ target: T,
decodingStrategy: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys,
callbackQueue: DispatchQueue? = nil,
completion: @escaping (_ result: Result<T.ResultType, Error>) -> Void
) -> Moya.Cancellable {
request(MultiTarget(target), callbackQueue: callbackQueue) { [weak self] result in
switch result {
case let .success(response):
do {
let JSONDecoder = JSONDecoder()
JSONDecoder.keyDecodingStrategy = decodingStrategy
let responseObject = try response.map(
T.ResultType.self,
atKeyPath: target.decodeAtKeyPath,
using: JSONDecoder
)
completion(.success(responseObject))
} catch {
completion(.failure(error))
self?.logDecodeError(error)
}
case let .failure(error):
completion(.failure(error))
}
}
}
private func logDecodeError(_ error: Error) {
print("===================================================================")
print("🔴 Decode Error: \(error)")
print("===================================================================")
}
}

View File

@@ -0,0 +1,44 @@
import Foundation
import Moya
struct CursorAggregatedUsageEventsAPI: DecodableTargetType {
typealias ResultType = CursorAggregatedUsageEventsResponse
let teamId: Int?
let startDate: Int64
private let cookieHeader: String?
var baseURL: URL { APIConfig.baseURL }
var path: String { "/api/dashboard/get-aggregated-usage-events" }
var method: Moya.Method { .post }
var task: Task {
var params: [String: Any] = [
"startDate": self.startDate
]
if let teamId = self.teamId {
params["teamId"] = teamId
}
return .requestParameters(parameters: params, encoding: JSONEncoding.default)
}
var headers: [String: String]? { APIHeadersBuilder.jsonHeaders(cookieHeader: self.cookieHeader) }
var sampleData: Data {
Data("""
{
"aggregations": [],
"totalInputTokens": "0",
"totalOutputTokens": "0",
"totalCacheWriteTokens": "0",
"totalCacheReadTokens": "0",
"totalCostCents": 0.0
}
""".utf8)
}
init(teamId: Int?, startDate: Int64, cookieHeader: String?) {
self.teamId = teamId
self.startDate = startDate
self.cookieHeader = cookieHeader
}
}

View File

@@ -0,0 +1,30 @@
import Foundation
import Moya
struct CursorCurrentBillingCycleAPI: DecodableTargetType {
typealias ResultType = CursorCurrentBillingCycleResponse
private let cookieHeader: String?
var baseURL: URL { APIConfig.baseURL }
var path: String { "/api/dashboard/get-current-billing-cycle" }
var method: Moya.Method { .post }
var task: Task {
.requestParameters(parameters: [:], encoding: JSONEncoding.default)
}
var headers: [String: String]? { APIHeadersBuilder.jsonHeaders(cookieHeader: self.cookieHeader) }
var sampleData: Data {
Data("""
{
"startDateEpochMillis": "1763891472000",
"endDateEpochMillis": "1764496272000"
}
""".utf8)
}
init(cookieHeader: String?) {
self.cookieHeader = cookieHeader
}
}

View File

@@ -0,0 +1,40 @@
import Foundation
import Moya
import VibeviewerModel
struct CursorFilteredUsageAPI: DecodableTargetType {
typealias ResultType = CursorFilteredUsageResponse
let startDateMs: String
let endDateMs: String
let userId: Int
let page: Int
private let cookieHeader: String?
var baseURL: URL { APIConfig.baseURL }
var path: String { "/api/dashboard/get-filtered-usage-events" }
var method: Moya.Method { .post }
var task: Task {
let params: [String: Any] = [
"startDate": self.startDateMs,
"endDate": self.endDateMs,
"userId": self.userId,
"page": self.page,
"pageSize": 100
]
return .requestParameters(parameters: params, encoding: JSONEncoding.default)
}
var headers: [String: String]? { APIHeadersBuilder.jsonHeaders(cookieHeader: self.cookieHeader) }
var sampleData: Data {
Data("{\"totalUsageEventsCount\":1,\"usageEventsDisplay\":[]}".utf8)
}
init(startDateMs: String, endDateMs: String, userId: Int, page: Int, cookieHeader: String?) {
self.startDateMs = startDateMs
self.endDateMs = endDateMs
self.userId = userId
self.page = page
self.cookieHeader = cookieHeader
}
}

View File

@@ -0,0 +1,19 @@
import Foundation
import Moya
import VibeviewerModel
struct CursorGetMeAPI: DecodableTargetType {
typealias ResultType = CursorMeResponse
var baseURL: URL { APIConfig.baseURL }
var path: String { "/api/dashboard/get-me" }
var method: Moya.Method { .get }
var task: Task { .requestPlain }
var headers: [String: String]? { APIHeadersBuilder.jsonHeaders(cookieHeader: self.cookieHeader) }
var sampleData: Data {
Data("{\"authId\":\"\",\"userId\":0,\"email\":\"\",\"workosId\":\"\",\"teamId\":0,\"isEnterpriseUser\":false}".utf8)
}
private let cookieHeader: String?
init(cookieHeader: String?) { self.cookieHeader = cookieHeader }
}

View File

@@ -0,0 +1,42 @@
import Foundation
import Moya
struct CursorGetTeamSpendAPI: DecodableTargetType {
typealias ResultType = CursorTeamSpendResponse
let teamId: Int
let page: Int
let pageSize: Int
let sortBy: String
let sortDirection: String
private let cookieHeader: String?
var baseURL: URL { APIConfig.baseURL }
var path: String { "/api/dashboard/get-team-spend" }
var method: Moya.Method { .post }
var task: Task {
let params: [String: Any] = [
"teamId": self.teamId,
"page": self.page,
"pageSize": self.pageSize,
"sortBy": self.sortBy,
"sortDirection": self.sortDirection
]
return .requestParameters(parameters: params, encoding: JSONEncoding.default)
}
var headers: [String: String]? { APIHeadersBuilder.jsonHeaders(cookieHeader: self.cookieHeader) }
var sampleData: Data {
Data("{\n \"teamMemberSpend\": [],\n \"subscriptionCycleStart\": \"0\",\n \"totalMembers\": 0,\n \"totalPages\": 0,\n \"totalByRole\": [],\n \"nextCycleStart\": \"0\",\n \"limitedUserCount\": 0,\n \"maxUserSpendCents\": 0,\n \"subscriptionLimitedUsers\": 0\n}".utf8)
}
init(teamId: Int, page: Int = 1, pageSize: Int = 50, sortBy: String = "name", sortDirection: String = "asc", cookieHeader: String?) {
self.teamId = teamId
self.page = page
self.pageSize = pageSize
self.sortBy = sortBy
self.sortDirection = sortDirection
self.cookieHeader = cookieHeader
}
}

View File

@@ -0,0 +1,36 @@
import Foundation
import Moya
struct CursorTeamModelsAnalyticsAPI: DecodableTargetType {
typealias ResultType = CursorTeamModelsAnalyticsResponse
let startDate: String
let endDate: String
let c: String
private let cookieHeader: String?
var baseURL: URL { APIConfig.baseURL }
var path: String { "/api/v2/analytics/team/models" }
var method: Moya.Method { .get }
var task: Task {
let params: [String: Any] = [
"startDate": self.startDate,
"endDate": self.endDate,
"c": self.c
]
return .requestParameters(parameters: params, encoding: URLEncoding.default)
}
var headers: [String: String]? { APIHeadersBuilder.basicHeaders(cookieHeader: self.cookieHeader) }
var sampleData: Data {
Data("{}".utf8)
}
init(startDate: String, endDate: String, c: String, cookieHeader: String?) {
self.startDate = startDate
self.endDate = endDate
self.c = c
self.cookieHeader = cookieHeader
}
}

View File

@@ -0,0 +1,47 @@
import Foundation
import Moya
struct CursorUsageSummaryAPI: DecodableTargetType {
typealias ResultType = CursorUsageSummaryResponse
let cookieHeader: String
var baseURL: URL {
URL(string: "https://cursor.com")!
}
var path: String {
"/api/usage-summary"
}
var method: Moya.Method {
.get
}
var task: Moya.Task {
.requestPlain
}
var headers: [String: String]? {
[
"accept": "*/*",
"accept-language": "zh-CN,zh;q=0.9",
"cache-control": "no-cache",
"dnt": "1",
"pragma": "no-cache",
"priority": "u=1, i",
"referer": "https://cursor.com/dashboard?tab=usage",
"sec-ch-ua": "\"Not=A?Brand\";v=\"24\", \"Chromium\";v=\"140\"",
"sec-ch-ua-arch": "\"arm\"",
"sec-ch-ua-bitness": "\"64\"",
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": "\"macOS\"",
"sec-ch-ua-platform-version": "\"15.3.1\"",
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
"Cookie": cookieHeader
]
}
}

View File

@@ -0,0 +1,16 @@
import Foundation
import Moya
protocol DecodableTargetType: TargetType {
associatedtype ResultType: Decodable
var decodeAtKeyPath: String? { get }
}
extension DecodableTargetType {
var decodeAtKeyPath: String? { nil }
var validationType: ValidationType {
.successCodes
}
}

View File

@@ -0,0 +1,6 @@
import Testing
@Test func placeholderTest() async throws {
// Placeholder test to ensure test target builds correctly
#expect(true)
}

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)
}
}

View File

@@ -0,0 +1,17 @@
// swift-tools-version:5.10
import PackageDescription
let package = Package(
name: "VibeviewerCore",
platforms: [
.macOS(.v14)
],
products: [
.library(name: "VibeviewerCore", targets: ["VibeviewerCore"]),
],
dependencies: [],
targets: [
.target(name: "VibeviewerCore", dependencies: []),
.testTarget(name: "VibeviewerCoreTests", dependencies: ["VibeviewerCore"])
]
)

View File

@@ -0,0 +1,22 @@
//
// Data+E.swift
// HttpClient
//
// Created by Groot chen on 2024/9/6.
//
import Foundation
public extension Data {
func toPrettyPrintedJSONString() -> String? {
if let json = try? JSONSerialization.jsonObject(with: self),
let data = try? JSONSerialization.data(
withJSONObject: json,
options: [.prettyPrinted, .withoutEscapingSlashes]
)
{
return String(data: data, encoding: .utf8)
}
return nil
}
}

View File

@@ -0,0 +1,33 @@
import Foundation
public extension Date {
///
public var millisecondsSince1970String: String {
String(Int(self.timeIntervalSince1970 * 1000))
}
/// Date
public static func fromMillisecondsString(_ msString: String) -> Date? {
guard let ms = Double(msString) else { return nil }
return Date(timeIntervalSince1970: ms / 1000.0)
}
}
public extension Calendar {
/// [start, end]
public func dayRange(for date: Date) -> (start: Date, end: Date) {
let startOfDay = self.startOfDay(for: date)
let nextDay = self.date(byAdding: .day, value: 1, to: startOfDay) ?? date
let endOfDay = Date(timeInterval: -0.001, since: nextDay)
return (startOfDay, endOfDay)
}
/// 00:00 [yesterdayStart, now]
public func yesterdayToNowRange(from now: Date = Date()) -> (start: Date, end: Date) {
let startOfToday = self.startOfDay(for: now)
let startOfYesterday = self.date(byAdding: .day, value: -1, to: startOfToday) ?? now
return (startOfYesterday, now)
}
}

View File

@@ -0,0 +1,121 @@
import Foundation
public enum DateUtils {
public enum TimeFormat {
case hm // HH:mm
case hms // HH:mm:ss
fileprivate var dateFormat: String {
switch self {
case .hm: return "HH:mm"
case .hms: return "HH:mm:ss"
}
}
}
/// [start, end]
public static func dayRange(for date: Date, calendar: Calendar = .current) -> (start: Date, end: Date) {
let startOfDay = calendar.startOfDay(for: date)
let nextDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) ?? date
let endOfDay = Date(timeInterval: -0.001, since: nextDay)
return (startOfDay, endOfDay)
}
/// 00:00 [yesterdayStart, now]
public static func yesterdayToNowRange(from now: Date = Date(), calendar: Calendar = .current) -> (start: Date, end: Date) {
let startOfToday = calendar.startOfDay(for: now)
let startOfYesterday = calendar.date(byAdding: .day, value: -1, to: startOfToday) ?? now
return (startOfYesterday, now)
}
/// 7 00:00 00:00 [sevenDaysAgoStart, tomorrowStart]
public static func sevenDaysAgoToNowRange(from now: Date = Date(), calendar: Calendar = .current) -> (start: Date, end: Date) {
let startOfToday = calendar.startOfDay(for: now)
let startOfSevenDaysAgo = calendar.date(byAdding: .day, value: -7, to: startOfToday) ?? now
let startOfTomorrow = calendar.date(byAdding: .day, value: 1, to: startOfToday) ?? now
return (startOfSevenDaysAgo, startOfTomorrow)
}
/// 00:00 00:00 [nDaysAgoStart, tomorrowStart]
public static func daysAgoToNowRange(days: Int, from now: Date = Date(), calendar: Calendar = .current) -> (start: Date, end: Date) {
let startOfToday = calendar.startOfDay(for: now)
let startOfNDaysAgo = calendar.date(byAdding: .day, value: -days, to: startOfToday) ?? now
let startOfTomorrow = calendar.date(byAdding: .day, value: 1, to: startOfToday) ?? now
return (startOfNDaysAgo, startOfTomorrow)
}
/// Date
public static func millisecondsString(from date: Date) -> String {
String(Int(date.timeIntervalSince1970 * 1000))
}
/// Date
public static func date(fromMillisecondsString msString: String) -> Date? {
guard let ms = Double(msString) else { return nil }
return Date(timeIntervalSince1970: ms / 1000.0)
}
/// Date HH:mm:ss
public static func timeString(from date: Date,
format: TimeFormat = .hms,
timeZone: TimeZone = .current,
locale: Locale = Locale(identifier: "en_US_POSIX")) -> String {
let formatter = DateFormatter()
formatter.locale = locale
formatter.timeZone = timeZone
formatter.dateFormat = format.dateFormat
return formatter.string(from: date)
}
///
public static func timeString(fromMilliseconds ms: Int64,
format: TimeFormat = .hms,
timeZone: TimeZone = .current,
locale: Locale = .current) -> String {
let date = Date(timeIntervalSince1970: TimeInterval(ms) / 1000.0)
return timeString(from: date, format: format, timeZone: timeZone, locale: locale)
}
///
public static func timeString(fromSeconds s: Int64,
format: TimeFormat = .hms,
timeZone: TimeZone = .current,
locale: Locale = .current) -> String {
let date = Date(timeIntervalSince1970: TimeInterval(s))
return timeString(from: date, format: format, timeZone: timeZone, locale: locale)
}
///
public static func timeString(fromMillisecondsString msString: String,
format: TimeFormat = .hms,
timeZone: TimeZone = .current,
locale: Locale = .current) -> String {
guard let ms = Int64(msString) else { return "" }
return timeString(fromMilliseconds: ms, format: format, timeZone: timeZone, locale: locale)
}
/// Date YYYY-MM-DD
public static func dateString(from date: Date, calendar: Calendar = .current) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
}
/// API
/// 使 UTC
/// n
public static func daysAgoToTodayRange(days: Int, from now: Date = Date(), calendar: Calendar = .current) -> (start: String, end: String) {
// 使 UTC dateString
var utcCalendar = Calendar(identifier: .gregorian)
utcCalendar.timeZone = TimeZone(secondsFromGMT: 0)!
let startOfToday = utcCalendar.startOfDay(for: now)
// (days-1) days
let startOfNDaysAgo = utcCalendar.date(byAdding: .day, value: -(days - 1), to: startOfToday) ?? now
return (dateString(from: startOfNDaysAgo, calendar: utcCalendar), dateString(from: startOfToday, calendar: utcCalendar))
}
}

View File

@@ -0,0 +1,7 @@
import Foundation
public extension Int {
var dollarStringFromCents: String {
"$" + String(format: "%.2f", Double(self) / 100.0)
}
}

View File

@@ -0,0 +1,36 @@
import Foundation
import ServiceManagement
public protocol LaunchAtLoginService {
var isEnabled: Bool { get }
func setEnabled(_ enabled: Bool) -> Bool
}
public final class DefaultLaunchAtLoginService: LaunchAtLoginService {
public init() {}
public var isEnabled: Bool {
SMAppService.mainApp.status == .enabled
}
public func setEnabled(_ enabled: Bool) -> Bool {
do {
if enabled {
if SMAppService.mainApp.status == .enabled {
return true
}
try SMAppService.mainApp.register()
return true
} else {
if SMAppService.mainApp.status != .enabled {
return true
}
try SMAppService.mainApp.unregister()
return true
}
} catch {
print("Failed to \(enabled ? "enable" : "disable") launch at login: \(error)")
return false
}
}
}

View File

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

View File

@@ -0,0 +1,26 @@
// swift-tools-version:5.10
import PackageDescription
let package = Package(
name: "VibeviewerLoginUI",
platforms: [
.macOS(.v14)
],
products: [
.library(name: "VibeviewerLoginUI", targets: ["VibeviewerLoginUI"]),
],
dependencies: [
.package(path: "../VibeviewerShareUI")
],
targets: [
.target(
name: "VibeviewerLoginUI",
dependencies: [
"VibeviewerShareUI"
]
),
.testTarget(name: "VibeviewerLoginUITests", dependencies: ["VibeviewerLoginUI"])
]
)

View File

@@ -0,0 +1,52 @@
import SwiftUI
import WebKit
struct CookieWebView: NSViewRepresentable {
let onCookieCaptured: (String) -> Void
func makeNSView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
let webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = context.coordinator
if let url =
URL(
string: "https://authenticator.cursor.sh/"
)
{
webView.load(URLRequest(url: url))
}
return webView
}
func updateNSView(_ nsView: WKWebView, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(onCookieCaptured: self.onCookieCaptured)
}
final class Coordinator: NSObject, WKNavigationDelegate {
let onCookieCaptured: (String) -> Void
init(onCookieCaptured: @escaping (String) -> Void) {
self.onCookieCaptured = onCookieCaptured
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
if webView.url?.absoluteString.hasSuffix("/dashboard") == true {
self.captureCursorCookies(from: webView)
}
}
private func captureCursorCookies(from webView: WKWebView) {
webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in
let relevant = cookies.filter { cookie in
let domain = cookie.domain.lowercased()
return domain.contains("cursor.com")
}
guard !relevant.isEmpty else { return }
let headerString = relevant.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
self.onCookieCaptured(headerString)
}
}
}
}

View File

@@ -0,0 +1,12 @@
import SwiftUI
private struct LoginWindowManagerKey: EnvironmentKey {
static let defaultValue: LoginWindowManager = .shared
}
public extension EnvironmentValues {
var loginWindowManager: LoginWindowManager {
get { self[LoginWindowManagerKey.self] }
set { self[LoginWindowManagerKey.self] = newValue }
}
}

View File

@@ -0,0 +1,38 @@
import AppKit
import SwiftUI
public final class LoginWindowManager {
public static let shared = LoginWindowManager()
private var controller: LoginWindowController?
public func show(onCookieCaptured: @escaping (String) -> Void) {
if let controller {
controller.showWindow(nil)
controller.window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return
}
let controller = LoginWindowController(onCookieCaptured: { [weak self] cookie in
onCookieCaptured(cookie)
self?.close()
})
self.controller = controller
controller.window?.center()
controller.showWindow(nil)
controller.window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
if let hosting = controller.contentViewController as? NSHostingController<CursorLoginView> {
hosting.rootView = CursorLoginView(onCookieCaptured: { cookie in
onCookieCaptured(cookie)
self.close()
}, onClose: { [weak self] in
self?.close()
})
}
}
public func close() {
self.controller?.close()
self.controller = nil
}
}

View File

@@ -0,0 +1,20 @@
import AppKit
import SwiftUI
final class LoginWindowController: NSWindowController, NSWindowDelegate {
private var onCookieCaptured: ((String) -> Void)?
convenience init(onCookieCaptured: @escaping (String) -> Void) {
let vc = NSHostingController(rootView: CursorLoginView(onCookieCaptured: { cookie in
onCookieCaptured(cookie)
}, onClose: {}))
let window = NSWindow(contentViewController: vc)
window.title = "Cursor 登录"
window.setContentSize(NSSize(width: 900, height: 680))
window.styleMask = [.titled, .closable, .miniaturizable, .resizable]
window.isReleasedWhenClosed = false
self.init(window: window)
self.onCookieCaptured = onCookieCaptured
self.window?.delegate = self
}
}

View File

@@ -0,0 +1,16 @@
import SwiftUI
@MainActor
struct CursorLoginView: View {
let onCookieCaptured: (String) -> Void
let onClose: () -> Void
var body: some View {
VStack(spacing: 0) {
CookieWebView(onCookieCaptured: { cookie in
self.onCookieCaptured(cookie)
self.onClose()
})
}
}
}

View File

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

View File

@@ -0,0 +1,42 @@
{
"originHash" : "9306278cf3775247b97d318b7dce25c7fee6729b83694f52dd8be9b737c35483",
"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,36 @@
// swift-tools-version:5.10
import PackageDescription
let package = Package(
name: "VibeviewerMenuUI",
platforms: [
.macOS(.v14)
],
products: [
.library(name: "VibeviewerMenuUI", targets: ["VibeviewerMenuUI"])
],
dependencies: [
.package(path: "../VibeviewerCore"),
.package(path: "../VibeviewerModel"),
.package(path: "../VibeviewerAppEnvironment"),
.package(path: "../VibeviewerAPI"),
.package(path: "../VibeviewerLoginUI"),
.package(path: "../VibeviewerSettingsUI"),
.package(path: "../VibeviewerShareUI"),
],
targets: [
.target(
name: "VibeviewerMenuUI",
dependencies: [
"VibeviewerCore",
"VibeviewerModel",
"VibeviewerAppEnvironment",
"VibeviewerAPI",
"VibeviewerLoginUI",
"VibeviewerSettingsUI",
"VibeviewerShareUI"
]
),
.testTarget(name: "VibeviewerMenuUITests", dependencies: ["VibeviewerMenuUI"]),
]
)

View File

@@ -0,0 +1,29 @@
import SwiftUI
import VibeviewerLoginUI
@MainActor
struct ActionButtonsView: View {
let isLoading: Bool
let isLoggedIn: Bool
let onRefresh: () -> Void
let onLogin: () -> Void
let onLogout: () -> Void
let onSettings: () -> Void
var body: some View {
HStack(spacing: 10) {
if self.isLoading {
ProgressView()
} else {
Button("刷新") { self.onRefresh() }
}
if !self.isLoggedIn {
Button("登录") { self.onLogin() }
} else {
Button("退出登录") { self.onLogout() }
}
Button("设置") { self.onSettings() }
}
}
}

View File

@@ -0,0 +1,45 @@
import SwiftUI
import VibeviewerShareUI
@MainActor
struct DashboardErrorView: View {
let message: String
let onRetry: (() -> Void)?
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Color.red.opacity(0.9))
Text("Failed to Refresh Data")
.font(.app(.satoshiBold, size: 12))
}
Text(message)
.font(.app(.satoshiMedium, size: 11))
.foregroundStyle(.secondary)
if let onRetry {
Button {
onRetry()
} label: {
Text("Retry")
}
.buttonStyle(.vibe(.primary))
.controlSize(.small)
}
}
.padding(10)
.maxFrame(true, false, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.red.opacity(0.08))
)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.red.opacity(0.25), lineWidth: 1)
)
}
}

View File

@@ -0,0 +1,14 @@
import SwiftUI
@MainActor
struct ErrorBannerView: View {
let message: String?
var body: some View {
if let msg = message, !msg.isEmpty {
Text(msg)
.foregroundStyle(.red)
.font(.caption)
}
}
}

View File

@@ -0,0 +1,29 @@
import SwiftUI
import VibeviewerModel
import VibeviewerShareUI
///
struct MembershipBadge: View {
let membershipType: MembershipType
let isEnterpriseUser: Bool
var body: some View {
Text(membershipType.displayName(isEnterprise: isEnterpriseUser))
.font(.app(.satoshiMedium, size: 12))
.foregroundStyle(.secondary)
}
}
#Preview {
VStack(spacing: 12) {
MembershipBadge(membershipType: .free, isEnterpriseUser: false)
MembershipBadge(membershipType: .freeTrial, isEnterpriseUser: false)
MembershipBadge(membershipType: .pro, isEnterpriseUser: false)
MembershipBadge(membershipType: .proPlus, isEnterpriseUser: false)
MembershipBadge(membershipType: .ultra, isEnterpriseUser: false)
MembershipBadge(membershipType: .enterprise, isEnterpriseUser: false)
MembershipBadge(membershipType: .enterprise, isEnterpriseUser: true)
}
.padding()
}

View File

@@ -0,0 +1,62 @@
import SwiftUI
import VibeviewerShareUI
import VibeviewerAppEnvironment
import VibeviewerModel
import VibeviewerSettingsUI
struct MenuFooterView: View {
@Environment(\.dashboardRefreshService) private var refresher
@Environment(\.settingsWindowManager) private var settingsWindow
@Environment(AppSession.self) private var session
let onRefresh: () -> Void
var body: some View {
HStack(alignment: .center, spacing: 12) {
Button {
settingsWindow.show()
} label: {
Image(systemName: "gear")
.font(.system(size: 14))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
//
if let membershipType = session.snapshot?.usageSummary?.membershipType {
MembershipBadge(
membershipType: membershipType,
isEnterpriseUser: session.credentials?.isEnterpriseUser ?? false
)
}
Spacer()
Button {
onRefresh()
} label: {
HStack(spacing: 4) {
if refresher.isRefreshing {
ProgressView()
.controlSize(.mini)
.progressViewStyle(.circular)
.tint(.white)
.frame(width: 16, height: 16)
}
Text("Refresh")
.font(.app(.satoshiMedium, size: 12))
}
}
.buttonStyle(.vibe(Color(hex: "5B67E2").opacity(0.8)))
.animation(.easeInOut(duration: 0.2), value: refresher.isRefreshing)
Button {
NSApplication.shared.terminate(nil)
} label: {
Text("Quit")
.font(.app(.satoshiMedium, size: 12))
}
.buttonStyle(.vibe(Color(hex: "F58283").opacity(0.8)))
}
}
}

View File

@@ -0,0 +1,250 @@
import SwiftUI
import VibeviewerModel
import VibeviewerCore
import VibeviewerShareUI
import Foundation
struct MetricsViewDataSource: Equatable {
var icon: String
var title: String
var description: String?
var currentValue: String
var targetValue: String?
var progress: Double
var tint: Color
}
struct MetricsView: View {
enum MetricType {
case billing(MetricsViewDataSource)
case onDemand(MetricsViewDataSource)
case free(MetricsViewDataSource)
}
var metric: MetricType
var body: some View {
VStack(alignment: .leading, spacing: 12) {
switch metric {
case .billing(let dataSource):
MetricContentView(dataSource: dataSource)
case .onDemand(let dataSource):
MetricContentView(dataSource: dataSource)
case .free(let dataSource):
MetricContentView(dataSource: dataSource)
}
}
}
struct MetricContentView: View {
let dataSource: MetricsViewDataSource
@State var isHovering: Bool = false
@Environment(\.colorScheme) private var colorScheme
var tintColor: Color {
if isHovering {
return dataSource.tint
} else {
return dataSource.tint.opacity(colorScheme == .dark ? 0.5 : 0.8)
}
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .center, spacing: 12) {
HStack(alignment: .center, spacing: 4) {
Image(systemName: dataSource.icon)
.font(.system(size: 16))
.foregroundStyle(tintColor)
Text(dataSource.title)
.font(.app(.satoshiBold, size: 12))
.foregroundStyle(tintColor)
}
Spacer()
HStack(alignment: .lastTextBaseline, spacing: 0) {
if let target = dataSource.targetValue, !target.isEmpty {
Text(target)
.font(.app(.satoshiRegular, size: 12))
.foregroundStyle(.secondary)
Text(" / ")
.font(.app(.satoshiRegular, size: 12))
.foregroundStyle(.secondary)
Text(dataSource.currentValue)
.font(.app(.satoshiBold, size: 16))
.foregroundStyle(.primary)
.contentTransition(.numericText())
} else {
Text(dataSource.currentValue)
.font(.app(.satoshiBold, size: 16))
.foregroundStyle(.primary)
.contentTransition(.numericText())
}
}
}
progressBar(color: tintColor)
if let description = dataSource.description {
Text(description)
.font(.app(.satoshiRegular, size: 10))
.foregroundStyle(.secondary)
}
}
.animation(.easeInOut(duration: 0.2), value: isHovering)
.onHover { isHovering = $0 }
}
@ViewBuilder
func progressBar(color: Color) -> some View {
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 100)
.fill(Color(hex: "686868").opacity(0.5))
.frame(height: 4)
GeometryReader { proxy in
RoundedRectangle(cornerRadius: 100)
.fill(color)
.frame(width: proxy.size.width * dataSource.progress, height: 4)
}
.frame(height: 4)
}
}
}
}
extension DashboardSnapshot {
// MARK: - Subscription Expiry Configuration
/// Configuration for subscription expiry date calculation
/// Modify this enum to change expiry date behavior with minimal code changes
private enum SubscriptionExpiryRule {
case endOfCurrentMonth
case specificDaysFromNow(Int)
case endOfNextMonth
// Add more cases as needed
}
/// Current expiry rule - change this to modify expiry date calculation
private var currentExpiryRule: SubscriptionExpiryRule {
.endOfCurrentMonth // Can be easily changed to any other rule
}
// MARK: - Helper Properties for Expiry Date Calculation
/// Current subscription expiry date based on configured rule
private var subscriptionExpiryDate: Date {
let calendar = Calendar.current
let now = Date()
switch currentExpiryRule {
case .endOfCurrentMonth:
let endOfMonth = calendar.dateInterval(of: .month, for: now)?.end ?? now
return calendar.date(byAdding: .day, value: -1, to: endOfMonth) ?? now
case .specificDaysFromNow(let days):
return calendar.date(byAdding: .day, value: days, to: now) ?? now
case .endOfNextMonth:
let nextMonth = calendar.date(byAdding: .month, value: 1, to: now) ?? now
let endOfNextMonth = calendar.dateInterval(of: .month, for: nextMonth)?.end ?? now
return calendar.date(byAdding: .day, value: -1, to: endOfNextMonth) ?? now
}
}
/// Formatted expiry date string in yy:mm:dd format
private var expiryDateString: String {
let formatter = DateFormatter()
formatter.dateFormat = "yy:MM:dd"
return formatter.string(from: subscriptionExpiryDate)
}
/// Remaining days until subscription expiry
private var remainingDays: Int {
let calendar = Calendar.current
let days = calendar.dateComponents([.day], from: Date(), to: subscriptionExpiryDate).day ?? 0
return max(days, 1) // At least 1 day to avoid division by zero
}
/// Remaining balance in cents
private var remainingBalanceCents: Int {
return max((hardLimitDollars * 100) - spendingCents, 0)
}
/// Average daily spending allowance from remaining balance
private var averageDailyAllowance: String {
let dailyAllowanceCents = remainingBalanceCents / remainingDays
return dailyAllowanceCents.dollarStringFromCents
}
var billingMetrics: MetricsViewDataSource {
// usageSummary使
if let usageSummary = usageSummary {
let description = "Expires \(expiryDateString)"
// UsageSummary used/limit
return MetricsViewDataSource(
icon: "dollarsign.circle.fill",
title: "Plan Usage",
description: description,
currentValue: usageSummary.individualUsage.plan.used.dollarStringFromCents,
targetValue: usageSummary.individualUsage.plan.limit.dollarStringFromCents,
progress: min(Double(usageSummary.individualUsage.plan.used) / Double(usageSummary.individualUsage.plan.limit), 1),
tint: Color(hex: "55E07A")
)
} else {
// 退
let description = "Expires \(expiryDateString), \(averageDailyAllowance)/day remaining"
return MetricsViewDataSource(
icon: "dollarsign.circle.fill",
title: "Usage Spending",
description: description,
currentValue: spendingCents.dollarStringFromCents,
targetValue: (hardLimitDollars * 100).dollarStringFromCents,
progress: min(Double(spendingCents) / Double(hardLimitDollars * 100), 1),
tint: Color(hex: "55E07A")
)
}
}
var onDemandMetrics: MetricsViewDataSource? {
guard let usageSummary = usageSummary,
let onDemand = usageSummary.individualUsage.onDemand,
let limit = onDemand.limit else {
return nil
}
let description = "Expires \(expiryDateString)"
// UsageSummary used/limit
return MetricsViewDataSource(
icon: "bolt.circle.fill",
title: "On-Demand Usage",
description: description,
currentValue: onDemand.used.dollarStringFromCents,
targetValue: limit.dollarStringFromCents,
progress: min(Double(onDemand.used) / Double(limit), 1),
tint: Color(hex: "FF6B6B")
)
}
var freeUsageMetrics: MetricsViewDataSource? {
guard freeUsageCents > 0 else { return nil }
let description = "Free credits (team plan)"
return MetricsViewDataSource(
icon: "gift.circle.fill",
title: "Free Usage",
description: description,
currentValue: freeUsageCents.dollarStringFromCents,
targetValue: nil,
progress: 1.0,
tint: Color(hex: "4DA3FF")
)
}
}

View File

@@ -0,0 +1,332 @@
import SwiftUI
import VibeviewerModel
import VibeviewerCore
import Charts
struct ModelsUsageBarChartView: View {
let data: ModelsUsageChartData
@State private var selectedDate: String?
// 绿
// /绿便
private let mossGreenPalette: [Color] = [
Color(red: 0/255, green: 92/255, blue: 66/255), // 绿
Color(red: 24/255, green: 120/255, blue: 88/255), // 绿
Color(red: 16/255, green: 104/255, blue: 80/255), // 绿
Color(red: 40/255, green: 132/255, blue: 96/255), // 绿
Color(red: 6/255, green: 76/255, blue: 60/255) // 绿
]
/// palette
private let modelPrefixOffsets: [String: Int] = [
"gpt-": 0,
"claude-": 1,
"composer-": 2,
"grok-": 3,
"Other": 4
]
/// 7
private var displayedDataPoints: [ModelsUsageChartData.DataPoint] {
guard data.dataPoints.count > 7 else {
return data.dataPoints
}
return Array(data.dataPoints.suffix(7))
}
var body: some View {
if displayedDataPoints.isEmpty {
emptyView
} else {
VStack(alignment: .leading, spacing: 12) {
chartView
legendView
summaryView
}
}
}
private var emptyView: some View {
Text("暂无数据")
.font(.app(.satoshiRegular, size: 12))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 40)
}
private var chartView: some View {
Chart {
ForEach(displayedDataPoints, id: \.date) { item in
let stackedData = calculateStackedData(for: item)
ForEach(Array(stackedData.enumerated()), id: \.offset) { index, stackedItem in
BarMark(
x: .value("Date", item.dateLabel),
yStart: .value("Start", stackedItem.start),
yEnd: .value("End", stackedItem.end)
)
.foregroundStyle(barColor(for: stackedItem.modelName, dateLabel: item.dateLabel))
.cornerRadius(4)
.opacity(shouldDimBar(for: item.dateLabel) ? 0.4 : 1.0)
}
}
if let selectedDate = selectedDate,
let selectedItem = displayedDataPoints.first(where: { $0.dateLabel == selectedDate }) {
RuleMark(x: .value("Selected", selectedDate))
.lineStyle(StrokeStyle(lineWidth: 2, dash: [4]))
.foregroundStyle(Color.gray.opacity(0.3))
.annotation(
position: annotationPosition(for: selectedDate),
alignment: .center,
spacing: 8,
overflowResolution: AnnotationOverflowResolution(x: .disabled, y: .disabled)
) {
annotationView(for: selectedItem)
}
}
}
// X 使
.chartXScale(domain: displayedDataPoints.map { $0.dateLabel })
.chartXSelection(value: $selectedDate)
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisValueLabel {
if let intValue = value.as(Int.self) {
Text("\(intValue)")
.font(.app(.satoshiRegular, size: 10))
.foregroundStyle(.secondary)
}
}
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
.foregroundStyle(.secondary.opacity(0.2))
}
}
.chartXAxis {
AxisMarks { value in
AxisValueLabel {
if let stringValue = value.as(String.self) {
Text(stringValue)
.font(.app(.satoshiRegular, size: 9))
.foregroundStyle(.secondary)
}
}
}
}
.frame(height: 180)
.animation(.easeInOut(duration: 0.2), value: selectedDate)
}
private func barColor(for modelName: String, dateLabel: String) -> AnyShapeStyle {
let color = colorForModel(modelName)
if selectedDate == dateLabel {
return AnyShapeStyle(color.opacity(0.9))
} else {
return AnyShapeStyle(color.gradient)
}
}
private func colorForModel(_ modelName: String) -> Color {
// 1.
let prefixOffset: Int = {
for (prefix, offset) in modelPrefixOffsets {
if modelName.hasPrefix(prefix) {
return offset
}
}
// "Other"
return modelPrefixOffsets["Other"] ?? 0
}()
// 2. 使
let hash = abs(modelName.hashValue)
let index = (prefixOffset + hash) % mossGreenPalette.count
return mossGreenPalette[index]
}
private func shouldDimBar(for dateLabel: String) -> Bool {
guard selectedDate != nil else { return false }
return selectedDate != dateLabel
}
/// annotation
/// 使 topTrailing使 topLeading使 top
private func annotationPosition(for dateLabel: String) -> AnnotationPosition {
guard let selectedIndex = displayedDataPoints.firstIndex(where: { $0.dateLabel == dateLabel }) else {
return .top
}
let totalCount = displayedDataPoints.count
let middleIndex = totalCount / 2
if selectedIndex < middleIndex {
// 使 topTrailingannotation
return .topTrailing
} else if selectedIndex > middleIndex {
// 使 topLeadingannotation
return .topLeading
} else {
// 使 top
return .top
}
}
///
private func calculateStackedData(for item: ModelsUsageChartData.DataPoint) -> [(modelName: String, start: Int, end: Int)] {
var cumulativeY: Int = 0
var result: [(modelName: String, start: Int, end: Int)] = []
for modelUsage in item.modelUsages {
if modelUsage.requests > 0 {
result.append((
modelName: modelUsage.modelName,
start: cumulativeY,
end: cumulativeY + modelUsage.requests
))
cumulativeY += modelUsage.requests
}
}
return result
}
private var legendView: some View {
//
let uniqueModels = Set(displayedDataPoints.flatMap { $0.modelUsages.map { $0.modelName } })
.sorted()
// 8
let displayedModels = Array(uniqueModels.prefix(8))
return ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(displayedModels, id: \.self) { modelName in
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 2)
.fill(colorForModel(modelName).gradient)
.frame(width: 12, height: 12)
Text(modelName)
.font(.app(.satoshiRegular, size: 10))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
if uniqueModels.count > 8 {
Text("+\(uniqueModels.count - 8) more")
.font(.app(.satoshiRegular, size: 10))
.foregroundStyle(.secondary)
}
}
}
}
private func annotationView(for item: ModelsUsageChartData.DataPoint) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(item.dateLabel)
.font(.app(.satoshiMedium, size: 11))
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 3) {
ForEach(item.modelUsages.prefix(5), id: \.modelName) { modelUsage in
if modelUsage.requests > 0 {
HStack(spacing: 6) {
Circle()
.fill(colorForModel(modelUsage.modelName))
.frame(width: 6, height: 6)
Text("\(modelUsage.modelName): \(modelUsage.requests)")
.font(.app(.satoshiRegular, size: 11))
.foregroundStyle(.primary)
}
}
}
if item.modelUsages.count > 5 {
Text("... and \(item.modelUsages.count - 5) more")
.font(.app(.satoshiRegular, size: 10))
.foregroundStyle(.secondary)
.padding(.leading, 12)
}
if item.modelUsages.count > 1 {
Divider()
.padding(.vertical, 2)
Text("Total: \(item.totalValue)")
.font(.app(.satoshiBold, size: 13))
.foregroundStyle(.primary)
} else if let firstModel = item.modelUsages.first {
Text("\(firstModel.requests) requests")
.font(.app(.satoshiBold, size: 13))
.foregroundStyle(.primary)
.padding(.top, 2)
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.fixedSize(horizontal: true, vertical: false)
.background {
RoundedRectangle(cornerRadius: 8)
.fill(.background)
.shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2)
}
}
private var summaryView: some View {
HStack(spacing: 16) {
if let total = totalValue {
VStack(alignment: .leading, spacing: 2) {
Text("Total")
.font(.app(.satoshiRegular, size: 10))
.foregroundStyle(.secondary)
Text("\(total)")
.font(.app(.satoshiBold, size: 14))
.foregroundStyle(.primary)
}
}
if let avg = averageValue {
VStack(alignment: .leading, spacing: 2) {
Text("Average")
.font(.app(.satoshiRegular, size: 10))
.foregroundStyle(.secondary)
Text(String(format: "%.1f", avg))
.font(.app(.satoshiBold, size: 14))
.foregroundStyle(.primary)
}
}
if let max = maxValue {
VStack(alignment: .leading, spacing: 2) {
Text("Peak")
.font(.app(.satoshiRegular, size: 10))
.foregroundStyle(.secondary)
Text("\(max)")
.font(.app(.satoshiBold, size: 14))
.foregroundStyle(.primary)
}
}
Spacer()
}
.padding(.top, 8)
}
private var totalValue: Int? {
guard !displayedDataPoints.isEmpty else { return nil }
return displayedDataPoints.reduce(0) { $0 + $1.totalValue }
}
private var averageValue: Double? {
guard let total = totalValue, !displayedDataPoints.isEmpty else { return nil }
return Double(total) / Double(displayedDataPoints.count)
}
private var maxValue: Int? {
displayedDataPoints.map { $0.totalValue }.max()
}
}

View File

@@ -0,0 +1,115 @@
import SwiftUI
import VibeviewerModel
import VibeviewerCore
import VibeviewerShareUI
struct TotalCreditsUsageView: View {
let snapshot: DashboardSnapshot?
@State private var isModelsUsageExpanded: Bool = false
var body: some View {
VStack(alignment: .trailing, spacing: 6) {
if let billingCycleText {
Text(billingCycleText)
.font(.app(.satoshiRegular, size: 10))
.foregroundStyle(.secondary)
}
headerView
if isModelsUsageExpanded, let modelsUsageSummary = snapshot?.modelsUsageSummary {
modelsUsageDetailView(modelsUsageSummary)
}
Text(snapshot?.displayTotalUsageCents.dollarStringFromCents ?? "0")
.font(.app(.satoshiBold, size: 16))
.foregroundStyle(.primary)
.contentTransition(.numericText())
}
.maxFrame(true, false, alignment: .trailing)
}
private var headerView: some View {
HStack(alignment: .center, spacing: 4) {
Text("Total Credits Usage")
.font(.app(.satoshiRegular, size: 12))
.foregroundStyle(.secondary)
// /
if snapshot?.modelsUsageSummary != nil {
Image(systemName: "chevron.down")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.secondary)
.rotationEffect(.degrees(isModelsUsageExpanded ? 180 : 0))
}
}
.onTapGesture {
if snapshot?.modelsUsageSummary != nil {
isModelsUsageExpanded.toggle()
}
}
.maxFrame(true, false, alignment: .trailing)
}
private func modelsUsageDetailView(_ summary: ModelsUsageSummary) -> some View {
VStack(alignment: .leading, spacing: 12) {
ForEach(summary.modelsSortedByCost.prefix(5), id: \.modelName) { model in
UsageEventView.EventItemView(event: makeAggregateEvent(from: model))
}
}
}
/// UsageEvent UsageEventView.EventItemView UI
private func makeAggregateEvent(from model: ModelUsageInfo) -> UsageEvent {
let tokenUsage = TokenUsage(
outputTokens: model.outputTokens,
inputTokens: model.inputTokens,
totalCents: model.costCents,
cacheWriteTokens: model.cacheWriteTokens,
cacheReadTokens: model.cacheReadTokens
)
// occurredAtMs 使 "0"
return UsageEvent(
occurredAtMs: "0",
modelName: model.modelName,
kind: "aggregate",
requestCostCount: 0,
usageCostDisplay: model.formattedCost,
usageCostCents: Int(model.costCents.rounded()),
isTokenBased: true,
userDisplayName: "",
cursorTokenFee: 0,
tokenUsage: tokenUsage
)
}
/// "Billing cycle: Oct 1 Oct 31"
private var billingCycleText: String? {
guard
let startMs = snapshot?.billingCycleStartMs,
let endMs = snapshot?.billingCycleEndMs,
let startDate = Date.fromMillisecondsString(startMs),
let endDate = Date.fromMillisecondsString(endMs)
else {
return nil
}
let formatter = DateFormatter()
formatter.dateFormat = "MMM d"
let start = formatter.string(from: startDate)
let end = formatter.string(from: endDate)
return "\(start) \(end)"
}
private func formatNumber(_ number: Int) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.groupingSeparator = ","
return formatter.string(from: NSNumber(value: number)) ?? "\(number)"
}
}

View File

@@ -0,0 +1,149 @@
import SwiftUI
import VibeviewerShareUI
@MainActor
struct UnloginView: View {
enum LoginMethod: String, CaseIterable, Identifiable {
case web
case cookie
var id: String { rawValue }
var title: String {
switch self {
case .web:
return "Web Login"
case .cookie:
return "Cookie Login"
}
}
var description: String {
switch self {
case .web:
return "Open Cursor login page and automatically capture your cookies after login."
case .cookie:
return "Paste your Cursor cookie header (from browser Developer Tools) to log in directly."
}
}
}
let onWebLogin: () -> Void
let onCookieLogin: (String) -> Void
@State private var selectedLoginMethod: LoginMethod = .web
@State private var manualCookie: String = ""
@State private var manualCookieError: String?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Login to Cursor")
.font(.app(.satoshiBold, size: 16))
Text("Choose a login method that works best for you.")
.font(.app(.satoshiMedium, size: 11))
.foregroundStyle(.secondary)
Picker("Login Method", selection: $selectedLoginMethod) {
ForEach(LoginMethod.allCases) { method in
Text(method.title).tag(method)
}
}
.pickerStyle(.segmented)
.labelsHidden()
Text(selectedLoginMethod.description)
.font(.app(.satoshiMedium, size: 11))
.foregroundStyle(.secondary)
Group {
switch selectedLoginMethod {
case .web:
Button {
onWebLogin()
} label: {
Text("Login via Web")
}
.buttonStyle(.vibe(.primary))
case .cookie:
manualCookieLoginView
}
}
}
.maxFrame(true, false, alignment: .leading)
}
@ViewBuilder
private var manualCookieLoginView: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Cursor Cookie Header")
.font(.app(.satoshiMedium, size: 12))
TextEditor(text: $manualCookie)
.font(.system(.body, design: .monospaced))
.frame(minHeight: 80, maxHeight: 120)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.secondary.opacity(0.3), lineWidth: 1)
)
.overlay {
if manualCookie.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Text("Example:\nCookie: cursor_session=...; other_key=...")
.foregroundStyle(Color.secondary.opacity(0.7))
.font(.app(.satoshiMedium, size: 10))
.padding(6)
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .topLeading
)
}
}
if let error = manualCookieError {
Text(error)
.font(.app(.satoshiMedium, size: 10))
.foregroundStyle(.red)
}
HStack {
Spacer()
Button("Login with Cookie") {
submitManualCookie()
}
.buttonStyle(.vibe(.primary))
.disabled(normalizedCookieHeader(from: manualCookie).isEmpty)
}
}
}
private func submitManualCookie() {
let normalized = normalizedCookieHeader(from: manualCookie)
guard !normalized.isEmpty else {
manualCookieError = "Cookie header cannot be empty."
return
}
manualCookieError = nil
onCookieLogin(normalized)
}
/// Cookie
/// -
/// - `Cookie:` `cookie:`
private func normalizedCookieHeader(from input: String) -> String {
var value = input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return "" }
let lowercased = value.lowercased()
if lowercased.hasPrefix("cookie:") {
if let range = value.range(of: ":", options: .caseInsensitive) {
let afterColon = value[range.upperBound...]
value = String(afterColon).trimmingCharacters(in: .whitespacesAndNewlines)
}
}
return value
}
}

View File

@@ -0,0 +1,211 @@
import SwiftUI
import VibeviewerModel
import VibeviewerShareUI
import VibeviewerCore
struct UsageEventView: View {
var events: [UsageEvent]
@Environment(AppSettings.self) private var appSettings
var body: some View {
UsageEventViewBody(events: events, limit: appSettings.usageHistory.limit)
}
struct EventItemView: View {
let event: UsageEvent
@State private var isExpanded = false
// MARK: - Body
var body: some View {
VStack(alignment: .leading, spacing: 8) {
mainRowView
if isExpanded {
expandedDetailsView
}
}
.animation(.easeInOut(duration: 0.2), value: isExpanded)
}
// MARK: - Computed Properties
private var totalTokensDisplay: String {
let totalTokens = event.tokenUsage?.totalTokens ?? 0
let value = Double(totalTokens)
switch totalTokens {
case 0..<1_000:
return "\(totalTokens)"
case 1_000..<1_000_000:
return String(format: "%.1fK", value / 1_000.0)
case 1_000_000..<1_000_000_000:
return String(format: "%.2fM", value / 1_000_000.0)
default:
return String(format: "%.2fB", value / 1_000_000_000.0)
}
}
private var costDisplay: String {
let totalCents = (event.tokenUsage?.totalCents ?? 0.0) + event.cursorTokenFee
let dollars = totalCents / 100.0
return String(format: "$%.2f", dollars)
}
private var tokenDetails: [(label: String, value: Int)] {
let rawDetails: [(String, Int)] = [
("Input", event.tokenUsage?.inputTokens ?? 0),
("Output", event.tokenUsage?.outputTokens ?? 0),
("Cache Write", event.tokenUsage?.cacheWriteTokens ?? 0),
("Cache Read", event.tokenUsage?.cacheReadTokens ?? 0),
("Total Tokens", event.tokenUsage?.totalTokens ?? 0),
]
return rawDetails
}
// MARK: - Subviews
private var brandLogoView: some View {
event.brand.logo
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 18, height: 18)
.padding(6)
.background(.thinMaterial, in: .circle)
}
private var modelNameView: some View {
Text(event.modelName)
.font(.app(.satoshiBold, size: 14))
.lineLimit(1)
// .foregroundStyle(event.kind.isError ? AnyShapeStyle(Color.red.secondary) : AnyShapeStyle(.primary))
}
private var tokenCostView: some View {
HStack(spacing: 12) {
Text(totalTokensDisplay)
.font(.app(.satoshiMedium, size: 12))
.foregroundStyle(.secondary)
.monospacedDigit()
Text(costDisplay)
.font(.app(.satoshiMedium, size: 12))
.foregroundStyle(.secondary)
.monospacedDigit()
}
.layoutPriority(1)
}
private var mainRowView: some View {
HStack(spacing: 12) {
brandLogoView
modelNameView
Spacer()
tokenCostView
}
.contentShape(Rectangle())
.onTapGesture {
isExpanded.toggle()
}
}
private func tokenDetailRowView(for detail: (String, Int)) -> some View {
HStack {
Text(detail.0)
.font(.app(.satoshiRegular, size: 12))
.foregroundStyle(.secondary)
.frame(width: 70, alignment: .leading)
Spacer()
Text("\(detail.1)")
.font(.app(.satoshiMedium, size: 12))
.foregroundStyle(.primary)
.monospacedDigit()
}
.padding(.horizontal, 12)
}
private var expandedDetailsView: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(tokenDetails, id: \.0) { detail in
tokenDetailRowView(for: detail)
}
}
.padding(.vertical, 4)
.transition(.opacity)
.fixedSize(horizontal: false, vertical: true)
}
}
}
struct UsageEventViewBody: View {
let events: [UsageEvent]
let limit: Int
private var groups: [UsageEventHourGroup] {
Array(events.prefix(limit)).groupedByHour()
}
var body: some View {
UsageEventGroupsView(groups: groups)
}
}
struct UsageEventGroupsView: View {
let groups: [UsageEventHourGroup]
var body: some View {
VStack(alignment: .leading, spacing: 16) {
ForEach(groups) { group in
HourGroupSectionView(group: group)
}
}
}
}
struct HourGroupSectionView: View {
let group: UsageEventHourGroup
var body: some View {
let totalRequestsText: String = String(group.totalRequests)
let totalCostText: String = {
let totalCents = group.events.reduce(0.0) { sum, event in
sum + (event.tokenUsage?.totalCents ?? 0.0) + event.cursorTokenFee
}
let dollars = totalCents / 100.0
return String(format: "$%.2f", dollars)
}()
return VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Text(group.title)
.font(.app(.satoshiBold, size: 12))
.foregroundStyle(.secondary)
Spacer()
HStack(spacing: 6) {
HStack(alignment: .center, spacing: 2) {
Image(systemName: "arrow.triangle.2.circlepath")
.font(.app(.satoshiMedium, size: 10))
.foregroundStyle(.primary)
Text(totalRequestsText)
.font(.app(.satoshiMedium, size: 12))
.foregroundStyle(.secondary)
}
HStack(alignment: .center, spacing: 2) {
Image(systemName: "dollarsign.circle")
.font(.app(.satoshiMedium, size: 10))
.foregroundStyle(.primary)
Text(totalCostText)
.font(.app(.satoshiMedium, size: 12))
.foregroundStyle(.secondary)
}
}
}
ForEach(group.events, id: \.occurredAtMs) { event in
UsageEventView.EventItemView(event: event)
}
}
}
}

View File

@@ -0,0 +1,26 @@
import SwiftUI
import Observation
import VibeviewerModel
import VibeviewerShareUI
struct UsageHeaderView: View {
enum Action {
case dashboard
}
var action: (Action) -> Void
var body: some View {
HStack {
Text("VibeViewer")
.font(.app(.satoshiMedium, size: 16))
.foregroundStyle(.primary)
Spacer()
Button("Dashboard") {
action(.dashboard)
}
.buttonStyle(.vibe(.secondary))
}
}
}

View File

@@ -0,0 +1,64 @@
import SwiftUI
import VibeviewerModel
@MainActor
struct UsageHistorySection: View {
let isLoading: Bool
@Bindable var settings: AppSettings
let events: [UsageEvent]
let onReload: () -> Void
let onToday: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Divider()
HStack {
Spacer()
Stepper("条数: \(self.settings.usageHistory.limit)", value: self.$settings.usageHistory.limit, in: 1 ... 100)
.frame(minWidth: 120)
}
.font(.callout)
HStack(spacing: 10) {
if self.isLoading {
ProgressView()
} else {
Button("加载用量历史") { self.onReload() }
}
Button("今天") { self.onToday() }
}
if !self.events.isEmpty {
VStack(alignment: .leading, spacing: 6) {
ForEach(Array(self.events.prefix(self.settings.usageHistory.limit).enumerated()), id: \.offset) { _, e in
HStack(alignment: .top, spacing: 8) {
Text(self.formatTimestamp(e.occurredAtMs))
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 120, alignment: .leading)
Text(e.modelName)
.font(.callout)
.frame(minWidth: 90, alignment: .leading)
Spacer(minLength: 6)
Text("req: \(e.requestCostCount)")
.font(.caption)
Text(e.usageCostDisplay)
.font(.caption)
}
}
}
.padding(.top, 4)
} else {
Text("暂无用量历史").font(.caption).foregroundStyle(.secondary)
}
}
}
private func formatTimestamp(_ msString: String) -> String {
guard let ms = Double(msString) else { return msString }
let date = Date(timeIntervalSince1970: ms / 1000.0)
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
return formatter.string(from: date)
}
}

View File

@@ -0,0 +1,31 @@
import SwiftUI
import VibeviewerCore
import VibeviewerModel
@MainActor
struct DashboardSummaryView: View {
let snapshot: DashboardSnapshot?
var body: some View {
Group {
if let snapshot {
VStack(alignment: .leading, spacing: 4) {
Text("邮箱: \(snapshot.email)")
Text("所有模型总请求: \(snapshot.totalRequestsAllModels)")
Text("Usage Spending ($): \(snapshot.spendingCents.dollarStringFromCents)")
Text("预算上限 ($): \(snapshot.hardLimitDollars)")
if let usageSummary = snapshot.usageSummary {
Text("Plan Usage: \(usageSummary.individualUsage.plan.used)/\(usageSummary.individualUsage.plan.limit)")
if let onDemand = usageSummary.individualUsage.onDemand,
let limit = onDemand.limit {
Text("On-Demand Usage: \(onDemand.used)/\(limit)")
}
}
}
} else {
Text("未登录,请先登录 Cursor")
}
}
}
}

View File

@@ -0,0 +1,189 @@
import Observation
import SwiftUI
import VibeviewerAPI
import VibeviewerAppEnvironment
import VibeviewerLoginUI
import VibeviewerModel
import VibeviewerSettingsUI
import VibeviewerCore
import VibeviewerShareUI
@MainActor
public struct MenuPopoverView: View {
@Environment(\.loginService) private var loginService
@Environment(\.cursorStorage) private var storage
@Environment(\.loginWindowManager) private var loginWindow
@Environment(\.settingsWindowManager) private var settingsWindow
@Environment(\.dashboardRefreshService) private var refresher
@Environment(AppSettings.self) private var appSettings
@Environment(AppSession.self) private var session
@Environment(\.colorScheme) private var colorScheme
enum ViewState: Equatable {
case loading
case loaded
case error(String)
}
public init() {}
@State private var state: ViewState = .loading
@State private var isLoggingIn: Bool = false
@State private var loginError: String?
public var body: some View {
@Bindable var appSettings = appSettings
VStack(alignment: .leading, spacing: 16) {
UsageHeaderView { action in
switch action {
case .dashboard:
self.openDashboard()
}
}
if isLoggingIn {
loginLoadingView
} else if let snapshot = self.session.snapshot {
if let loginError {
// snapshot
DashboardErrorView(
message: loginError,
onRetry: { manualRefresh() }
)
} else {
let isProSeriesUser = snapshot.usageSummary?.membershipType.isProSeries == true
if !isProSeriesUser {
MetricsView(metric: .billing(snapshot.billingMetrics))
if let free = snapshot.freeUsageMetrics {
MetricsView(metric: .free(free))
}
if let onDemandMetrics = snapshot.onDemandMetrics {
MetricsView(metric: .onDemand(onDemandMetrics))
}
Divider().opacity(0.5)
}
UsageEventView(events: self.session.snapshot?.usageEvents ?? [])
if let modelsUsageChart = self.session.snapshot?.modelsUsageChart {
Divider().opacity(0.5)
ModelsUsageBarChartView(data: modelsUsageChart)
}
Divider().opacity(0.5)
TotalCreditsUsageView(snapshot: snapshot)
Divider().opacity(0.5)
MenuFooterView(onRefresh: {
manualRefresh()
})
}
} else {
VStack(alignment: .leading, spacing: 8) {
loginButtonView
if let loginError {
DashboardErrorView(
message: loginError,
onRetry: nil
)
}
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 24)
.background {
ZStack {
Color(hex: colorScheme == .dark ? "1F1E1E" : "F9F9F9")
Circle()
.fill(Color(hex: colorScheme == .dark ? "354E48" : "F2A48B"))
.padding(80)
.blur(radius: 100)
}
.cornerRadiusWithCorners(32 - 4)
}
.padding(session.credentials != nil ? 4 : 0)
}
private var loginButtonView: some View {
UnloginView(
onWebLogin: {
loginWindow.show(onCookieCaptured: { cookie in
self.performLogin(with: cookie)
})
},
onCookieLogin: { cookie in
self.performLogin(with: cookie)
}
)
}
private func openDashboard() {
NSWorkspace.shared.open(URL(string: "https://cursor.com/dashboard?tab=usage")!)
}
private var loginLoadingView: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Logging in…")
.font(.app(.satoshiBold, size: 16))
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
Text("Fetching your latest usage data, this may take a few seconds.")
.font(.app(.satoshiMedium, size: 11))
.foregroundStyle(.secondary)
}
}
.maxFrame(true, false, alignment: .leading)
}
private func performLogin(with cookieHeader: String) {
Task { @MainActor in
self.loginError = nil
self.isLoggingIn = true
defer { self.isLoggingIn = false }
do {
try await self.loginService.login(with: cookieHeader)
} catch LoginServiceError.fetchAccountFailed {
self.loginError = "Failed to fetch account info. Please check your cookie and try again."
} catch LoginServiceError.saveCredentialsFailed {
self.loginError = "Failed to save credentials locally. Please try again."
} catch LoginServiceError.initialRefreshFailed {
self.loginError = "Failed to load dashboard data. Please try again later."
} catch {
self.loginError = "Login failed. Please try again."
}
}
}
private func manualRefresh() {
Task { @MainActor in
guard self.session.credentials != nil else {
self.loginError = "You need to login before refreshing dashboard data."
return
}
self.loginError = nil
// 使
await self.refresher.refreshNow()
// snapshot
if self.session.snapshot == nil {
self.loginError = "Failed to refresh dashboard data. Please try again later."
}
}
}
}

View File

@@ -0,0 +1,94 @@
import SwiftUI
import VibeviewerShareUI
import VibeviewerAppEnvironment
import VibeviewerModel
struct SettingsView: View {
@Environment(\.dismiss) private var dismiss
@Environment(AppSettings.self) private var appSettings
@State private var refreshFrequency: String = ""
@State private var usageHistoryLimit: String = ""
@State private var pauseOnScreenSleep: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack {
Text("Settings")
.font(.app(.satoshiBold, size: 18))
Spacer()
Button {
dismiss()
} label: {
Image(systemName: "xmark")
.font(.system(size: 12))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("Refresh Frequency (minutes)")
.font(.app(.satoshiMedium, size: 12))
TextField("5", text: $refreshFrequency)
.textFieldStyle(.roundedBorder)
.frame(width: 80)
}
VStack(alignment: .leading, spacing: 8) {
Text("Usage History Limit")
.font(.app(.satoshiMedium, size: 12))
TextField("5", text: $usageHistoryLimit)
.textFieldStyle(.roundedBorder)
.frame(width: 80)
}
Toggle("Pause refresh when screen sleeps", isOn: $pauseOnScreenSleep)
.font(.app(.satoshiMedium, size: 12))
}
HStack {
Spacer()
Button("Cancel") {
dismiss()
}
.buttonStyle(.vibe(Color(hex: "F58283").opacity(0.8)))
Button("Save") {
saveSettings()
dismiss()
}
.buttonStyle(.vibe(Color(hex: "5B67E2").opacity(0.8)))
}
}
.padding(20)
.frame(width: 320, height: 240)
.onAppear {
loadSettings()
}
}
private func loadSettings() {
refreshFrequency = String(appSettings.overview.refreshInterval)
usageHistoryLimit = String(appSettings.usageHistory.limit)
pauseOnScreenSleep = appSettings.pauseOnScreenSleep
}
private func saveSettings() {
if let refreshValue = Int(refreshFrequency) {
appSettings.overview.refreshInterval = refreshValue
}
if let limitValue = Int(usageHistoryLimit) {
appSettings.usageHistory.limit = limitValue
}
appSettings.pauseOnScreenSleep = pauseOnScreenSleep
}
}

View File

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

View File

@@ -0,0 +1,42 @@
{
"originHash" : "92fad12ce0ee54ec200016721b4c688ff3af7c525ef00f048094fd209751300c",
"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,19 @@
// swift-tools-version:5.10
import PackageDescription
let package = Package(
name: "VibeviewerModel",
platforms: [
.macOS(.v14)
],
products: [
.library(name: "VibeviewerModel", targets: ["VibeviewerModel"]),
],
dependencies: [
.package(path: "../VibeviewerCore")
],
targets: [
.target(name: "VibeviewerModel", dependencies: ["VibeviewerCore"]),
.testTarget(name: "VibeviewerModelTests", dependencies: ["VibeviewerModel"])
]
)

View File

@@ -0,0 +1,20 @@
import Foundation
public enum AIModelBrands: String, CaseIterable {
case gpt
case claude
case deepseek
case gemini
case grok
case kimi
case `default`
public static func brand(for modelName: String) -> AIModelBrands {
for brand in AIModelBrands.allCases {
if modelName.hasPrefix(brand.rawValue) {
return brand
}
}
return .default
}
}

View File

@@ -0,0 +1,66 @@
import Foundation
/// 使
public struct AggregatedUsageEvents: Sendable, Equatable, Codable {
/// 使
public let aggregations: [ModelAggregation]
/// token
public let totalInputTokens: Int
/// token
public let totalOutputTokens: Int
/// token
public let totalCacheWriteTokens: Int
/// token
public let totalCacheReadTokens: Int
///
public let totalCostCents: Double
public init(
aggregations: [ModelAggregation],
totalInputTokens: Int,
totalOutputTokens: Int,
totalCacheWriteTokens: Int,
totalCacheReadTokens: Int,
totalCostCents: Double
) {
self.aggregations = aggregations
self.totalInputTokens = totalInputTokens
self.totalOutputTokens = totalOutputTokens
self.totalCacheWriteTokens = totalCacheWriteTokens
self.totalCacheReadTokens = totalCacheReadTokens
self.totalCostCents = totalCostCents
}
}
/// 使
public struct ModelAggregation: Sendable, Equatable, Codable {
/// / "claude-4.5-sonnet-thinking"
public let modelIntent: String
/// token
public let inputTokens: Int
/// token
public let outputTokens: Int
/// token
public let cacheWriteTokens: Int
/// token
public let cacheReadTokens: Int
///
public let totalCents: Double
public init(
modelIntent: String,
inputTokens: Int,
outputTokens: Int,
cacheWriteTokens: Int,
cacheReadTokens: Int,
totalCents: Double
) {
self.modelIntent = modelIntent
self.inputTokens = inputTokens
self.outputTokens = outputTokens
self.cacheWriteTokens = cacheWriteTokens
self.cacheReadTokens = cacheReadTokens
self.totalCents = totalCents
}
}

View File

@@ -0,0 +1,9 @@
import Foundation
public enum AppAppearance: String, Codable, Sendable, Equatable, CaseIterable, Hashable {
case system
case light
case dark
}

View File

@@ -0,0 +1,14 @@
import Foundation
import Observation
@MainActor
@Observable
public final class AppSession {
public var credentials: Credentials?
public var snapshot: DashboardSnapshot?
public init(credentials: Credentials? = nil, snapshot: DashboardSnapshot? = nil) {
self.credentials = credentials
self.snapshot = snapshot
}
}

View File

@@ -0,0 +1,98 @@
import Foundation
import Observation
@Observable
public final class AppSettings: Codable, Sendable, Equatable {
public var launchAtLogin: Bool
public var usageHistory: AppSettings.UsageHistory
public var overview: AppSettings.Overview
public var pauseOnScreenSleep: Bool
public var appearance: AppAppearance
public var analyticsDataDays: Int
public init(
launchAtLogin: Bool = false,
usageHistory: AppSettings.UsageHistory = AppSettings.UsageHistory(limit: 5),
overview: AppSettings.Overview = AppSettings.Overview(refreshInterval: 5),
pauseOnScreenSleep: Bool = false,
appearance: AppAppearance = .system,
analyticsDataDays: Int = 7
) {
self.launchAtLogin = launchAtLogin
self.usageHistory = usageHistory
self.overview = overview
self.pauseOnScreenSleep = pauseOnScreenSleep
self.appearance = appearance
self.analyticsDataDays = analyticsDataDays
}
public static func == (lhs: AppSettings, rhs: AppSettings) -> Bool {
lhs.launchAtLogin == rhs.launchAtLogin &&
lhs.usageHistory == rhs.usageHistory &&
lhs.overview == rhs.overview &&
lhs.pauseOnScreenSleep == rhs.pauseOnScreenSleep &&
lhs.appearance == rhs.appearance &&
lhs.analyticsDataDays == rhs.analyticsDataDays
}
// MARK: - Codable (backward compatible)
private enum CodingKeys: String, CodingKey {
case launchAtLogin
case usageHistory
case overview
case pauseOnScreenSleep
case appearance
case analyticsDataDays
}
public required convenience init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let launchAtLogin = try container.decodeIfPresent(Bool.self, forKey: .launchAtLogin) ?? false
let usageHistory = try container.decodeIfPresent(AppSettings.UsageHistory.self, forKey: .usageHistory) ?? AppSettings.UsageHistory(limit: 5)
let overview = try container.decodeIfPresent(AppSettings.Overview.self, forKey: .overview) ?? AppSettings.Overview(refreshInterval: 5)
let pauseOnScreenSleep = try container.decodeIfPresent(Bool.self, forKey: .pauseOnScreenSleep) ?? false
let appearance = try container.decodeIfPresent(AppAppearance.self, forKey: .appearance) ?? .system
let analyticsDataDays = try container.decodeIfPresent(Int.self, forKey: .analyticsDataDays) ?? 7
self.init(
launchAtLogin: launchAtLogin,
usageHistory: usageHistory,
overview: overview,
pauseOnScreenSleep: pauseOnScreenSleep,
appearance: appearance,
analyticsDataDays: analyticsDataDays
)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.launchAtLogin, forKey: .launchAtLogin)
try container.encode(self.usageHistory, forKey: .usageHistory)
try container.encode(self.overview, forKey: .overview)
try container.encode(self.pauseOnScreenSleep, forKey: .pauseOnScreenSleep)
try container.encode(self.appearance, forKey: .appearance)
try container.encode(self.analyticsDataDays, forKey: .analyticsDataDays)
}
public struct Overview: Codable, Sendable, Equatable {
public var refreshInterval: Int
public init(
refreshInterval: Int = 5
) {
self.refreshInterval = refreshInterval
}
}
public struct UsageHistory: Codable, Sendable, Equatable {
public var limit: Int
public init(
limit: Int = 5
) {
self.limit = limit
}
}
// moved to its own file: AppAppearance
}

View File

@@ -0,0 +1,18 @@
import Foundation
///
public struct BillingCycle: Sendable, Equatable, Codable {
///
public let startDate: Date
///
public let endDate: Date
public init(
startDate: Date,
endDate: Date
) {
self.startDate = startDate
self.endDate = endDate
}
}

View File

@@ -0,0 +1,29 @@
import Foundation
@Observable
public class Credentials: Codable, Equatable {
public let userId: Int
public let workosId: String
public let email: String
public let teamId: Int
public let cookieHeader: String
public let isEnterpriseUser: Bool
public init(userId: Int, workosId: String, email: String, teamId: Int, cookieHeader: String, isEnterpriseUser: Bool) {
self.userId = userId
self.workosId = workosId
self.email = email
self.teamId = teamId
self.cookieHeader = cookieHeader
self.isEnterpriseUser = isEnterpriseUser
}
public static func == (lhs: Credentials, rhs: Credentials) -> Bool {
lhs.userId == rhs.userId &&
lhs.workosId == rhs.workosId &&
lhs.email == rhs.email &&
lhs.teamId == rhs.teamId &&
lhs.cookieHeader == rhs.cookieHeader &&
lhs.isEnterpriseUser == rhs.isEnterpriseUser
}
}

View File

@@ -0,0 +1,165 @@
import Foundation
@Observable
public class DashboardSnapshot: Codable, Equatable {
//
public let email: String
/// ( + (Billing))
public let totalRequestsAllModels: Int
///
public let spendingCents: Int
///
public let hardLimitDollars: Int
///
public let usageEvents: [UsageEvent]
/// usageEvents
public let requestToday: Int
/// usageEvents
public let requestYestoday: Int
/// 使
public let usageSummary: UsageSummary?
/// Team Plan
public let freeUsageCents: Int
/// 使
public let modelsUsageChart: ModelsUsageChartData?
/// Pro Team
public let modelsUsageSummary: ModelsUsageSummary?
///
public let billingCycleStartMs: String?
///
public let billingCycleEndMs: String?
public init(
email: String,
totalRequestsAllModels: Int,
spendingCents: Int,
hardLimitDollars: Int,
usageEvents: [UsageEvent] = [],
requestToday: Int = 0,
requestYestoday: Int = 0,
usageSummary: UsageSummary? = nil,
freeUsageCents: Int = 0,
modelsUsageChart: ModelsUsageChartData? = nil,
modelsUsageSummary: ModelsUsageSummary? = nil,
billingCycleStartMs: String? = nil,
billingCycleEndMs: String? = nil
) {
self.email = email
self.totalRequestsAllModels = totalRequestsAllModels
self.spendingCents = spendingCents
self.hardLimitDollars = hardLimitDollars
self.usageEvents = usageEvents
self.requestToday = requestToday
self.requestYestoday = requestYestoday
self.usageSummary = usageSummary
self.freeUsageCents = freeUsageCents
self.modelsUsageChart = modelsUsageChart
self.modelsUsageSummary = modelsUsageSummary
self.billingCycleStartMs = billingCycleStartMs
self.billingCycleEndMs = billingCycleEndMs
}
private enum CodingKeys: String, CodingKey {
case email
case totalRequestsAllModels
case spendingCents
case hardLimitDollars
case usageEvents
case requestToday
case requestYestoday
case usageSummary
case freeUsageCents
case modelsUsageChart
case modelsUsageSummary
case billingCycleStartMs
case billingCycleEndMs
}
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.email = try container.decode(String.self, forKey: .email)
self.totalRequestsAllModels = try container.decode(Int.self, forKey: .totalRequestsAllModels)
self.spendingCents = try container.decode(Int.self, forKey: .spendingCents)
self.hardLimitDollars = try container.decode(Int.self, forKey: .hardLimitDollars)
self.requestToday = try container.decode(Int.self, forKey: .requestToday)
self.requestYestoday = try container.decode(Int.self, forKey: .requestYestoday)
self.usageEvents = try container.decode([UsageEvent].self, forKey: .usageEvents)
self.usageSummary = try? container.decode(UsageSummary.self, forKey: .usageSummary)
self.freeUsageCents = (try? container.decode(Int.self, forKey: .freeUsageCents)) ?? 0
self.modelsUsageChart = try? container.decode(ModelsUsageChartData.self, forKey: .modelsUsageChart)
self.modelsUsageSummary = try? container.decode(ModelsUsageSummary.self, forKey: .modelsUsageSummary)
self.billingCycleStartMs = try? container.decode(String.self, forKey: .billingCycleStartMs)
self.billingCycleEndMs = try? container.decode(String.self, forKey: .billingCycleEndMs)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.email, forKey: .email)
try container.encode(self.totalRequestsAllModels, forKey: .totalRequestsAllModels)
try container.encode(self.spendingCents, forKey: .spendingCents)
try container.encode(self.hardLimitDollars, forKey: .hardLimitDollars)
try container.encode(self.usageEvents, forKey: .usageEvents)
try container.encode(self.requestToday, forKey: .requestToday)
try container.encode(self.requestYestoday, forKey: .requestYestoday)
if let usageSummary = self.usageSummary {
try container.encode(usageSummary, forKey: .usageSummary)
}
if self.freeUsageCents > 0 {
try container.encode(self.freeUsageCents, forKey: .freeUsageCents)
}
if let modelsUsageChart = self.modelsUsageChart {
try container.encode(modelsUsageChart, forKey: .modelsUsageChart)
}
if let modelsUsageSummary = self.modelsUsageSummary {
try container.encode(modelsUsageSummary, forKey: .modelsUsageSummary)
}
if let billingCycleStartMs = self.billingCycleStartMs {
try container.encode(billingCycleStartMs, forKey: .billingCycleStartMs)
}
if let billingCycleEndMs = self.billingCycleEndMs {
try container.encode(billingCycleEndMs, forKey: .billingCycleEndMs)
}
}
/// plan + onDemand
public var totalUsageCents: Int {
guard let usageSummary = usageSummary else {
return spendingCents
}
let planUsed = usageSummary.individualUsage.plan.used
let onDemandUsed = usageSummary.individualUsage.onDemand?.used ?? 0
let freeUsage = freeUsageCents
return planUsed + onDemandUsed + freeUsage
}
/// UI
/// - Pro pro / proPlus / ultra `modelsUsageSummary`
/// 使 `ModelUsageInfo`
/// - 退 `totalUsageCents`
public var displayTotalUsageCents: Int {
if
let usageSummary,
let modelsUsageSummary,
usageSummary.membershipType.isProSeries
{
return Int(modelsUsageSummary.totalCostCents.rounded())
}
return totalUsageCents
}
public static func == (lhs: DashboardSnapshot, rhs: DashboardSnapshot) -> Bool {
lhs.email == rhs.email &&
lhs.totalRequestsAllModels == rhs.totalRequestsAllModels &&
lhs.spendingCents == rhs.spendingCents &&
lhs.hardLimitDollars == rhs.hardLimitDollars &&
lhs.usageSummary == rhs.usageSummary &&
lhs.freeUsageCents == rhs.freeUsageCents &&
lhs.modelsUsageChart == rhs.modelsUsageChart &&
lhs.modelsUsageSummary == rhs.modelsUsageSummary &&
lhs.billingCycleStartMs == rhs.billingCycleStartMs &&
lhs.billingCycleEndMs == rhs.billingCycleEndMs
}
}

View File

@@ -0,0 +1,11 @@
import Foundation
public struct FilteredUsageHistory: Sendable, Equatable {
public let totalCount: Int
public let events: [UsageEvent]
public init(totalCount: Int, events: [UsageEvent]) {
self.totalCount = totalCount
self.events = events
}
}

View File

@@ -0,0 +1,56 @@
import Foundation
///
public enum MembershipType: String, Sendable, Equatable, Codable {
case enterprise = "enterprise"
case freeTrial = "free_trial"
case pro = "pro"
case proPlus = "pro_plus"
case ultra = "ultra"
case free = "free"
/// Pro Pro / Pro+ / Ultra
public var isProSeries: Bool {
switch self {
case .pro, .proPlus, .ultra:
return true
default:
return false
}
}
///
/// - Parameters:
/// - subscriptionStatus:
/// - isEnterprise: Enterprise Team Plan
/// - Returns:
public func displayName(
subscriptionStatus: SubscriptionStatus? = nil,
isEnterprise: Bool = false
) -> String {
switch self {
case .enterprise:
return isEnterprise ? "Enterprise" : "Team Plan"
case .freeTrial:
return "Pro Trial"
case .pro:
return subscriptionStatus == .trialing ? "Pro Trial" : "Pro Plan"
case .proPlus:
return subscriptionStatus == .trialing ? "Pro+ Trial" : "Pro+ Plan"
case .ultra:
return "Ultra Plan"
case .free:
return "Free Plan"
}
}
}
///
public enum SubscriptionStatus: String, Sendable, Equatable, Codable {
case trialing = "trialing"
case active = "active"
case canceled = "canceled"
case pastDue = "past_due"
case unpaid = "unpaid"
}

View File

@@ -0,0 +1,126 @@
import Foundation
/// - 使
public struct ModelUsageInfo: Sendable, Equatable, Codable {
///
public let modelName: String
/// token
public let inputTokens: Int
/// token
public let outputTokens: Int
/// token
public let cacheWriteTokens: Int
/// token
public let cacheReadTokens: Int
///
public let costCents: Double
public init(
modelName: String,
inputTokens: Int,
outputTokens: Int,
cacheWriteTokens: Int,
cacheReadTokens: Int,
costCents: Double
) {
self.modelName = modelName
self.inputTokens = inputTokens
self.outputTokens = outputTokens
self.cacheWriteTokens = cacheWriteTokens
self.cacheReadTokens = cacheReadTokens
self.costCents = costCents
}
/// ModelAggregation
public init(from aggregation: ModelAggregation) {
self.modelName = aggregation.modelIntent
self.inputTokens = aggregation.inputTokens
self.outputTokens = aggregation.outputTokens
self.cacheWriteTokens = aggregation.cacheWriteTokens
self.cacheReadTokens = aggregation.cacheReadTokens
self.costCents = aggregation.totalCents
}
/// token
public var totalTokens: Int {
inputTokens + outputTokens
}
/// token
public var totalTokensWithCache: Int {
inputTokens + outputTokens + cacheWriteTokens + cacheReadTokens
}
/// "$1.23"
public var formattedCost: String {
String(format: "$%.2f", costCents / 100.0)
}
}
/// -
public struct ModelsUsageSummary: Sendable, Equatable, Codable {
///
public let models: [ModelUsageInfo]
/// token
public let totalInputTokens: Int
/// token
public let totalOutputTokens: Int
/// token
public let totalCacheWriteTokens: Int
/// token
public let totalCacheReadTokens: Int
///
public let totalCostCents: Double
public init(
models: [ModelUsageInfo],
totalInputTokens: Int,
totalOutputTokens: Int,
totalCacheWriteTokens: Int,
totalCacheReadTokens: Int,
totalCostCents: Double
) {
self.models = models
self.totalInputTokens = totalInputTokens
self.totalOutputTokens = totalOutputTokens
self.totalCacheWriteTokens = totalCacheWriteTokens
self.totalCacheReadTokens = totalCacheReadTokens
self.totalCostCents = totalCostCents
}
/// AggregatedUsageEvents
public init(from aggregated: AggregatedUsageEvents) {
self.models = aggregated.aggregations.map { ModelUsageInfo(from: $0) }
self.totalInputTokens = aggregated.totalInputTokens
self.totalOutputTokens = aggregated.totalOutputTokens
self.totalCacheWriteTokens = aggregated.totalCacheWriteTokens
self.totalCacheReadTokens = aggregated.totalCacheReadTokens
self.totalCostCents = aggregated.totalCostCents
}
/// token
public var totalTokens: Int {
totalInputTokens + totalOutputTokens
}
/// token
public var totalTokensWithCache: Int {
totalInputTokens + totalOutputTokens + totalCacheWriteTokens + totalCacheReadTokens
}
/// "$1.23"
public var formattedTotalCost: String {
String(format: "$%.2f", totalCostCents / 100.0)
}
///
public var modelsSortedByCost: [ModelUsageInfo] {
models.sorted { $0.costCents > $1.costCents }
}
/// token 使
public var modelsSortedByTokens: [ModelUsageInfo] {
models.sorted { $0.totalTokens > $1.totalTokens }
}
}

View File

@@ -0,0 +1,45 @@
import Foundation
/// 使
public struct ModelsUsageChartData: Codable, Sendable, Equatable {
///
public let dataPoints: [DataPoint]
public init(dataPoints: [DataPoint]) {
self.dataPoints = dataPoints
}
///
public struct DataPoint: Codable, Sendable, Equatable {
/// YYYY-MM-DD
public let date: String
/// MM/dd
public let dateLabel: String
/// 使
public let modelUsages: [ModelUsage]
/// 使
public var totalValue: Int {
modelUsages.reduce(0) { $0 + $1.requests }
}
public init(date: String, dateLabel: String, modelUsages: [ModelUsage]) {
self.date = date
self.dateLabel = dateLabel
self.modelUsages = modelUsages
}
}
/// 使
public struct ModelUsage: Codable, Sendable, Equatable {
///
public let modelName: String
///
public let requests: Int
public init(modelName: String, requests: Int) {
self.modelName = modelName
self.requests = requests
}
}
}

View File

@@ -0,0 +1,27 @@
import Foundation
public struct TokenUsage: Codable, Sendable, Equatable {
public let outputTokens: Int?
public let inputTokens: Int?
public let totalCents: Double
public let cacheWriteTokens: Int?
public let cacheReadTokens: Int?
public var totalTokens: Int {
return (outputTokens ?? 0) + (inputTokens ?? 0) + (cacheWriteTokens ?? 0) + (cacheReadTokens ?? 0)
}
public init(
outputTokens: Int?,
inputTokens: Int?,
totalCents: Double,
cacheWriteTokens: Int?,
cacheReadTokens: Int?
) {
self.outputTokens = outputTokens
self.inputTokens = inputTokens
self.totalCents = totalCents
self.cacheWriteTokens = cacheWriteTokens
self.cacheReadTokens = cacheReadTokens
}
}

View File

@@ -0,0 +1,92 @@
import Foundation
public struct UsageEvent: Codable, Sendable, Equatable {
public let occurredAtMs: String
public let modelName: String
public let kind: String
public let requestCostCount: Int
public let usageCostDisplay: String
///
public let usageCostCents: Int
public let isTokenBased: Bool
public let userDisplayName: String
public let cursorTokenFee: Double
public let tokenUsage: TokenUsage?
public var brand: AIModelBrands {
AIModelBrands.brand(for: self.modelName)
}
///
public var calculatedCostDisplay: String {
let totalCents = (tokenUsage?.totalCents ?? 0.0) + cursorTokenFee
let dollars = totalCents / 100.0
return String(format: "$%.2f", dollars)
}
public init(
occurredAtMs: String,
modelName: String,
kind: String,
requestCostCount: Int,
usageCostDisplay: String,
usageCostCents: Int = 0,
isTokenBased: Bool,
userDisplayName: String,
cursorTokenFee: Double = 0.0,
tokenUsage: TokenUsage? = nil
) {
self.occurredAtMs = occurredAtMs
self.modelName = modelName
self.kind = kind
self.requestCostCount = requestCostCount
self.usageCostDisplay = usageCostDisplay
self.usageCostCents = usageCostCents
self.isTokenBased = isTokenBased
self.userDisplayName = userDisplayName
self.cursorTokenFee = cursorTokenFee
self.tokenUsage = tokenUsage
}
private enum CodingKeys: String, CodingKey {
case occurredAtMs
case modelName
case kind
case requestCostCount
case usageCostDisplay
case usageCostCents
case isTokenBased
case userDisplayName
case teamDisplayName
case cursorTokenFee
case tokenUsage
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.occurredAtMs = try container.decode(String.self, forKey: .occurredAtMs)
self.modelName = try container.decode(String.self, forKey: .modelName)
self.kind = try container.decode(String.self, forKey: .kind)
self.requestCostCount = try container.decode(Int.self, forKey: .requestCostCount)
self.usageCostDisplay = try container.decode(String.self, forKey: .usageCostDisplay)
self.usageCostCents = (try? container.decode(Int.self, forKey: .usageCostCents)) ?? 0
self.isTokenBased = try container.decode(Bool.self, forKey: .isTokenBased)
self.userDisplayName = try container.decode(String.self, forKey: .userDisplayName)
self.cursorTokenFee = (try? container.decode(Double.self, forKey: .cursorTokenFee)) ?? 0.0
self.tokenUsage = try container.decodeIfPresent(TokenUsage.self, forKey: .tokenUsage)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.occurredAtMs, forKey: .occurredAtMs)
try container.encode(self.modelName, forKey: .modelName)
try container.encode(self.kind, forKey: .kind)
try container.encode(self.requestCostCount, forKey: .requestCostCount)
try container.encode(self.usageCostDisplay, forKey: .usageCostDisplay)
try container.encode(self.usageCostCents, forKey: .usageCostCents)
try container.encode(self.isTokenBased, forKey: .isTokenBased)
try container.encode(self.userDisplayName, forKey: .userDisplayName)
try container.encode(self.cursorTokenFee, forKey: .cursorTokenFee)
try container.encodeIfPresent(self.tokenUsage, forKey: .tokenUsage)
}
}

View File

@@ -0,0 +1,68 @@
import Foundation
import VibeviewerCore
public struct UsageEventHourGroup: Identifiable, Sendable, Equatable {
public let id: Date
public let hourStart: Date
public let title: String
public let events: [UsageEvent]
public var totalRequests: Int { events.map(\.requestCostCount).reduce(0, +) }
public var totalCostCents: Int { events.map(\.usageCostCents).reduce(0, +) }
///
public var calculatedTotalCostDisplay: String {
let totalCents = events.reduce(0.0) { sum, event in
sum + (event.tokenUsage?.totalCents ?? 0.0) + event.cursorTokenFee
}
let dollars = totalCents / 100.0
return String(format: "$%.2f", dollars)
}
public init(id: Date, hourStart: Date, title: String, events: [UsageEvent]) {
self.id = id
self.hourStart = hourStart
self.title = title
self.events = events
}
}
public extension Array where Element == UsageEvent {
func groupedByHour(calendar: Calendar = .current) -> [UsageEventHourGroup] {
var buckets: [Date: [UsageEvent]] = [:]
for event in self {
guard let date = DateUtils.date(fromMillisecondsString: event.occurredAtMs),
let hourStart = calendar.dateInterval(of: .hour, for: date)?.start else { continue }
buckets[hourStart, default: []].append(event)
}
let sortedStarts = buckets.keys.sorted(by: >)
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = .current
formatter.dateFormat = "yyyy-MM-dd HH:00"
return sortedStarts.map { start in
UsageEventHourGroup(
id: start,
hourStart: start,
title: formatter.string(from: start),
events: buckets[start] ?? []
)
}
}
}
public enum UsageEventHourGrouper {
public static func groupByHour(_ events: [UsageEvent], calendar: Calendar = .current) -> [UsageEventHourGroup] {
events.groupedByHour(calendar: calendar)
}
}
public extension UsageEventHourGroup {
static func group(_ events: [UsageEvent], calendar: Calendar = .current) -> [UsageEventHourGroup] {
events.groupedByHour(calendar: calendar)
}
}

View File

@@ -0,0 +1,22 @@
import Foundation
public struct UsageOverview: Sendable, Equatable {
public struct ModelUsage: Sendable, Equatable {
public let modelName: String
/// token
public let tokensUsed: Int?
public init(modelName: String, tokensUsed: Int? = nil) {
self.modelName = modelName
self.tokensUsed = tokensUsed
}
}
public let startOfMonthMs: Date
public let models: [ModelUsage]
public init(startOfMonthMs: Date, models: [ModelUsage]) {
self.startOfMonthMs = startOfMonthMs
self.models = models
}
}

View File

@@ -0,0 +1,84 @@
import Foundation
public struct UsageSummary: Sendable, Equatable, Codable {
public let billingCycleStart: Date
public let billingCycleEnd: Date
public let membershipType: MembershipType
public let limitType: String
public let individualUsage: IndividualUsage
public let teamUsage: TeamUsage?
public init(
billingCycleStart: Date,
billingCycleEnd: Date,
membershipType: MembershipType,
limitType: String,
individualUsage: IndividualUsage,
teamUsage: TeamUsage? = nil
) {
self.billingCycleStart = billingCycleStart
self.billingCycleEnd = billingCycleEnd
self.membershipType = membershipType
self.limitType = limitType
self.individualUsage = individualUsage
self.teamUsage = teamUsage
}
}
public struct IndividualUsage: Sendable, Equatable, Codable {
public let plan: PlanUsage
public let onDemand: OnDemandUsage?
public init(plan: PlanUsage, onDemand: OnDemandUsage? = nil) {
self.plan = plan
self.onDemand = onDemand
}
}
public struct PlanUsage: Sendable, Equatable, Codable {
public let used: Int
public let limit: Int
public let remaining: Int
public let breakdown: PlanBreakdown
public init(used: Int, limit: Int, remaining: Int, breakdown: PlanBreakdown) {
self.used = used
self.limit = limit
self.remaining = remaining
self.breakdown = breakdown
}
}
public struct PlanBreakdown: Sendable, Equatable, Codable {
public let included: Int
public let bonus: Int
public let total: Int
public init(included: Int, bonus: Int, total: Int) {
self.included = included
self.bonus = bonus
self.total = total
}
}
public struct OnDemandUsage: Sendable, Equatable, Codable {
public let used: Int
public let limit: Int?
public let remaining: Int?
public let enabled: Bool
public init(used: Int, limit: Int?, remaining: Int?, enabled: Bool) {
self.used = used
self.limit = limit
self.remaining = remaining
self.enabled = enabled
}
}
public struct TeamUsage: Sendable, Equatable, Codable {
public let onDemand: OnDemandUsage
public init(onDemand: OnDemandUsage) {
self.onDemand = onDemand
}
}

View File

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

View File

@@ -0,0 +1,51 @@
{
"originHash" : "87b7891a178f9f79751f334c663dfe85e6310bca1bb0aa6f3cce8da4fe4fb426",
"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"
}
},
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
"revision" : "9a1d2a19d3595fcf8d9c447173f9a1687b3dcadb",
"version" : "2.8.0"
}
}
],
"version" : 3
}

View File

@@ -0,0 +1,28 @@
// swift-tools-version:5.10
import PackageDescription
let package = Package(
name: "VibeviewerSettingsUI",
platforms: [
.macOS(.v14)
],
products: [
.library(name: "VibeviewerSettingsUI", targets: ["VibeviewerSettingsUI"])
],
dependencies: [
.package(path: "../VibeviewerModel"),
.package(path: "../VibeviewerAppEnvironment"),
.package(path: "../VibeviewerShareUI"),
],
targets: [
.target(
name: "VibeviewerSettingsUI",
dependencies: [
"VibeviewerModel",
"VibeviewerAppEnvironment",
"VibeviewerShareUI",
]
),
.testTarget(name: "VibeviewerSettingsUITests", dependencies: ["VibeviewerSettingsUI"]),
]
)

View File

@@ -0,0 +1,15 @@
import SwiftUI
private struct SettingsWindowManagerKey: EnvironmentKey {
@MainActor
static var defaultValue: SettingsWindowManager {
SettingsWindowManager.shared
}
}
public extension EnvironmentValues {
var settingsWindowManager: SettingsWindowManager {
get { self[SettingsWindowManagerKey.self] }
set { self[SettingsWindowManagerKey.self] = newValue }
}
}

View File

@@ -0,0 +1,66 @@
import AppKit
import SwiftUI
import VibeviewerAppEnvironment
import VibeviewerCore
import VibeviewerModel
import VibeviewerStorage
@MainActor
public final class SettingsWindowManager {
public static let shared = SettingsWindowManager()
private var controller: NSWindowController?
public var appSettings: AppSettings = DefaultCursorStorageService.loadSettingsSync()
public var appSession: AppSession = AppSession(
credentials: DefaultCursorStorageService.loadCredentialsSync(),
snapshot: DefaultCursorStorageService.loadDashboardSnapshotSync()
)
public var dashboardRefreshService: any DashboardRefreshService = NoopDashboardRefreshService()
public var updateService: any UpdateService = NoopUpdateService()
public func show() {
// Close MenuBarExtra popover window if it's open
closeMenuBarExtraWindow()
if let controller {
controller.close()
self.controller = nil
}
let vc = NSHostingController(rootView: SettingsView()
.environment(self.appSettings)
.environment(self.appSession)
.environment(\.dashboardRefreshService, self.dashboardRefreshService)
.environment(\.updateService, self.updateService)
.environment(\.cursorStorage, DefaultCursorStorageService())
.environment(\.launchAtLoginService, DefaultLaunchAtLoginService()))
let window = NSWindow(contentViewController: vc)
window.title = "Settings"
window.setContentSize(NSSize(width: 560, height: 500))
window.styleMask = [.titled, .closable]
window.isReleasedWhenClosed = false
window.titlebarAppearsTransparent = false
window.toolbarStyle = .unified
let ctrl = NSWindowController(window: window)
self.controller = ctrl
ctrl.window?.center()
ctrl.showWindow(nil)
ctrl.window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
private func closeMenuBarExtraWindow() {
// Close MenuBarExtra popover windows
// MenuBarExtra windows are typically non-activating NSPanel instances
for window in NSApp.windows {
if let panel = window as? NSPanel,
panel.styleMask.contains(.nonactivatingPanel),
window != self.controller?.window {
window.close()
}
}
}
public func close() {
self.controller?.close()
self.controller = nil
}
}

View File

@@ -0,0 +1,251 @@
import Observation
import SwiftUI
import VibeviewerAppEnvironment
import VibeviewerModel
import VibeviewerShareUI
public struct SettingsView: View {
@Environment(AppSettings.self) private var appSettings
@Environment(\.cursorStorage) private var storage
@Environment(\.launchAtLoginService) private var launchAtLoginService
@Environment(\.dashboardRefreshService) private var refresher
@Environment(\.updateService) private var updateService
@Environment(AppSession.self) private var session
@State private var refreshFrequency: Int = 5
@State private var usageHistoryLimit: Int = 5
@State private var pauseOnScreenSleep: Bool = false
@State private var launchAtLogin: Bool = false
@State private var appearanceSelection: VibeviewerModel.AppAppearance = .system
@State private var showingClearSessionAlert: Bool = false
@State private var showingLogoutAlert: Bool = false
@State private var analyticsDataDays: Int = 7
//
private let refreshFrequencyOptions: [Int] = [1, 2, 3, 5, 10, 15, 30]
private let usageHistoryLimitOptions: [Int] = [5, 10, 20, 50, 100]
private let analyticsDataDaysOptions: [Int] = [3, 7, 14, 30, 60, 90]
public init() {}
public var body: some View {
Form {
Section {
Picker("Appearance", selection: $appearanceSelection) {
Text("System").tag(VibeviewerModel.AppAppearance.system)
Text("Light").tag(VibeviewerModel.AppAppearance.light)
Text("Dark").tag(VibeviewerModel.AppAppearance.dark)
}
.onChange(of: appearanceSelection) { oldValue, newValue in
appSettings.appearance = newValue
Task { @MainActor in
try? await appSettings.save(using: storage)
}
}
//
HStack {
Text("Current Version")
Spacer()
Text(updateService.currentVersion)
.foregroundColor(.secondary)
}
} header: {
Text("General")
}
Section {
Picker("Refresh Frequency", selection: $refreshFrequency) {
ForEach(refreshFrequencyOptions, id: \.self) { value in
Text("\(value) minutes").tag(value)
}
}
.pickerStyle(.menu)
.onChange(of: refreshFrequency) { oldValue, newValue in
appSettings.overview.refreshInterval = newValue
Task { @MainActor in
try? await appSettings.save(using: storage)
}
}
Picker("Usage History Limit", selection: $usageHistoryLimit) {
ForEach(usageHistoryLimitOptions, id: \.self) { value in
Text("\(value) items").tag(value)
}
}
.pickerStyle(.menu)
.onChange(of: usageHistoryLimit) { oldValue, newValue in
appSettings.usageHistory.limit = newValue
Task { @MainActor in
try? await appSettings.save(using: storage)
}
}
Picker("Analytics Data Range", selection: $analyticsDataDays) {
ForEach(analyticsDataDaysOptions, id: \.self) { value in
Text("\(value) days").tag(value)
}
}
.pickerStyle(.menu)
.onChange(of: analyticsDataDays) { oldValue, newValue in
appSettings.analyticsDataDays = newValue
Task { @MainActor in
try? await appSettings.save(using: storage)
}
}
} header: {
Text("Data")
} footer: {
Text("Refresh Frequency: Controls the automatic refresh interval for dashboard data.\nUsage History Limit: Limits the number of usage history items displayed.\nAnalytics Data Range: Controls the number of days of data shown in analytics charts.")
}
Section {
Toggle("Pause refresh when screen sleeps", isOn: $pauseOnScreenSleep)
.onChange(of: pauseOnScreenSleep) { oldValue, newValue in
appSettings.pauseOnScreenSleep = newValue
Task { @MainActor in
try? await appSettings.save(using: storage)
}
}
Toggle("Launch at login", isOn: $launchAtLogin)
.onChange(of: launchAtLogin) { oldValue, newValue in
_ = launchAtLoginService.setEnabled(newValue)
appSettings.launchAtLogin = newValue
Task { @MainActor in
try? await appSettings.save(using: storage)
}
}
} header: {
Text("Behavior")
}
if session.credentials != nil {
Section {
Button(role: .destructive) {
showingLogoutAlert = true
} label: {
Text("Log Out")
}
} header: {
Text("Account")
} footer: {
Text("Clear login credentials and stop data refresh. You will need to log in again to continue using the app.")
}
}
Section {
Button(role: .destructive) {
showingClearSessionAlert = true
} label: {
Text("Clear App Cache")
}
} header: {
Text("Advanced")
} footer: {
Text("Clear all stored credentials and dashboard data. You will need to log in again.")
}
}
.formStyle(.grouped)
.frame(width: 560, height: 500)
.onAppear {
loadSettings()
}
.alert("Log Out", isPresented: $showingLogoutAlert) {
Button("Cancel", role: .cancel) { }
Button("Log Out", role: .destructive) {
Task { @MainActor in
await logout()
}
}
} message: {
Text("This will clear your login credentials and stop data refresh. You will need to log in again to continue using the app.")
}
.alert("Clear App Cache", isPresented: $showingClearSessionAlert) {
Button("Cancel", role: .cancel) { }
Button("Clear", role: .destructive) {
Task { @MainActor in
await clearAppSession()
}
}
} message: {
Text("This will clear all stored credentials and dashboard data. You will need to log in again.")
}
}
private func loadSettings() {
//
let currentRefreshFrequency = appSettings.overview.refreshInterval
let currentUsageHistoryLimit = appSettings.usageHistory.limit
let currentAnalyticsDataDays = appSettings.analyticsDataDays
// 使
if refreshFrequencyOptions.contains(currentRefreshFrequency) {
refreshFrequency = currentRefreshFrequency
} else {
let closest = refreshFrequencyOptions.min(by: { abs($0 - currentRefreshFrequency) < abs($1 - currentRefreshFrequency) }) ?? 5
refreshFrequency = closest
appSettings.overview.refreshInterval = closest
}
if usageHistoryLimitOptions.contains(currentUsageHistoryLimit) {
usageHistoryLimit = currentUsageHistoryLimit
} else {
let closest = usageHistoryLimitOptions.min(by: { abs($0 - currentUsageHistoryLimit) < abs($1 - currentUsageHistoryLimit) }) ?? 5
usageHistoryLimit = closest
appSettings.usageHistory.limit = closest
}
if analyticsDataDaysOptions.contains(currentAnalyticsDataDays) {
analyticsDataDays = currentAnalyticsDataDays
} else {
let closest = analyticsDataDaysOptions.min(by: { abs($0 - currentAnalyticsDataDays) < abs($1 - currentAnalyticsDataDays) }) ?? 7
analyticsDataDays = closest
appSettings.analyticsDataDays = closest
}
pauseOnScreenSleep = appSettings.pauseOnScreenSleep
launchAtLogin = launchAtLoginService.isEnabled
appearanceSelection = appSettings.appearance
//
if !refreshFrequencyOptions.contains(currentRefreshFrequency) ||
!usageHistoryLimitOptions.contains(currentUsageHistoryLimit) ||
!analyticsDataDaysOptions.contains(currentAnalyticsDataDays) {
Task { @MainActor in
try? await appSettings.save(using: storage)
}
}
}
private func logout() async {
//
refresher.stop()
// 使
await storage.clearCredentials()
await storage.clearDashboardSnapshot()
//
session.credentials = nil
session.snapshot = nil
//
NSApplication.shared.keyWindow?.close()
}
private func clearAppSession() async {
//
refresher.stop()
// AppSession
await storage.clearAppSession()
// AppSession
session.credentials = nil
session.snapshot = nil
//
NSApplication.shared.keyWindow?.close()
}
}

View File

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

Some files were not shown because too many files have changed in this diff Show More