蜂鸟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

736
参考计费/.CLAUDE.md Normal file
View File

@@ -0,0 +1,736 @@
# Project Overview
> 参见 Tuist/模块化细节与常见问题排查:`.cursor/rules/tuist.mdc`
> 参见 项目模块化架构设计及新增代码规范: `.cursor/rules/architecture.mdc`
This is a native **MacOS MenuBar application** built with **Swift 6.1+** and **SwiftUI**. The codebase targets **iOS 18.0 and later**, allowing full use of modern Swift and iOS APIs. All concurrency is handled with **Swift Concurrency** (async/await, actors, @MainActor isolation) ensuring thread-safe code.
- **Frameworks & Tech:** SwiftUI for UI, Swift Concurrency with strict mode, Swift Package Manager for modular architecture
- **Architecture:** Model-View (MV) pattern using pure SwiftUI state management. We avoid MVVM and instead leverage SwiftUI's built-in state mechanisms (@State, @Observable, @Environment, @Binding)
- **Testing:** Swift Testing framework with modern @Test macros and #expect/#require assertions
- **Platform:** iOS (Simulator and Device)
- **Accessibility:** Full accessibility support using SwiftUI's accessibility modifiers
## Project Structure
The project follows a **workspace + SPM package** architecture:
```
YourApp/
├── Config/ # XCConfig build settings
│ ├── Debug.xcconfig
│ ├── Release.xcconfig
│ ├── Shared.xcconfig
│ └── Tests.xcconfig
├── YourApp.xcworkspace/ # Workspace container
├── YourApp.xcodeproj/ # App shell (minimal wrapper)
├── YourApp/ # App target - just the entry point
│ ├── Assets.xcassets/
│ ├── YourAppApp.swift # @main entry point only
│ └── YourApp.xctestplan
├── YourAppPackage/ # All features and business logic
│ ├── Package.swift
│ ├── Sources/
│ │ └── YourAppFeature/ # Feature modules
│ └── Tests/
│ └── YourAppFeatureTests/ # Swift Testing tests
└── YourAppUITests/ # UI automation tests
```
**Important:** All development work should be done in the **YourAppPackage** Swift Package, not in the app project. The app project is merely a thin wrapper that imports and launches the package features.
# Code Quality & Style Guidelines
## Swift Style & Conventions
- **Naming:** Use `UpperCamelCase` for types, `lowerCamelCase` for properties/functions. Choose descriptive names (e.g., `calculateMonthlyRevenue()` not `calcRev`)
- **Value Types:** Prefer `struct` for models and data, use `class` only when reference semantics are required
- **Enums:** Leverage Swift's powerful enums with associated values for state representation
- **Early Returns:** Prefer early return pattern over nested conditionals to avoid pyramid of doom
## Optionals & Error Handling
- Use optionals with `if let`/`guard let` for nil handling
- Never force-unwrap (`!`) without absolute certainty - prefer `guard` with failure path
- Use `do/try/catch` for error handling with meaningful error types
- Handle or propagate all errors - no empty catch blocks
# Modern SwiftUI Architecture Guidelines (2025)
### No ViewModels - Use Native SwiftUI Data Flow
**New features MUST follow these patterns:**
1. **Views as Pure State Expressions**
```swift
struct MyView: View {
@Environment(MyService.self) private var service
@State private var viewState: ViewState = .loading
enum ViewState {
case loading
case loaded(data: [Item])
case error(String)
}
var body: some View {
// View is just a representation of its state
}
}
```
2. **Use Environment Appropriately**
- **App-wide services**: Router, Theme, CurrentAccount, Client, etc. - use `@Environment`
- **Feature-specific services**: Timeline services, single-view logic - use `let` properties with `@Observable`
- Rule: Environment for cross-app/cross-feature dependencies, let properties for single-feature services
- Access app-wide via `@Environment(ServiceType.self)`
- Feature services: `private let myService = MyObservableService()`
3. **Local State Management**
- Use `@State` for view-specific state
- Use `enum` for view states (loading, loaded, error)
- Use `.task(id:)` and `.onChange(of:)` for side effects
- Pass state between views using `@Binding`
4. **No ViewModels Required**
- Views should be lightweight and disposable
- Business logic belongs in services/clients
- Test services independently, not views
- Use SwiftUI previews for visual testing
5. **When Views Get Complex**
- Split into smaller subviews
- Use compound views that compose smaller views
- Pass state via bindings between views
- Never reach for a ViewModel as the solution
# iOS 26 Features (Optional)
**Note**: If your app targets iOS 26+, you can take advantage of these cutting-edge SwiftUI APIs introduced in June 2025. These features are optional and should only be used when your deployment target supports iOS 26.
## Available iOS 26 SwiftUI APIs
When targeting iOS 26+, consider using these new APIs:
#### Liquid Glass Effects
- `glassEffect(_:in:isEnabled:)` - Apply Liquid Glass effects to views
- `buttonStyle(.glass)` - Apply Liquid Glass styling to buttons
- `ToolbarSpacer` - Create visual breaks in toolbars with Liquid Glass
#### Enhanced Scrolling
- `scrollEdgeEffectStyle(_:for:)` - Configure scroll edge effects
- `backgroundExtensionEffect()` - Duplicate, mirror, and blur views around edges
#### Tab Bar Enhancements
- `tabBarMinimizeBehavior(_:)` - Control tab bar minimization behavior
- Search role for tabs with search field replacing tab bar
- `TabViewBottomAccessoryPlacement` - Adjust accessory view content based on placement
#### Web Integration
- `WebView` and `WebPage` - Full control over browsing experience
#### Drag and Drop
- `draggable(_:_:)` - Drag multiple items
- `dragContainer(for:id:in:selection:_:)` - Container for draggable views
#### Animation
- `@Animatable` macro - SwiftUI synthesizes custom animatable data properties
#### UI Components
- `Slider` with automatic tick marks when using step parameter
- `windowResizeAnchor(_:)` - Set window anchor point for resizing
#### Text Enhancements
- `TextEditor` now supports `AttributedString`
- `AttributedTextSelection` - Handle text selection with attributed text
- `AttributedTextFormattingDefinition` - Define text styling in specific contexts
- `FindContext` - Create find navigator in text editing views
#### Accessibility
- `AssistiveAccess` - Support Assistive Access in iOS scenes
#### HDR Support
- `Color.ResolvedHDR` - RGBA values with HDR headroom information
#### UIKit Integration
- `UIHostingSceneDelegate` - Host and present SwiftUI scenes in UIKit
- `NSGestureRecognizerRepresentable` - Incorporate gesture recognizers from AppKit
#### Immersive Spaces (if applicable)
- `manipulable(coordinateSpace:operations:inertia:isEnabled:onChanged:)` - Hand gesture manipulation
- `SurfaceSnappingInfo` - Snap volumes and windows to surfaces
- `RemoteImmersiveSpace` - Render stereo content from Mac to Apple Vision Pro
- `SpatialContainer` - 3D layout container
- Depth-based modifiers: `aspectRatio3D(_:contentMode:)`, `rotation3DLayout(_:)`, `depthAlignment(_:)`
## iOS 26 Usage Guidelines
- **Only use when targeting iOS 26+**: Ensure your deployment target supports these APIs
- **Progressive enhancement**: Use availability checks if supporting multiple iOS versions
- **Feature detection**: Test on older simulators to ensure graceful fallbacks
- **Modern aesthetics**: Leverage Liquid Glass effects for cutting-edge UI design
```swift
// Example: Using iOS 26 features with availability checks
struct ModernButton: View {
var body: some View {
Button("Tap me") {
// Action
}
.buttonStyle({
if #available(iOS 26.0, *) {
.glass
} else {
.bordered
}
}())
}
}
```
## SwiftUI State Management (MV Pattern)
- **@State:** For all state management, including observable model objects
- **@Observable:** Modern macro for making model classes observable (replaces ObservableObject)
- **@Environment:** For dependency injection and shared app state
- **@Binding:** For two-way data flow between parent and child views
- **@Bindable:** For creating bindings to @Observable objects
- Avoid ViewModels - put view logic directly in SwiftUI views using these state mechanisms
- Keep views focused and extract reusable components
Example with @Observable:
```swift
@Observable
class UserSettings {
var theme: Theme = .light
var fontSize: Double = 16.0
}
@MainActor
struct SettingsView: View {
@State private var settings = UserSettings()
var body: some View {
VStack {
// Direct property access, no $ prefix needed
Text("Font Size: \(settings.fontSize)")
// For bindings, use @Bindable
@Bindable var settings = settings
Slider(value: $settings.fontSize, in: 10...30)
}
}
}
// Sharing state across views
@MainActor
struct ContentView: View {
@State private var userSettings = UserSettings()
var body: some View {
NavigationStack {
MainView()
.environment(userSettings)
}
}
}
@MainActor
struct MainView: View {
@Environment(UserSettings.self) private var settings
var body: some View {
Text("Current theme: \(settings.theme)")
}
}
```
Example with .task modifier for async operations:
```swift
@Observable
class DataModel {
var items: [Item] = []
var isLoading = false
func loadData() async throws {
isLoading = true
defer { isLoading = false }
// Simulated network call
try await Task.sleep(for: .seconds(1))
items = try await fetchItems()
}
}
@MainActor
struct ItemListView: View {
@State private var model = DataModel()
var body: some View {
List(model.items) { item in
Text(item.name)
}
.overlay {
if model.isLoading {
ProgressView()
}
}
.task {
// This task automatically cancels when view disappears
do {
try await model.loadData()
} catch {
// Handle error
}
}
.refreshable {
// Pull to refresh also uses async/await
try? await model.loadData()
}
}
}
```
## Concurrency
- **@MainActor:** All UI updates must use @MainActor isolation
- **Actors:** Use actors for expensive operations like disk I/O, network calls, or heavy computation
- **async/await:** Always prefer async functions over completion handlers
- **Task:** Use structured concurrency with proper task cancellation
- **.task modifier:** Always use .task { } on views for async operations tied to view lifecycle - it automatically handles cancellation
- **Avoid Task { } in onAppear:** This doesn't cancel automatically and can cause memory leaks or crashes
- No GCD usage - Swift Concurrency only
### Sendable Conformance
Swift 6 enforces strict concurrency checking. All types that cross concurrency boundaries must be Sendable:
- **Value types (struct, enum):** Usually Sendable if all properties are Sendable
- **Classes:** Must be marked `final` and have immutable or Sendable properties, or use `@unchecked Sendable` with thread-safe implementation
- **@Observable classes:** Automatically Sendable when all properties are Sendable
- **Closures:** Mark as `@Sendable` when captured by concurrent contexts
```swift
// Sendable struct - automatic conformance
struct UserData: Sendable {
let id: UUID
let name: String
}
// Sendable class - must be final with immutable properties
final class Configuration: Sendable {
let apiKey: String
let endpoint: URL
init(apiKey: String, endpoint: URL) {
self.apiKey = apiKey
self.endpoint = endpoint
}
}
// @Observable with Sendable
@Observable
final class UserModel: Sendable {
var name: String = ""
var age: Int = 0
// Automatically Sendable if all stored properties are Sendable
}
// Using @unchecked Sendable for thread-safe types
final class Cache: @unchecked Sendable {
private let lock = NSLock()
private var storage: [String: Any] = [:]
func get(_ key: String) -> Any? {
lock.withLock { storage[key] }
}
}
// @Sendable closures
func processInBackground(completion: @Sendable @escaping (Result<Data, Error>) -> Void) {
Task {
// Processing...
completion(.success(data))
}
}
```
## Code Organization
- Keep functions focused on a single responsibility
- Break large functions (>50 lines) into smaller, testable units
- Use extensions to organize code by feature or protocol conformance
- Prefer `let` over `var` - use immutability by default
- Use `[weak self]` in closures to prevent retain cycles
- Always include `self.` when referring to instance properties in closures
# Testing Guidelines
We use **Swift Testing** framework (not XCTest) for all tests. Tests live in the package test target.
## Swift Testing Basics
```swift
import Testing
@Test func userCanLogin() async throws {
let service = AuthService()
let result = try await service.login(username: "test", password: "pass")
#expect(result.isSuccess)
#expect(result.user.name == "Test User")
}
@Test("User sees error with invalid credentials")
func invalidLogin() async throws {
let service = AuthService()
await #expect(throws: AuthError.self) {
try await service.login(username: "", password: "")
}
}
```
## Key Swift Testing Features
- **@Test:** Marks a test function (replaces XCTest's test prefix)
- **@Suite:** Groups related tests together
- **#expect:** Validates conditions (replaces XCTAssert)
- **#require:** Like #expect but stops test execution on failure
- **Parameterized Tests:** Use @Test with arguments for data-driven tests
- **async/await:** Full support for testing async code
- **Traits:** Add metadata like `.bug()`, `.feature()`, or custom tags
## Test Organization
- Write tests in the package's Tests/ directory
- One test file per source file when possible
- Name tests descriptively explaining what they verify
- Test both happy paths and edge cases
- Add tests for bug fixes to prevent regression
# Entitlements Management
This template includes a **declarative entitlements system** that AI agents can safely modify without touching Xcode project files.
## How It Works
- **Entitlements File**: `Config/MyProject.entitlements` contains all app capabilities
- **XCConfig Integration**: `CODE_SIGN_ENTITLEMENTS` setting in `Config/Shared.xcconfig` points to the entitlements file
- **AI-Friendly**: Agents can edit the XML file directly to add/remove capabilities
## Adding Entitlements
To add capabilities to your app, edit `Config/MyProject.entitlements`:
## Common Entitlements
| Capability | Entitlement Key | Value |
|------------|-----------------|-------|
| HealthKit | `com.apple.developer.healthkit` | `<true/>` |
| CloudKit | `com.apple.developer.icloud-services` | `<array><string>CloudKit</string></array>` |
| Push Notifications | `aps-environment` | `development` or `production` |
| App Groups | `com.apple.security.application-groups` | `<array><string>group.id</string></array>` |
| Keychain Sharing | `keychain-access-groups` | `<array><string>$(AppIdentifierPrefix)bundle.id</string></array>` |
| Background Modes | `com.apple.developer.background-modes` | `<array><string>mode-name</string></array>` |
| Contacts | `com.apple.developer.contacts.notes` | `<true/>` |
| Camera | `com.apple.developer.avfoundation.audio` | `<true/>` |
# XcodeBuildMCP Tool Usage
To work with this project, build, test, and development commands should use XcodeBuildMCP tools instead of raw command-line calls.
## Project Discovery & Setup
```javascript
// Discover Xcode projects in the workspace
discover_projs({
workspaceRoot: "/path/to/YourApp"
})
// List available schemes
list_schems_ws({
workspacePath: "/path/to/YourApp.xcworkspace"
})
```
## Building for Simulator
```javascript
// Build for iPhone simulator by name
build_sim_name_ws({
workspacePath: "/path/to/YourApp.xcworkspace",
scheme: "YourApp",
simulatorName: "iPhone 16",
configuration: "Debug"
})
// Build and run in one step
build_run_sim_name_ws({
workspacePath: "/path/to/YourApp.xcworkspace",
scheme: "YourApp",
simulatorName: "iPhone 16"
})
```
## Building for Device
```javascript
// List connected devices first
list_devices()
// Build for physical device
build_dev_ws({
workspacePath: "/path/to/YourApp.xcworkspace",
scheme: "YourApp",
configuration: "Debug"
})
```
## Testing
```javascript
// Run tests on simulator
test_sim_name_ws({
workspacePath: "/path/to/YourApp.xcworkspace",
scheme: "YourApp",
simulatorName: "iPhone 16"
})
// Run tests on device
test_device_ws({
workspacePath: "/path/to/YourApp.xcworkspace",
scheme: "YourApp",
deviceId: "DEVICE_UUID_HERE"
})
// Test Swift Package
swift_package_test({
packagePath: "/path/to/YourAppPackage"
})
```
## Simulator Management
```javascript
// List available simulators
list_sims({
enabled: true
})
// Boot simulator
boot_sim({
simulatorUuid: "SIMULATOR_UUID"
})
// Install app
install_app_sim({
simulatorUuid: "SIMULATOR_UUID",
appPath: "/path/to/YourApp.app"
})
// Launch app
launch_app_sim({
simulatorUuid: "SIMULATOR_UUID",
bundleId: "com.example.YourApp"
})
```
## Device Management
```javascript
// Install on device
install_app_device({
deviceId: "DEVICE_UUID",
appPath: "/path/to/YourApp.app"
})
// Launch on device
launch_app_device({
deviceId: "DEVICE_UUID",
bundleId: "com.example.YourApp"
})
```
## UI Automation
```javascript
// Get UI hierarchy
describe_ui({
simulatorUuid: "SIMULATOR_UUID"
})
// Tap element
tap({
simulatorUuid: "SIMULATOR_UUID",
x: 100,
y: 200
})
// Type text
type_text({
simulatorUuid: "SIMULATOR_UUID",
text: "Hello World"
})
// Take screenshot
screenshot({
simulatorUuid: "SIMULATOR_UUID"
})
```
## Log Capture
```javascript
// Start capturing simulator logs
start_sim_log_cap({
simulatorUuid: "SIMULATOR_UUID",
bundleId: "com.example.YourApp"
})
// Stop and retrieve logs
stop_sim_log_cap({
logSessionId: "SESSION_ID"
})
// Device logs
start_device_log_cap({
deviceId: "DEVICE_UUID",
bundleId: "com.example.YourApp"
})
```
## Utility Functions
```javascript
// Get bundle ID from app
get_app_bundle_id({
appPath: "/path/to/YourApp.app"
})
// Clean build artifacts
clean_ws({
workspacePath: "/path/to/YourApp.xcworkspace"
})
// Get app path for simulator
get_sim_app_path_name_ws({
workspacePath: "/path/to/YourApp.xcworkspace",
scheme: "YourApp",
platform: "iOS Simulator",
simulatorName: "iPhone 16"
})
```
# Development Workflow
1. **Make changes in the Package**: All feature development happens in YourAppPackage/Sources/
2. **Write tests**: Add Swift Testing tests in YourAppPackage/Tests/
3. **Build and test**: Use XcodeBuildMCP tools to build and run tests
4. **Run on simulator**: Deploy to simulator for manual testing
5. **UI automation**: Use describe_ui and automation tools for UI testing
6. **Device testing**: Deploy to physical device when needed
# Best Practices
## SwiftUI & State Management
- Keep views small and focused
- Extract reusable components into their own files
- Use @ViewBuilder for conditional view composition
- Leverage SwiftUI's built-in animations and transitions
- Avoid massive body computations - break them down
- **Always use .task modifier** for async work tied to view lifecycle - it automatically cancels when the view disappears
- Never use Task { } in onAppear - use .task instead for proper lifecycle management
## Performance
- Use .id() modifier sparingly as it forces view recreation
- Implement Equatable on models to optimize SwiftUI diffing
- Use LazyVStack/LazyHStack for large lists
- Profile with Instruments when needed
- @Observable tracks only accessed properties, improving performance over @Published
## Accessibility
- Always provide accessibilityLabel for interactive elements
- Use accessibilityIdentifier for UI testing
- Implement accessibilityHint where actions aren't obvious
- Test with VoiceOver enabled
- Support Dynamic Type
## Security & Privacy
- Never log sensitive information
- Use Keychain for credential storage
- All network calls must use HTTPS
- Request minimal permissions
- Follow App Store privacy guidelines
## Data Persistence
When data persistence is required, always prefer **SwiftData** over CoreData. However, carefully consider whether persistence is truly necessary - many apps can function well with in-memory state that loads on launch.
### When to Use SwiftData
- You have complex relational data that needs to persist across app launches
- You need advanced querying capabilities with predicates and sorting
- You're building a data-heavy app (note-taking, inventory, task management)
- You need CloudKit sync with minimal configuration
### When NOT to Use Data Persistence
- Simple user preferences (use UserDefaults)
- Temporary state that can be reloaded from network
- Small configuration data (consider JSON files or plist)
- Apps that primarily display remote data
### SwiftData Best Practices
```swift
import SwiftData
@Model
final class Task {
var title: String
var isCompleted: Bool
var createdAt: Date
init(title: String) {
self.title = title
self.isCompleted = false
self.createdAt = Date()
}
}
// In your app
@main
struct MyProjectApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.modelContainer(for: Task.self)
}
}
}
// In your views
struct TaskListView: View {
@Query private var tasks: [Task]
@Environment(\.modelContext) private var context
var body: some View {
List(tasks) { task in
Text(task.title)
}
.toolbar {
Button("Add") {
let newTask = Task(title: "New Task")
context.insert(newTask)
}
}
}
}
```
**Important:** Never use CoreData for new projects. SwiftData provides a modern, type-safe API that's easier to work with and integrates seamlessly with SwiftUI.
---
Remember: This project prioritizes clean, simple SwiftUI code using the platform's native state management. Keep the app shell minimal and implement all features in the Swift Package.

View File

@@ -0,0 +1,47 @@
# Release Version Command
## Description
Automatically bump version number, build DMG package, create GitHub PR and release with English descriptions.
## Usage
```
@release_version [version_type]
```
## Parameters
- `version_type` (optional): Type of version bump
- `patch` (default): 1.1.1 → 1.1.2
- `minor`: 1.1.1 → 1.2.0
- `major`: 1.1.1 → 2.0.0
## Examples
```
@release_version
@release_version patch
@release_version minor
@release_version major
```
## What it does
1. **Version Bump**: Updates version in `Scripts/create_dmg.sh` and `Derived/InfoPlists/Vibeviewer-Info.plist`
2. **Build DMG**: Runs `make dmg` to create installation package
3. **Git Operations**: Commits changes and pushes to current branch
4. **Create PR**: Creates GitHub PR with English description
5. **Create Release**: Creates GitHub release with DMG attachment and English release notes
## Prerequisites
- GitHub CLI (`gh`) installed and authenticated
- Current branch pushed to remote
- Make sure you're in the project root directory
## Output
- Updated version files
- Built DMG package
- GitHub PR link
- GitHub Release link
## Notes
- The command will automatically detect the current version and increment accordingly
- All descriptions will be in English
- The DMG file will be automatically attached to the release
- Make sure you have write permissions to the repository

View File

@@ -0,0 +1,18 @@
{
"mcpServers": {
"tuist": {
"command": "/opt/homebrew/bin/tuist",
"args": [
"mcp",
"start"
]
},
"XcodeBuildMCP": {
"command": "npx",
"args": [
"-y",
"xcodebuildmcp@latest"
]
}
}
}

View File

@@ -0,0 +1,149 @@
---
alwaysApply: false
---
# API Authoring Guidelines (VibeviewerAPI)
## Goals
- Unify API naming, directories, abstractions, dependency injection, and decoding patterns
- Keep all APIs in a single module `VibeviewerAPI` to enforce isolation and modularity
- Standardize `DecodableTargetType` and the `HttpClient.decodableRequest(_:)` usage (async/await only) on top of Moya/Alamofire
## Hard rules
- API targets must be declared with `struct` (no `enum`/case-style targets)
- Use async/await-only decoding; callback-based styles are forbidden
- Separate API declarations from model declarations:
- API Targets/Services → `VibeviewerAPI`
- Data models/aggregations → `VibeviewerModel`
- Views/upper layers must use `Service` protocols via dependency injection, and must not call API targets or `HttpClient` directly
- The API module only exposes `Service` protocols and default implementations; API targets, networking details, and common header configuration remain internal
## Dependencies & imports
- API module imports only:
- `Foundation`
- `Moya`
- `Alamofire` (used via `HttpClient`)
- `VibeviewerModel`
- Never import UI frameworks in the API module (`SwiftUI`/`AppKit`/`UIKit`)
## Naming conventions
- Targets: Feature name + `API`, e.g., `YourFeatureAPI`
- Protocols: `YourFeatureService`
- Default implementations: `DefaultYourFeatureService`
- Models: `YourFeatureResponse`, `YourFeatureDetail`, etc.
## Directory structure (VibeviewerAPI)
```text
VibeviewerAPI/
Sources/VibeviewerAPI/
Mapping/
... DTOs & Mappers
Plugins/
RequestHeaderConfigurationPlugin.swift
RequestErrorHandlingPlugin.swift
SimpleNetworkLoggerPlugin.swift
Service/
MoyaProvider+DecodableRequest.swift
HttpClient.swift # Unified Moya provider & session wrapper
HttpClientError.swift
Targets/
CursorGetMeAPI.swift # internal target
CursorUsageAPI.swift # internal target
CursorTeamSpendAPI.swift # internal target
CursorService.swift # public protocol + default implementation (service only)
```
## Target and decoding conventions
- Targets conform to `DecodableTargetType`:
- `associatedtype ResultType: Decodable`
- `var decodeAtKeyPath: String? { get }` (default `nil`)
- Implement `baseURL`, `path`, `method`, `task`, `headers`, `sampleData`
- Avoid overriding `validationType` unless necessary
Example:
```swift
import Foundation
import Moya
import VibeviewerModel
struct UserProfileDetailAPI: DecodableTargetType {
typealias ResultType = UserProfileResponse
let userId: String
var baseURL: URL { APIConfig.baseURL }
var path: String { "/users/\(userId)" }
var method: Moya.Method { .get }
var task: Task { .requestPlain }
var headers: [String: String]? { APIHeadersBuilder.basicHeaders(cookieHeader: nil) }
var sampleData: Data { Data("{\"id\":\"1\",\"name\":\"foo\"}".utf8) }
}
```
## Service abstraction & dependency injection
- Expose protocol + default implementation (expose services only; hide networking details)
- The default `public init(decoding:)` must not leak internal protocol types; provide `internal init(network:decoding:)` for test injection
```swift
import Foundation
import Moya
import VibeviewerModel
public protocol UserProfileService {
func fetchDetail(userId: String) async throws -> UserProfileResponse
}
public struct DefaultUserProfileService: UserProfileService {
private let network: NetworkClient
private let decoding: JSONDecoder.KeyDecodingStrategy
// Business-facing: do not expose internal NetworkClient abstraction
public init(decoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys) {
self.network = DefaultNetworkClient()
self.decoding = decoding
}
// Test injection: available within the API module (same package or @testable)
init(network: any NetworkClient, decoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys) {
self.network = network
self.decoding = decoding
}
public func fetchDetail(userId: String) async throws -> UserProfileResponse {
try await network.decodableRequest(
UserProfileDetailAPI(userId: userId),
decodingStrategy: decoding
)
}
}
```
> Note: `DefaultNetworkClient`, the `NetworkClient` protocol, and the concrete `HttpClient` implementation details remain `internal` and are not exposed.
## View usage (dependency injection)
Views must not call API targets or `HttpClient` directly. Use injected services instead:
```swift
import VibeviewerAPI
import VibeviewerModel
let service: UserProfileService = DefaultUserProfileService()
let model = try await service.fetchDetail(userId: "1")
```
## Error handling & logging
- Enable `SimpleNetworkLoggerPlugin` by default to log requests/responses
- Enable `RequestErrorHandlingPlugin` by default:
- Timeouts/offline → unified handling
- Customizable via strategy protocols
## Testing & mock conventions
- Within the `VibeviewerAPI` module, inject a `FakeNetworkClient` via `internal init(network:decoding:)` to replace real networking
- Provide `sampleData` for each target; prefer minimal realistic JSON to ensure robust decoding
- Use `@testable import VibeviewerAPI` to access internal symbols when external tests are required
## Alignment with modular architecture (architecture.mdc)
- Do not import UI frameworks in the API module
- Expose only `Service` protocols and default implementations; hide targets and networking details
- Dependency direction: `VibeviewerModel` ← `VibeviewerAPI` ← `VibeviewerFeature`
- Strict “one file, one type/responsibility”; clear feature aggregation; one-way dependencies

View File

@@ -0,0 +1,108 @@
---
alwaysApply: true
title: Vibeviewer Architecture Guidelines
---
## Background
- The project uses a layered, modular Swift Package architecture with goals: minimal public surface, one-way dependencies, single responsibility, testability, and replaceability.
- Layers and dependency direction (top-down only):
- Core/Shared → common utilities and extensions (no business-layer dependencies)
- Model → pure data/DTO/domain entities (may depend on Core)
- API/Service → networking/IO/3rd-party orchestration and DTO→domain mapping (depends on Model + 3rd-party)
- Feature/UI → SwiftUI views and interactions (depends on API-exposed service protocols and domain models; must not depend on networking libraries)
- Architectural style: Native SwiftUI MV (not MVVM). State via @State/@Observable; dependency injection via @Environment; concurrency with async/await and @MainActor.
## Do (Recommended)
- Module placement & responsibilities
- Before adding code, decide whether it belongs to UI/Service/Model/Core and place it in the corresponding package/directory; one type/responsibility per file.
- The API layer exposes only “service protocol + default implementation”; networking library/targets/plugins are encapsulated internally.
- Service functions return domain models (Model-layer entities) or clear error types; avoid leaking DTOs to the UI.
- Domain models & mapping
- Abstract API response DTOs into domain entities (e.g., UserProfile / UsageOverview / TeamSpendOverview / UsageEvent / FilteredUsageHistory).
- Perform DTO→domain mapping in the API layer; UI consumes domain-only.
- Dependencies & visibility
- One-way: Core ← Model ← API ← Feature.
- Default to internal; use public only for cross-package use; prefer protocols over concrete types.
- SwiftUI & concurrency
- Inject services via @Environment; place side effects in .task / .onChange so they automatically cancel with the view lifecycle.
- UI updates occur on @MainActor; networking/IO on background using async/await; cross-isolation types must be Sendable.
- Testing & replaceability
- Provide an injectable network client interface for services; separate default implementation from testable construction paths.
- Put utilities/algorithms into Core; prefer pure functions for unit testing and reuse.
- Troble Shooting
- if you facing an lint error by "can't not found xxx in scope" when you edit/new/delete some interface on Package, that means you need to call XCodeBuildMCP to rebuild that package, so that other package can update the codebase to fix that error
## Don't (Avoid)
- UI directly depending on networking libraries, triggering requests, or being exposed to backend error details.
- Feature depending on API internals (e.g., Targets/Plugins/concrete networking implementations).
- Exposing API DTOs directly to the UI (causes global coupling and fragility).
- Reverse dependencies (e.g., Model depends on Feature; API depends on UI).
- Introducing MVVM/ViewModel as the default; or using Task { } in onAppear (use .task instead).
- Overusing public types/initializers; placing multiple unrelated types in one file.
## Review checklist
1) Quadrant self-check (placement)
- UI/interaction/rendering → Feature/UI
- Networking/disk/auth/3rd-party → API/Service
- Pure data/DTO/state aggregation → Model
- Utilities/extensions/algorithms → Core
2) Surface area & replaceability
- Can it be exposed via protocol to hide details? Is internal sufficient by default?
- Do services return only domain models/error enums? Is it easy to replace/mock?
3) Dependency direction & coupling
- Any violation of Core ← Model ← API ← Feature one-way dependency?
- Does the UI still reference DTOs or networking implementations? If yes, move mapping/abstraction to the API layer.
4) Concurrency & thread safety
- Are UI updates on @MainActor? Are cross-isolation types Sendable? Are we using async/await?
- Should serialization-required persistence/cache be placed within an Actor boundary?
5) File organization & naming
- Clear directories (Feature/Views, API/Service, API/Targets, API/Plugins, Model/Entities, Core/Extensions).
- One type per file; names reflect layer and responsibility (e.g., FeatureXView, FeatureXService, GetYAPI, ZResponse).
- Package directory structure: Sources/<PackageName>/ organized by feature subfolders; avoid dumping all source at one level.
- Suggested subfolders:
- API: Service / Targets / Plugins / Mapping (DTO→Domain mapping)
- Feature: Views / Components / Scenes / Modifiers
- Model: Entities
- Core: Extensions / Utils
- Consistent naming: use a shared prefix/suffix for similar features for discoverability.
- Suffix examples: …Service, …API, …Response, …Request, …View, …Section, …Window, …Plugin, …Mapper.
- Use a consistent domain/vendor prefix where needed (e.g., Cursor…).
- File name equals type name: each file contains only one primary type; exact case-sensitive match.
- Protocol/implementation convention: protocol uses FooService; default implementation uses DefaultFooService (or LiveFooService). Expose only protocols and inject implementations.
- Model-layer naming (Entities vs DTOs):
- Entities (exposed to business/UI):
- Use domain-oriented neutral nouns; avoid vendor prefixes by default (e.g., UserProfile, UsageOverview, TeamSpendOverview, UsageEvent, FilteredUsageHistory, AppSettings, Credentials, DashboardSnapshot).
- If source domain must be shown (e.g., “Cursor”), use a consistent prefix within that domain (e.g., CursorCredentials, CursorDashboardSnapshot) for consistency and discoverability.
- Suggested suffixes: …Overview, …Snapshot, …History, …Event, …Member, …RoleCount.
- Prefer struct, value semantics, and Sendable; expose public types/members only when needed cross-package.
- File name equals type name; single-type files.
- DTOs (API layer only, under API/Mapping/DTOs):
- Use vendor/source prefix + semantic suffix: e.g., Cursor…Request, Cursor…Response, Cursor…Event.
- Default visibility is internal; do not expose to Feature/UI; map to domain in the API layer only.
- File name equals type name; single-type files; field names mirror backend responses (literal), adapted to domain naming via mapping.
- Mapping lives in the API layer (Service/Mapping); UI/Feature must never depend on DTOs.
## Pre-PR checks
- Remove unnecessary public modifiers; check for reverse dependencies across layers.
- Ensure UI injects services via @Environment and contains no networking details.
- Ensure DTO→domain mapping is complete, robust, and testable.
Note: When using iOS 26 features, follow availability checks and progressive enhancement; ensure reasonable fallbacks for older OS versions.
## FAQ
- After adding/removing module code, if lint reports a missing class but you are sure it exists, rebuild the package with XcodeBuild MCP and try again.

