蜂鸟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:
736
参考计费/.CLAUDE.md
Normal file
736
参考计费/.CLAUDE.md
Normal 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.
|
||||
47
参考计费/.cursor/commands/release_version.md
Normal file
47
参考计费/.cursor/commands/release_version.md
Normal 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
|
||||
18
参考计费/.cursor/mcp.json
Normal file
18
参考计费/.cursor/mcp.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"tuist": {
|
||||
"command": "/opt/homebrew/bin/tuist",
|
||||
"args": [
|
||||
"mcp",
|
||||
"start"
|
||||
]
|
||||
},
|
||||
"XcodeBuildMCP": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"xcodebuildmcp@latest"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
149
参考计费/.cursor/rules/api_guideline.mdc
Normal file
149
参考计费/.cursor/rules/api_guideline.mdc
Normal 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
|
||||
|
||||
|
||||
108
参考计费/.cursor/rules/architecture.mdc
Normal file
108
参考计费/.cursor/rules/architecture.mdc
Normal 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.
|
||||
|
||||
738
参考计费/.cursor/rules/project.mdc
Normal file
738
参考计费/.cursor/rules/project.mdc
Normal 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.
|
||||
198
参考计费/.cursor/rules/tuist.mdc
Normal file
198
参考计费/.cursor/rules/tuist.mdc
Normal 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
117
参考计费/.gitignore
vendored
Normal 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/
|
||||
42
参考计费/.package.resolved
Normal file
42
参考计费/.package.resolved
Normal 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
28
参考计费/.swiftformat
Normal 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
|
||||
|
||||
BIN
参考计费/Images/image.png
Normal file
BIN
参考计费/Images/image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
23
参考计费/LICENSE
Normal file
23
参考计费/LICENSE
Normal 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
32
参考计费/Makefile
Normal 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
|
||||
|
||||
|
||||
42
参考计费/Packages/VibeviewerAPI/Package.resolved
Normal file
42
参考计费/Packages/VibeviewerAPI/Package.resolved
Normal 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
|
||||
}
|
||||
34
参考计费/Packages/VibeviewerAPI/Package.swift
Normal file
34
参考计费/Packages/VibeviewerAPI/Package.swift
Normal 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"]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 "
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 usage(以分计)。计算方式:includedSpendCents - hardLimitOverrideDollars*100,若小于0则为0
|
||||
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: 团队 ID,Pro 账号传 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
|
||||
}
|
||||
|
||||
// 如果数据不足7天,从今天往前补足7天
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
enum HttpClientError: Error {
|
||||
case missingParams
|
||||
case invalidateParams
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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("===================================================================")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import Testing
|
||||
|
||||
@Test func placeholderTest() async throws {
|
||||
// Placeholder test to ensure test target builds correctly
|
||||
#expect(true)
|
||||
}
|
||||
42
参考计费/Packages/VibeviewerAppEnvironment/Package.resolved
Normal file
42
参考计费/Packages/VibeviewerAppEnvironment/Package.resolved
Normal 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
|
||||
}
|
||||
42
参考计费/Packages/VibeviewerAppEnvironment/Package.swift
Normal file
42
参考计费/Packages/VibeviewerAppEnvironment/Package.swift
Normal 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"
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 events(700 条)
|
||||
/// - 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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 { "更新服务不可用" }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
@testable import VibeviewerCore
|
||||
import XCTest
|
||||
|
||||
final class VibeviewerAppEnvironmentTests: XCTestCase {
|
||||
func testExample() {
|
||||
XCTAssertTrue(true)
|
||||
}
|
||||
}
|
||||
17
参考计费/Packages/VibeviewerCore/Package.swift
Normal file
17
参考计费/Packages/VibeviewerCore/Package.swift
Normal 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"])
|
||||
]
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
public extension Int {
|
||||
var dollarStringFromCents: String {
|
||||
"$" + String(format: "%.2f", Double(self) / 100.0)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@testable import VibeviewerCore
|
||||
import XCTest
|
||||
|
||||
final class VibeviewerCoreTests: XCTestCase {
|
||||
func testExample() {
|
||||
XCTAssertTrue(true)
|
||||
}
|
||||
}
|
||||
26
参考计费/Packages/VibeviewerLoginUI/Package.swift
Normal file
26
参考计费/Packages/VibeviewerLoginUI/Package.swift
Normal 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"])
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@testable import VibeviewerLoginUI
|
||||
import XCTest
|
||||
|
||||
final class VibeviewerLoginUITests: XCTestCase {
|
||||
func testExample() {
|
||||
XCTAssertTrue(true)
|
||||
}
|
||||
}
|
||||
42
参考计费/Packages/VibeviewerMenuUI/Package.resolved
Normal file
42
参考计费/Packages/VibeviewerMenuUI/Package.resolved
Normal 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
|
||||
}
|
||||
36
参考计费/Packages/VibeviewerMenuUI/Package.swift
Normal file
36
参考计费/Packages/VibeviewerMenuUI/Package.swift
Normal 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"]),
|
||||
]
|
||||
)
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
// 左侧:使用 topTrailing,annotation 显示在右侧
|
||||
return .topTrailing
|
||||
} else if selectedIndex > middleIndex {
|
||||
// 右侧:使用 topLeading,annotation 显示在左侧
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@testable import VibeviewerMenuUI
|
||||
import XCTest
|
||||
|
||||
final class VibeviewerMenuUITests: XCTestCase {
|
||||
func testExample() {
|
||||
XCTAssertTrue(true)
|
||||
}
|
||||
}
|
||||
42
参考计费/Packages/VibeviewerModel/Package.resolved
Normal file
42
参考计费/Packages/VibeviewerModel/Package.resolved
Normal 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
|
||||
}
|
||||
19
参考计费/Packages/VibeviewerModel/Package.swift
Normal file
19
参考计费/Packages/VibeviewerModel/Package.swift
Normal 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"])
|
||||
]
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
public enum AppAppearance: String, Codable, Sendable, Equatable, CaseIterable, Hashable {
|
||||
case system
|
||||
case light
|
||||
case dark
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user