View File

@@ -0,0 +1,738 @@
---
alwaysApply: true
---
# Project Overview
> 参见 Tuist/模块化细节与常见问题排查:`.cursor/rules/tuist.mdc`
This is a native **MacOS MenuBar application** built with **Swift 6.1+** and **SwiftUI**. The codebase targets **iOS 18.0 and later**, allowing full use of modern Swift and iOS APIs. All concurrency is handled with **Swift Concurrency** (async/await, actors, @MainActor isolation) ensuring thread-safe code.
- **Frameworks & Tech:** SwiftUI for UI, Swift Concurrency with strict mode, Swift Package Manager for modular architecture
- **Architecture:** Model-View (MV) pattern using pure SwiftUI state management. We avoid MVVM and instead leverage SwiftUI's built-in state mechanisms (@State, @Observable, @Environment, @Binding)
- **Testing:** Swift Testing framework with modern @Test macros and #expect/#require assertions
- **Platform:** iOS (Simulator and Device)
- **Accessibility:** Full accessibility support using SwiftUI's accessibility modifiers
## Project Structure
The project follows a **workspace + SPM package** architecture:
```
YourApp/
├── Config/ # XCConfig build settings
│ ├── Debug.xcconfig
│ ├── Release.xcconfig
│ ├── Shared.xcconfig
│ └── Tests.xcconfig
├── YourApp.xcworkspace/ # Workspace container
├── YourApp.xcodeproj/ # App shell (minimal wrapper)
├── YourApp/ # App target - just the entry point
│ ├── Assets.xcassets/
│ ├── YourAppApp.swift # @main entry point only
│ └── YourApp.xctestplan
├── YourAppPackage/ # All features and business logic
│ ├── Package.swift
│ ├── Sources/
│ │ └── YourAppFeature/ # Feature modules
│ └── Tests/
│ └── YourAppFeatureTests/ # Swift Testing tests
└── YourAppUITests/ # UI automation tests
```
**Important:** All development work should be done in the **YourAppPackage** Swift Package, not in the app project. The app project is merely a thin wrapper that imports and launches the package features.
# Code Quality & Style Guidelines
## Swift Style & Conventions
- **Naming:** Use `UpperCamelCase` for types, `lowerCamelCase` for properties/functions. Choose descriptive names (e.g., `calculateMonthlyRevenue()` not `calcRev`)
- **Value Types:** Prefer `struct` for models and data, use `class` only when reference semantics are required
- **Enums:** Leverage Swift's powerful enums with associated values for state representation
- **Early Returns:** Prefer early return pattern over nested conditionals to avoid pyramid of doom
## Optionals & Error Handling
- Use optionals with `if let`/`guard let` for nil handling
- Never force-unwrap (`!`) without absolute certainty - prefer `guard` with failure path
- Use `do/try/catch` for error handling with meaningful error types
- Handle or propagate all errors - no empty catch blocks
# Modern SwiftUI Architecture Guidelines (2025)
### No ViewModels - Use Native SwiftUI Data Flow
**New features MUST follow these patterns:**
1. **Views as Pure State Expressions**
```swift
struct MyView: View {
@Environment(MyService.self) private var service
@State private var viewState: ViewState = .loading
enum ViewState {
case loading
case loaded(data: [Item])
case error(String)
}
var body: some View {
// View is just a representation of its state
}
}
```
2. **Use Environment Appropriately**
- **App-wide services**: Router, Theme, CurrentAccount, Client, etc. - use `@Environment`
- **Feature-specific services**: Timeline services, single-view logic - use `let` properties with `@Observable`
- Rule: Environment for cross-app/cross-feature dependencies, let properties for single-feature services
- Access app-wide via `@Environment(ServiceType.self)`
- Feature services: `private let myService = MyObservableService()`
3. **Local State Management**
- Use `@State` for view-specific state
- Use `enum` for view states (loading, loaded, error)
- Use `.task(id:)` and `.onChange(of:)` for side effects
- Pass state between views using `@Binding`
4. **No ViewModels Required**
- Views should be lightweight and disposable
- Business logic belongs in services/clients
- Test services independently, not views
- Use SwiftUI previews for visual testing
5. **When Views Get Complex**
- Split into smaller subviews
- Use compound views that compose smaller views
- Pass state via bindings between views
- Never reach for a ViewModel as the solution
# iOS 26 Features (Optional)
**Note**: If your app targets iOS 26+, you can take advantage of these cutting-edge SwiftUI APIs introduced in June 2025. These features are optional and should only be used when your deployment target supports iOS 26.
## Available iOS 26 SwiftUI APIs
When targeting iOS 26+, consider using these new APIs:
#### Liquid Glass Effects
- `glassEffect(_:in:isEnabled:)` - Apply Liquid Glass effects to views
- `buttonStyle(.glass)` - Apply Liquid Glass styling to buttons
- `ToolbarSpacer` - Create visual breaks in toolbars with Liquid Glass
#### Enhanced Scrolling
- `scrollEdgeEffectStyle(_:for:)` - Configure scroll edge effects
- `backgroundExtensionEffect()` - Duplicate, mirror, and blur views around edges
#### Tab Bar Enhancements
- `tabBarMinimizeBehavior(_:)` - Control tab bar minimization behavior
- Search role for tabs with search field replacing tab bar
- `TabViewBottomAccessoryPlacement` - Adjust accessory view content based on placement
#### Web Integration
- `WebView` and `WebPage` - Full control over browsing experience
#### Drag and Drop
- `draggable(_:_:)` - Drag multiple items
- `dragContainer(for:id:in:selection:_:)` - Container for draggable views
#### Animation
- `@Animatable` macro - SwiftUI synthesizes custom animatable data properties
#### UI Components
- `Slider` with automatic tick marks when using step parameter
- `windowResizeAnchor(_:)` - Set window anchor point for resizing
#### Text Enhancements
- `TextEditor` now supports `AttributedString`
- `AttributedTextSelection` - Handle text selection with attributed text
- `AttributedTextFormattingDefinition` - Define text styling in specific contexts
- `FindContext` - Create find navigator in text editing views
#### Accessibility
- `AssistiveAccess` - Support Assistive Access in iOS scenes
#### HDR Support
- `Color.ResolvedHDR` - RGBA values with HDR headroom information
#### UIKit Integration
- `UIHostingSceneDelegate` - Host and present SwiftUI scenes in UIKit
- `NSGestureRecognizerRepresentable` - Incorporate gesture recognizers from AppKit
#### Immersive Spaces (if applicable)
- `manipulable(coordinateSpace:operations:inertia:isEnabled:onChanged:)` - Hand gesture manipulation
- `SurfaceSnappingInfo` - Snap volumes and windows to surfaces
- `RemoteImmersiveSpace` - Render stereo content from Mac to Apple Vision Pro
- `SpatialContainer` - 3D layout container
- Depth-based modifiers: `aspectRatio3D(_:contentMode:)`, `rotation3DLayout(_:)`, `depthAlignment(_:)`
## iOS 26 Usage Guidelines
- **Only use when targeting iOS 26+**: Ensure your deployment target supports these APIs
- **Progressive enhancement**: Use availability checks if supporting multiple iOS versions
- **Feature detection**: Test on older simulators to ensure graceful fallbacks
- **Modern aesthetics**: Leverage Liquid Glass effects for cutting-edge UI design
```swift
// Example: Using iOS 26 features with availability checks
struct ModernButton: View {
var body: some View {
Button("Tap me") {
// Action
}
.buttonStyle({
if #available(iOS 26.0, *) {
.glass
} else {
.bordered
}
}())
}
}
```
## SwiftUI State Management (MV Pattern)
- **@State:** For all state management, including observable model objects
- **@Observable:** Modern macro for making model classes observable (replaces ObservableObject)
- **@Environment:** For dependency injection and shared app state
- **@Binding:** For two-way data flow between parent and child views
- **@Bindable:** For creating bindings to @Observable objects
- Avoid ViewModels - put view logic directly in SwiftUI views using these state mechanisms
- Keep views focused and extract reusable components
Example with @Observable:
```swift
@Observable
class UserSettings {
var theme: Theme = .light
var fontSize: Double = 16.0
}
@MainActor
struct SettingsView: View {
@State private var settings = UserSettings()
var body: some View {
VStack {
// Direct property access, no $ prefix needed
Text("Font Size: \(settings.fontSize)")
// For bindings, use @Bindable
@Bindable var settings = settings
Slider(value: $settings.fontSize, in: 10...30)
}
}
}
// Sharing state across views
@MainActor
struct ContentView: View {
@State private var userSettings = UserSettings()
var body: some View {
NavigationStack {
MainView()
.environment(userSettings)
}
}
}
@MainActor
struct MainView: View {
@Environment(UserSettings.self) private var settings
var body: some View {
Text("Current theme: \(settings.theme)")
}
}
```
Example with .task modifier for async operations:
```swift
@Observable
class DataModel {
var items: [Item] = []
var isLoading = false
func loadData() async throws {
isLoading = true
defer { isLoading = false }
// Simulated network call
try await Task.sleep(for: .seconds(1))
items = try await fetchItems()
}
}
@MainActor
struct ItemListView: View {
@State private var model = DataModel()
var body: some View {
List(model.items) { item in
Text(item.name)
}
.overlay {
if model.isLoading {
ProgressView()
}
}
.task {
// This task automatically cancels when view disappears
do {
try await model.loadData()
} catch {
// Handle error
}
}
.refreshable {
// Pull to refresh also uses async/await
try? await model.loadData()
}
}
}
```
## Concurrency
- **@MainActor:** All UI updates must use @MainActor isolation
- **Actors:** Use actors for expensive operations like disk I/O, network calls, or heavy computation
- **async/await:** Always prefer async functions over completion handlers
- **Task:** Use structured concurrency with proper task cancellation
- **.task modifier:** Always use .task { } on views for async operations tied to view lifecycle - it automatically handles cancellation
- **Avoid Task { } in onAppear:** This doesn't cancel automatically and can cause memory leaks or crashes
- No GCD usage - Swift Concurrency only
### Sendable Conformance
Swift 6 enforces strict concurrency checking. All types that cross concurrency boundaries must be Sendable:
- **Value types (struct, enum):** Usually Sendable if all properties are Sendable
- **Classes:** Must be marked `final` and have immutable or Sendable properties, or use `@unchecked Sendable` with thread-safe implementation
- **@Observable classes:** Automatically Sendable when all properties are Sendable
- **Closures:** Mark as `@Sendable` when captured by concurrent contexts
```swift
// Sendable struct - automatic conformance
struct UserData: Sendable {
let id: UUID
let name: String
}
// Sendable class - must be final with immutable properties
final class Configuration: Sendable {
let apiKey: String
let endpoint: URL
init(apiKey: String, endpoint: URL) {
self.apiKey = apiKey
self.endpoint = endpoint
}
}
// @Observable with Sendable
@Observable
final class UserModel: Sendable {
var name: String = ""
var age: Int = 0
// Automatically Sendable if all stored properties are Sendable
}
// Using @unchecked Sendable for thread-safe types
final class Cache: @unchecked Sendable {
private let lock = NSLock()
private var storage: [String: Any] = [:]
func get(_ key: String) -> Any? {
lock.withLock { storage[key] }
}
}
// @Sendable closures
func processInBackground(completion: @Sendable @escaping (Result<Data, Error>) -> Void) {
Task {
// Processing...
completion(.success(data))
}
}
```
## Code Organization
- Keep functions focused on a single responsibility
- Break large functions (>50 lines) into smaller, testable units
- Use extensions to organize code by feature or protocol conformance
- Prefer `let` over `var` - use immutability by default
- Use `[weak self]` in closures to prevent retain cycles
- Always include `self.` when referring to instance properties in closures
# Testing Guidelines
We use **Swift Testing** framework (not XCTest) for all tests. Tests live in the package test target.
## Swift Testing Basics
```swift
import Testing
@Test func userCanLogin() async throws {
let service = AuthService()
let result = try await service.login(username: "test", password: "pass")
#expect(result.isSuccess)
#expect(result.user.name == "Test User")
}
@Test("User sees error with invalid credentials")
func invalidLogin() async throws {
let service = AuthService()
await #expect(throws: AuthError.self) {
try await service.login(username: "", password: "")
}
}
```
## Key Swift Testing Features
- **@Test:** Marks a test function (replaces XCTest's test prefix)
- **@Suite:** Groups related tests together
- **#expect:** Validates conditions (replaces XCTAssert)
- **#require:** Like #expect but stops test execution on failure
- **Parameterized Tests:** Use @Test with arguments for data-driven tests
- **async/await:** Full support for testing async code
- **Traits:** Add metadata like `.bug()`, `.feature()`, or custom tags
## Test Organization
- Write tests in the package's Tests/ directory
- One test file per source file when possible
- Name tests descriptively explaining what they verify
- Test both happy paths and edge cases
- Add tests for bug fixes to prevent regression
# Entitlements Management
This template includes a **declarative entitlements system** that AI agents can safely modify without touching Xcode project files.
## How It Works
- **Entitlements File**: `Config/MyProject.entitlements` contains all app capabilities
- **XCConfig Integration**: `CODE_SIGN_ENTITLEMENTS` setting in `Config/Shared.xcconfig` points to the entitlements file
- **AI-Friendly**: Agents can edit the XML file directly to add/remove capabilities
## Adding Entitlements
To add capabilities to your app, edit `Config/MyProject.entitlements`:
## Common Entitlements
| Capability | Entitlement Key | Value |
|------------|-----------------|-------|
| HealthKit | `com.apple.developer.healthkit` | `<true/>` |
| CloudKit | `com.apple.developer.icloud-services` | `<array><string>CloudKit</string></array>` |
| Push Notifications | `aps-environment` | `development` or `production` |
| App Groups | `com.apple.security.application-groups` | `<array><string>group.id</string></array>` |
| Keychain Sharing | `keychain-access-groups` | `<array><string>$(AppIdentifierPrefix)bundle.id</string></array>` |
| Background Modes | `com.apple.developer.background-modes` | `<array><string>mode-name</string></array>` |
| Contacts | `com.apple.developer.contacts.notes` | `<true/>` |
| Camera | `com.apple.developer.avfoundation.audio` | `<true/>` |
# XcodeBuildMCP Tool Usage
To work with this project, build, test, and development commands should use XcodeBuildMCP tools instead of raw command-line calls.
## Project Discovery & Setup
```javascript
// Discover Xcode projects in the workspace
discover_projs({
workspaceRoot: "/path/to/YourApp"
})
// List available schemes
list_schems_ws({
workspacePath: "/path/to/YourApp.xcworkspace"
})
```
## Building for Simulator
```javascript
// Build for iPhone simulator by name
build_sim_name_ws({
workspacePath: "/path/to/YourApp.xcworkspace",
scheme: "YourApp",
simulatorName: "iPhone 16",
configuration: "Debug"
})
// Build and run in one step
build_run_sim_name_ws({
workspacePath: "/path/to/YourApp.xcworkspace",
scheme: "YourApp",
simulatorName: "iPhone 16"
})
```
## Building for Device
```javascript
// List connected devices first
list_devices()
// Build for physical device
build_dev_ws({
workspacePath: "/path/to/YourApp.xcworkspace",
scheme: "YourApp",
configuration: "Debug"
})
```
## Testing
```javascript
// Run tests on simulator
test_sim_name_ws({
workspacePath: "/path/to/YourApp.xcworkspace",
scheme: "YourApp",
simulatorName: "iPhone 16"
})
// Run tests on device
test_device_ws({
workspacePath: "/path/to/YourApp.xcworkspace",
scheme: "YourApp",
deviceId: "DEVICE_UUID_HERE"
})
// Test Swift Package
swift_package_test({
packagePath: "/path/to/YourAppPackage"
})
```
## Simulator Management
```javascript
// List available simulators
list_sims({
enabled: true
})
// Boot simulator
boot_sim({
simulatorUuid: "SIMULATOR_UUID"
})
// Install app
install_app_sim({
simulatorUuid: "SIMULATOR_UUID",
appPath: "/path/to/YourApp.app"
})
// Launch app
launch_app_sim({
simulatorUuid: "SIMULATOR_UUID",
bundleId: "com.example.YourApp"
})
```
## Device Management
```javascript
// Install on device
install_app_device({
deviceId: "DEVICE_UUID",
appPath: "/path/to/YourApp.app"
})
// Launch on device
launch_app_device({
deviceId: "DEVICE_UUID",
bundleId: "com.example.YourApp"
})
```
## UI Automation
```javascript
// Get UI hierarchy
describe_ui({
simulatorUuid: "SIMULATOR_UUID"
})
// Tap element
tap({
simulatorUuid: "SIMULATOR_UUID",
x: 100,
y: 200
})
// Type text
type_text({
simulatorUuid: "SIMULATOR_UUID",
text: "Hello World"
})
// Take screenshot
screenshot({
simulatorUuid: "SIMULATOR_UUID"
})
```
## Log Capture
```javascript
// Start capturing simulator logs
start_sim_log_cap({
simulatorUuid: "SIMULATOR_UUID",
bundleId: "com.example.YourApp"
})
// Stop and retrieve logs
stop_sim_log_cap({
logSessionId: "SESSION_ID"
})
// Device logs
start_device_log_cap({
deviceId: "DEVICE_UUID",
bundleId: "com.example.YourApp"
})
```
## Utility Functions
```javascript
// Get bundle ID from app
get_app_bundle_id({
appPath: "/path/to/YourApp.app"
})
// Clean build artifacts
clean_ws({
workspacePath: "/path/to/YourApp.xcworkspace"
})
// Get app path for simulator
get_sim_app_path_name_ws({
workspacePath: "/path/to/YourApp.xcworkspace",
scheme: "YourApp",
platform: "iOS Simulator",
simulatorName: "iPhone 16"
})
```
# Development Workflow
1. **Make changes in the Package**: All feature development happens in YourAppPackage/Sources/
2. **Write tests**: Add Swift Testing tests in YourAppPackage/Tests/
3. **Build and test**: Use XcodeBuildMCP tools to build and run tests
4. **Run on simulator**: Deploy to simulator for manual testing
5. **UI automation**: Use describe_ui and automation tools for UI testing
6. **Device testing**: Deploy to physical device when needed
# Best Practices
## SwiftUI & State Management
- Keep views small and focused
- Extract reusable components into their own files
- Use @ViewBuilder for conditional view composition
- Leverage SwiftUI's built-in animations and transitions
- Avoid massive body computations - break them down
- **Always use .task modifier** for async work tied to view lifecycle - it automatically cancels when the view disappears
- Never use Task { } in onAppear - use .task instead for proper lifecycle management
## Performance
- Use .id() modifier sparingly as it forces view recreation
- Implement Equatable on models to optimize SwiftUI diffing
- Use LazyVStack/LazyHStack for large lists
- Profile with Instruments when needed
- @Observable tracks only accessed properties, improving performance over @Published
## Accessibility
- Always provide accessibilityLabel for interactive elements
- Use accessibilityIdentifier for UI testing
- Implement accessibilityHint where actions aren't obvious
- Test with VoiceOver enabled
- Support Dynamic Type
## Security & Privacy
- Never log sensitive information
- Use Keychain for credential storage
- All network calls must use HTTPS
- Request minimal permissions
- Follow App Store privacy guidelines
## Data Persistence
When data persistence is required, always prefer **SwiftData** over CoreData. However, carefully consider whether persistence is truly necessary - many apps can function well with in-memory state that loads on launch.
### When to Use SwiftData
- You have complex relational data that needs to persist across app launches
- You need advanced querying capabilities with predicates and sorting
- You're building a data-heavy app (note-taking, inventory, task management)
- You need CloudKit sync with minimal configuration
### When NOT to Use Data Persistence
- Simple user preferences (use UserDefaults)
- Temporary state that can be reloaded from network
- Small configuration data (consider JSON files or plist)
- Apps that primarily display remote data
### SwiftData Best Practices
```swift
import SwiftData
@Model
final class Task {
var title: String
var isCompleted: Bool
var createdAt: Date
init(title: String) {
self.title = title
self.isCompleted = false
self.createdAt = Date()
}
}
// In your app
@main
struct MyProjectApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.modelContainer(for: Task.self)
}
}
}
// In your views
struct TaskListView: View {
@Query private var tasks: [Task]
@Environment(\.modelContext) private var context
var body: some View {
List(tasks) { task in
Text(task.title)
}
.toolbar {
Button("Add") {
let newTask = Task(title: "New Task")
context.insert(newTask)
}
}
}
}
```
**Important:** Never use CoreData for new projects. SwiftData provides a modern, type-safe API that's easier to work with and integrates seamlessly with SwiftUI.
---
Remember: This project prioritizes clean, simple SwiftUI code using the platform's native state management. Keep the app shell minimal and implement all features in the Swift Package.

View File

@@ -0,0 +1,198 @@
---
alwaysApply: false
---
# Tuist 集成与模块化拆分Vibeviewer
本规则记录项目接入 Tuist、按 Feature 拆分为独立 SPM 包、UI 层依赖注入,以及常见问题排查与修复。
## 标准方案Single Source of Truth
- 仅在 `Project.swift` 的 `packages` 节点声明本地包,保持“单一来源”。
- 不使用 `Tuist/Dependencies.swift` 声明本地包,避免与 `Project.swift` 重复导致解析冲突。
- App 目标依赖统一使用 `.package(product:)`。
- 生成工程:`make generate`;清理:`make clear`(仅清当前项目 DerivedData 与项目级 Tuist 缓存)。
示例(节选,自 `Project.swift`
```swift
packages: [
.local(path: "Packages/VibeviewerCore"),
.local(path: "Packages/VibeviewerModel"),
.local(path: "Packages/VibeviewerAPI"),
.local(path: "Packages/VibeviewerLoginUI"),
.local(path: "Packages/VibeviewerMenuUI"),
.local(path: "Packages/VibeviewerSettingsUI")
],
```
## UI 层依赖注入(遵循 project.mdc
- 不使用 MVVM视图内部用 `@State` 管理轻量状态。
- 使用 Environment 注入跨模块依赖:
- 在 `VibeviewerModel` 暴露 `EnvironmentValues.cursorStorage`。
- 在 `VibeviewerMenuUI` 暴露 `EnvironmentValues.cursorService`、`loginWindowManager`、`settingsWindowManager`。
- App 注入:
```swift
MenuPopoverView()
.environment(\.cursorService, DefaultCursorService())
.environment(\.cursorStorage, CursorStorage.shared)
.environment(\.loginWindowManager, LoginWindowManager.shared)
.environment(\.settingsWindowManager, SettingsWindowManager.shared)
```
- 视图使用:
```swift
@Environment(\.cursorService) private var service
@Environment(\.cursorStorage) private var storage
@Environment(\.loginWindowManager) private var loginWindow
@Environment(\.settingsWindowManager) private var settingsWindow
```
## Feature 拆包规范
- 单一职责:
- `VibeviewerLoginUI`:登录视图与窗口
- `VibeviewerMenuUI`:菜单视图与业务触发
- `VibeviewerSettingsUI`:设置视图与窗口
- 每个包必须包含测试目录 `Tests/<TargetName>Tests/`(即便是占位),否则会出现测试路径报错。
## 常见问题与排查
- 包在 Xcode 里显示为“文件夹 + ?”,不是 SPM 包:
- 原因:`Project.swift` 与 `Tuist/Dependencies.swift` 同时声明了本地包(重复来源),或 SwiftPM/Xcode 缓存脏。
- 处理:删除 `Tuist/Dependencies.swift` 的本地包声明(本项目直接删除该文件);删除各包 `.swiftpm/``make clear` 后再 `make generate`。
### 修复步骤示例VibeviewerAppEnvironment 未作为包加载/显示为文件夹)
1. 确认 Single Source of Truth仅在 `Project.swift` 的 `packages` 节点保留本地包声明。
- 保持如下形式(节选):
```swift
packages: [
.local(path: "Packages/VibeviewerCore"),
.local(path: "Packages/VibeviewerModel"),
.local(path: "Packages/VibeviewerAPI"),
.local(path: "Packages/VibeviewerLoginUI"),
.local(path: "Packages/VibeviewerMenuUI"),
.local(path: "Packages/VibeviewerSettingsUI"),
.local(path: "Packages/VibeviewerAppEnvironment"),
]
```
2. 清空 `Tuist/Dependencies.swift` 的本地包声明,避免与 `Project.swift` 重复:
```swift
let dependencies = Dependencies(
swiftPackageManager: .init(
packages: [ /* 留空,统一由 Project.swift 管理 */ ],
baseSettings: .settings(base: [:], configurations: [/* 省略 */])
),
platforms: [.macOS]
)
```
- 注:也可直接删除该文件;两者目标一致——移除重复来源。
3. 可选清理缓存(若仍显示为文件夹或解析异常):
- 删除各包下残留的 `.swiftpm/` 目录(若存在)。
4. 重新生成工程:
```bash
make clear && make generate
```
5. 验证:
- Xcode 的 Project Navigator 中,`VibeviewerAppEnvironment` 以 Swift Package 方式展示(非普通文件夹)。
- App 目标依赖通过 `.package(product: "VibeviewerAppEnvironment")` 引入。
- “Couldn't load project at …/.swiftpm/xcode”
- 原因:加载了过期的 `.swiftpm/xcode` 子工程缓存。
- 处理:删除对应包 `.swiftpm/` 后重新生成。
- `no such module 'X'`
- 原因:缺少包/目标依赖或未在 `packages` 声明路径。
- 处理:在包的 `Package.swift` 增加依赖;在 `Project.swift` 的 `packages` 增加 `.local(path:)`;再生成。
- 捕获列表语法错误(如 `[weak _ = service]`
- Swift 不允许匿名弱引用捕获。移除该语法,使用受控任务生命周期(持有 `Task` 并适时取消)。
## Make 命令
- 生成:
```bash
make generate
```
- 清理(当前项目):
```bash
make clear
```
## 新增 Feature 包 Checklist
1. 在 `Packages/YourFeature/` 创建 `Package.swift`、`Sources/YourFeature/`、`Tests/YourFeatureTests/`。
2. 在 `Package.swift` 写入 `.package(path: ...)` 与 `targets.target.dependencies`。
3. 在 `Project.swift` 的 `packages` 增加 `.local(path: ...)`,并在 App 目标依赖加 `.package(product: ...)`。
4. `make generate` 重新生成。
> 经验:保持“单一来源”(只在 `Project.swift` 声明本地包)显著降低 Tuist/SwiftPM 解析歧义与缓存问题。# Tuist 集成与模块化拆分Vibeviewer
本规则记录项目接入 Tuist、按 Feature 拆分为独立 SPM 包、UI 层依赖注入,以及常见问题排查与修复。
## 标准方案Single Source of Truth
- 仅在 `Project.swift` 的 `packages` 节点声明本地包,保持“单一来源”。
- 不使用 `Tuist/Dependencies.swift` 声明本地包,避免与 `Project.swift` 重复导致解析冲突。
- App 目标依赖统一使用 `.package(product:)`。
- 生成工程:`make generate`;清理:`make clear`(仅清当前项目 DerivedData 与项目级 Tuist 缓存)。
示例(节选,自 `Project.swift`
```swift
packages: [
.local(path: "Packages/VibeviewerCore"),
.local(path: "Packages/VibeviewerModel"),
.local(path: "Packages/VibeviewerAPI"),
.local(path: "Packages/VibeviewerLoginUI"),
.local(path: "Packages/VibeviewerMenuUI"),
.local(path: "Packages/VibeviewerSettingsUI")
],
```
## UI 层依赖注入(遵循 project.mdc
- 不使用 MVVM视图内部用 `@State` 管理轻量状态。
- 使用 Environment 注入跨模块依赖:
- 在 `VibeviewerModel` 暴露 `EnvironmentValues.cursorStorage`。
- 在 `VibeviewerMenuUI` 暴露 `EnvironmentValues.cursorService`、`loginWindowManager`、`settingsWindowManager`。
- App 注入:
```swift
MenuPopoverView()
.environment(\.cursorService, DefaultCursorService())
.environment(\.cursorStorage, CursorStorage.shared)
.environment(\.loginWindowManager, LoginWindowManager.shared)
.environment(\.settingsWindowManager, SettingsWindowManager.shared)
```
- 视图使用:
```swift
@Environment(\.cursorService) private var service
@Environment(\.cursorStorage) private var storage
@Environment(\.loginWindowManager) private var loginWindow
@Environment(\.settingsWindowManager) private var settingsWindow
```
## Feature 拆包规范
- 单一职责:
- `VibeviewerLoginUI`:登录视图与窗口
- `VibeviewerMenuUI`:菜单视图与业务触发
- `VibeviewerSettingsUI`:设置视图与窗口
- 每个包必须包含测试目录 `Tests/<TargetName>Tests/`(即便是占位),否则会出现测试路径报错。
## 常见问题与排查
- 包在 Xcode 里显示为“文件夹 + ?”,不是 SPM 包:
- 原因:`Project.swift` 与 `Tuist/Dependencies.swift` 同时声明了本地包(重复来源),或 SwiftPM/Xcode 缓存脏。
- 处理:删除 `Tuist/Dependencies.swift` 的本地包声明(本项目直接删除该文件);删除各包 `.swiftpm/``make clear` 后再 `make generate`。
- “Couldn't load project at …/.swiftpm/xcode”
- 原因:加载了过期的 `.swiftpm/xcode` 子工程缓存。
- 处理:删除对应包 `.swiftpm/` 后重新生成。
- `no such module 'X'`
- 原因:缺少包/目标依赖或未在 `packages` 声明路径。
- 处理:在包的 `Package.swift` 增加依赖;在 `Project.swift` 的 `packages` 增加 `.local(path:)`;再生成。
- 捕获列表语法错误(如 `[weak _ = service]`
- Swift 不允许匿名弱引用捕获。移除该语法,使用受控任务生命周期(持有 `Task` 并适时取消)。
## Make 命令
- 生成:
```bash
make generate
```
- 清理(当前项目):
```bash
make clear
```
## 新增 Feature 包 Checklist
1. 在 `Packages/YourFeature/` 创建 `Package.swift`、`Sources/YourFeature/`、`Tests/YourFeatureTests/`。
2. 在 `Package.swift` 写入 `.package(path: ...)` 与 `targets.target.dependencies`。
3. 在 `Project.swift` 的 `packages` 增加 `.local(path: ...)`,并在 App 目标依赖加 `.package(product: ...)`。
4. `make generate` 重新生成。
> 经验:保持“单一来源”(只在 `Project.swift` 声明本地包)显著降低 Tuist/SwiftPM 解析歧义与缓存问题。

117
参考计费/.gitignore vendored Normal file
View File

@@ -0,0 +1,117 @@
# Xcode
#
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
## User settings
xcuserdata/
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
*.xcscmblueprint
*.xccheckout
*.xcodeproj
*.xcworkspace
## macos
*.dmg
*.app
*.app.zip
*.app.tar.gz
*.app.tar.bz2
*.app.tar.xz
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
build/
DerivedData/
*.moved-aside
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
## Obj-C/Swift specific
*.hmap
## App packaging
*.ipa
*.dSYM.zip
*.dSYM
.wrangler/
## Playgrounds
timeline.xctimeline
playground.xcworkspace
# Swift Package Manager
#
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
# Packages/
# Package.pins
# Package.resolved
# *.xcodeproj
#
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
# hence it is not needed unless you have added a package configuration file to your project
# .swiftpm
.build/
# CocoaPods
#
# We recommend against adding the Pods directory to your .gitignore. However
# you should judge for yourself, the pros and cons are mentioned at:
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
#
Pods/
#
# Add this line if you want to avoid checking in source code from the Xcode workspace
*.xcworkspace
# Carthage
#
# Add this line if you want to avoid checking in source code from Carthage dependencies.
# Carthage/Checkouts
Carthage/Build/
# Accio dependency management
Dependencies/
.accio/
# fastlane
#
# It is recommended to not store the screenshots in the git repo.
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://docs.fastlane.tools/best-practices/source-control/#source-control
**/fastlane/report.xml
**/fastlane/Preview.html
**/fastlane/screenshots
**/fastlane/test_output
.DS_Store
**/.build/
# Info.plist
# Tuist (generated artifacts not necessary to track)
# Project/workspace are already ignored above via *.xcodeproj / *.xcworkspace
# Ignore project-local Derived directory that may appear at repo root
Derived/
# Potential Tuist local directories (safe to ignore if present)
Tuist/Derived/
Tuist/Cache/
Tuist/Graph/
buildServer.json
# Sparkle 更新相关文件
Scripts/sparkle_keys/eddsa_private_key.pem
Scripts/sparkle_keys/eddsa_private_key_base64.txt
Scripts/sparkle_keys/signature_*.txt
Scripts/sparkle/
*.tar.xz
temp_dmg/

View File

@@ -0,0 +1,42 @@
{
"originHash" : "dd4976b5f6a35b41f285c4d19c0e521031fb5d395df8adc8ed7a8e14ad1db176",
"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
}

28
参考计费/.swiftformat Normal file
View File

@@ -0,0 +1,28 @@
--swiftversion 5.10
--indent 4
--tabwidth 4
--allman false
--wraparguments before-first
--wrapcollections before-first
--maxwidth 160
--wrapreturntype preserve
--wrapparameters before-first
--stripunusedargs closure-only
--header ignore
--enable enumNamespaces
--self insert
# Enabled rules (opt-in)
--enable isEmpty
--enable redundantType
--enable redundantReturn
--enable extensionAccessControl
# Disabled rules (avoid risky auto-fixes by default)
--disable strongOutlets
--disable trailingCommas
# File options
--exclude Derived,.build,**/Package.swift

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

23
参考计费/LICENSE Normal file
View File

@@ -0,0 +1,23 @@
MIT License
Copyright (c) 2025 Groot chen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

32
参考计费/Makefile Normal file
View File

@@ -0,0 +1,32 @@
.PHONY: generate clear build dmg dmg-release release
generate:
@Scripts/generate.sh
clear:
@Scripts/clear.sh
build:
@echo "🔨 Building Vibeviewer..."
@xcodebuild -workspace Vibeviewer.xcworkspace -scheme Vibeviewer -configuration Release -destination "platform=macOS" -skipMacroValidation build
dmg:
@echo "💽 Creating DMG package..."
@Scripts/create_dmg.sh
dmg-release:
@echo "💽 Creating DMG package..."
@Scripts/create_dmg.sh
release: clear generate build dmg-release
@echo "🚀 Release build completed! DMG is ready for distribution."
@echo "📋 Next steps:"
@echo " 1. Create GitHub Release (tag: v<VERSION>)"
@echo " 2. Upload DMG file"
@echo ""
@echo "💡 提示: 使用 ./Scripts/release.sh 可以自动化整个流程"
release-full:
@Scripts/release.sh

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

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