1918 lines
60 KiB
Markdown
1918 lines
60 KiB
Markdown
# Bugger for macOS — Detailed Design & Implementation Document
|
||
|
||
---
|
||
|
||
## Table of Contents
|
||
|
||
1. [Project Structure](#1-project-structure)
|
||
2. [Data Models](#2-data-models)
|
||
3. [OAuth & Token Management](#3-oauth--token-management)
|
||
4. [Feishu API Integration](#4-feishu-api-integration)
|
||
5. [State Management](#5-state-management)
|
||
6. [View Layer](#6-view-layer)
|
||
7. [Notification Service](#7-notification-service)
|
||
8. [Poller Service](#8-poller-service)
|
||
9. [Floating Widget](#9-floating-widget)
|
||
10. [Settings & Persistence](#10-settings--persistence)
|
||
11. [Error Handling](#11-error-handling)
|
||
12. [App Lifecycle](#12-app-lifecycle)
|
||
13. [Testing Strategy](#13-testing-strategy)
|
||
14. [Implementation Sequence](#14-implementation-sequence)
|
||
|
||
---
|
||
|
||
## 1. Project Structure
|
||
|
||
```
|
||
Bugger/
|
||
├── Bugger.xcodeproj
|
||
├── Sources/
|
||
│ ├── BuggerApp.swift # @main App entry
|
||
│ ├── AppDelegate.swift # NSApplicationDelegate
|
||
│ │
|
||
│ ├── Models/
|
||
│ │ ├── Bug.swift # Core bug model
|
||
│ │ ├── BugPriority.swift # Priority enum
|
||
│ │ ├── BugStatus.swift # Status enum
|
||
│ │ ├── BugChange.swift # Diff result for notifications
|
||
│ │ └── AppConfig.swift # User-facing config model
|
||
│ │
|
||
│ ├── Services/
|
||
│ │ ├── Feishu/
|
||
│ │ │ ├── FeishuService.swift # Bitable API client
|
||
│ │ │ ├── FeishuAuthService.swift # OAuth flow orchestrator
|
||
│ │ │ ├── FeishuModels.swift # API request/response DTOs
|
||
│ │ │ └── FeishuError.swift # API error types
|
||
│ │ │
|
||
│ │ ├── TokenManager.swift # Keychain read/write for tokens
|
||
│ │ ├── PollerService.swift # Timer-based polling engine
|
||
│ │ ├── NotificationService.swift # Diff + UNUserNotificationCenter
|
||
│ │ └── AppStateService.swift # Persist app state between launches
|
||
│ │
|
||
│ ├── ViewModels/
|
||
│ │ └── BugStore.swift # @Observable central store
|
||
│ │
|
||
│ ├── Views/
|
||
│ │ ├── MenuBar/
|
||
│ │ │ ├── MenuBarController.swift # NSStatusItem management
|
||
│ │ │ ├── BugListPopover.swift # Popover content
|
||
│ │ │ └── BugRow.swift # Single bug row component
|
||
│ │ │
|
||
│ │ ├── FloatingWidget/
|
||
│ │ │ ├── FloatingWidgetWindow.swift # NSPanel subclass
|
||
│ │ │ └── FloatingWidgetView.swift # SwiftUI content
|
||
│ │ │
|
||
│ │ ├── Settings/
|
||
│ │ │ ├── SettingsWindow.swift # NSWindow wrapper
|
||
│ │ │ └── SettingsView.swift # SwiftUI form
|
||
│ │ │
|
||
│ │ └── OAuth/
|
||
│ │ ├── OAuthSetupView.swift # First-launch OAuth flow
|
||
│ │ └── OAuthCallbackHandler.swift # Custom URL scheme handler
|
||
│ │
|
||
│ └── Utils/
|
||
│ ├── KeychainHelper.swift # Security framework wrapper
|
||
│ ├── DateFormatter+Extensions.swift
|
||
│ ├── Color+Extensions.swift # Priority/status colors
|
||
│ └── URL+Feishu.swift # Feishu URL builders
|
||
│
|
||
├── Resources/
|
||
│ ├── Assets.xcassets/
|
||
│ │ ├── AppIcon.icns
|
||
│ │ ├── MenuBarIcon.svg # Template image for menu bar
|
||
│ │ └── PriorityIcons/
|
||
│ ├── Info.plist
|
||
│ └── Bugger.entitlements # Keychain access, notifications
|
||
│
|
||
├── Tests/
|
||
│ ├── BuggerTests/
|
||
│ │ ├── FeishuServiceTests.swift
|
||
│ │ ├── TokenManagerTests.swift
|
||
│ │ ├── BugStoreTests.swift
|
||
│ │ ├── NotificationServiceTests.swift
|
||
│ │ └── Mocks/
|
||
│ │ ├── MockURLProtocol.swift
|
||
│ │ └── MockKeychainHelper.swift
|
||
│ │
|
||
│ └── BuggerUITests/
|
||
│ └── MenuBarUITests.swift
|
||
│
|
||
└── Package.swift # SPM dependencies
|
||
```
|
||
|
||
### Dependencies (SPM)
|
||
|
||
| Package | Purpose |
|
||
|---|---|
|
||
| N/A (stdlib only) | The app uses Foundation + SwiftUI + AppKit + Security + UserNotifications. No third-party dependencies required for v1. |
|
||
|
||
This is intentional: a menu bar utility should have a minimal supply-chain
|
||
surface. `URLSession` handles networking; `JSONDecoder` handles parsing;
|
||
`Security` framework handles Keychain.
|
||
|
||
---
|
||
|
||
## 2. Data Models
|
||
|
||
### 2.1 `Bug` — the core model
|
||
|
||
```swift
|
||
struct Bug: Identifiable, Equatable, Hashable {
|
||
let id: String // Feishu record_id
|
||
let title: String // Bug title field
|
||
let priority: BugPriority
|
||
let status: BugStatus
|
||
let assignee: String // Feishu user name
|
||
let reporter: String? // Who filed it
|
||
let createdAt: Date
|
||
let updatedAt: Date
|
||
let feishuURL: URL // Deep link to the record
|
||
|
||
// Computed
|
||
var age: TimeInterval { Date().timeIntervalSince(createdAt) }
|
||
var isNew: Bool // Set by BugStore diff
|
||
}
|
||
|
||
enum BugPriority: String, Codable, Comparable {
|
||
case p0 = "P0" // Critical / Blocker
|
||
case p1 = "P1" // High
|
||
case p2 = "P2" // Medium
|
||
case p3 = "P3" // Low
|
||
case unknown
|
||
|
||
static func < (lhs: BugPriority, rhs: BugPriority) -> Bool {
|
||
order(lhs) < order(rhs)
|
||
}
|
||
|
||
private static func order(_ p: BugPriority) -> Int {
|
||
switch p {
|
||
case .p0: 0; case .p1: 1; case .p2: 2; case .p3: 3; case .unknown: 4
|
||
}
|
||
}
|
||
}
|
||
|
||
enum BugStatus: String, Codable {
|
||
case open = "Open"
|
||
case inProgress = "In Progress"
|
||
case inReview = "In Review"
|
||
case resolved = "Resolved"
|
||
case closed = "Closed"
|
||
case unknown
|
||
}
|
||
```
|
||
|
||
### 2.2 `BugChange` — diff result
|
||
|
||
```swift
|
||
struct BugChange {
|
||
let type: ChangeType
|
||
let bug: Bug
|
||
let oldStatus: BugStatus? // Only for .statusChanged
|
||
let oldAssignee: String? // Only for .assigneeChanged
|
||
|
||
enum ChangeType {
|
||
case newBug // Assigned to you for the first time
|
||
case statusChanged // Moved between statuses
|
||
case priorityChanged // Priority bumped or lowered
|
||
case assigneeChanged // (Rare — bug reassigned to/from you)
|
||
}
|
||
}
|
||
```
|
||
|
||
### 2.3 `FeishuModels` — API DTOs
|
||
|
||
```swift
|
||
// FeishuModels.swift
|
||
|
||
// Request
|
||
struct RecordListResponse: Decodable {
|
||
let code: Int
|
||
let msg: String
|
||
let data: RecordData?
|
||
}
|
||
|
||
struct RecordData: Decodable {
|
||
let items: [RecordItem]
|
||
let hasMore: Bool
|
||
let pageToken: String?
|
||
let total: Int
|
||
}
|
||
|
||
struct RecordItem: Decodable {
|
||
let recordId: String // maps to "record_id" in JSON
|
||
let fields: BugFields
|
||
|
||
enum CodingKeys: String, CodingKey {
|
||
case recordId = "record_id"
|
||
case fields
|
||
}
|
||
}
|
||
|
||
struct BugFields: Decodable {
|
||
let title: String?
|
||
let priority: String?
|
||
let status: String?
|
||
let assignee: [AssigneeItem]? // Feishu user fields are arrays
|
||
let reporter: [ReporterItem]?
|
||
let createdAt: String? // Timestamp from Bitable, in ms
|
||
let updatedAt: String?
|
||
|
||
enum CodingKeys: String, CodingKey {
|
||
case title = "Title"
|
||
case priority = "Priority"
|
||
case status = "Status"
|
||
case assignee = "Assignee"
|
||
case reporter = "Reporter"
|
||
case createdAt = "Created At"
|
||
case updatedAt = "Updated At"
|
||
}
|
||
}
|
||
|
||
struct AssigneeItem: Decodable {
|
||
let name: String
|
||
}
|
||
|
||
struct ReporterItem: Decodable {
|
||
let name: String
|
||
}
|
||
|
||
// Token
|
||
struct OAuthTokenResponse: Decodable {
|
||
let accessToken: String
|
||
let tokenType: String
|
||
let expiresIn: Int
|
||
let refreshToken: String?
|
||
let refreshExpiresIn: Int?
|
||
|
||
enum CodingKeys: String, CodingKey {
|
||
case accessToken = "access_token"
|
||
case tokenType = "token_type"
|
||
case expiresIn = "expires_in"
|
||
case refreshToken = "refresh_token"
|
||
case refreshExpiresIn = "refresh_expires_in"
|
||
}
|
||
}
|
||
```
|
||
|
||
### 2.4 `AppConfig` — persisted settings
|
||
|
||
```swift
|
||
struct AppConfig: Codable {
|
||
var appToken: String = "" // Feishu Bitable app_token
|
||
var tableId: String = "" // Table ID within the Bitable
|
||
var fieldMappings: FieldMappings // Column name → model field
|
||
var pollIntervalSeconds: Int = 300 // 5 minutes
|
||
var showFloatingWidget: Bool = false
|
||
var launchAtLogin: Bool = true
|
||
|
||
struct FieldMappings: Codable {
|
||
var titleField: String = "Title"
|
||
var priorityField: String = "Priority"
|
||
var statusField: String = "Status"
|
||
var assigneeField: String = "Assignee"
|
||
var reporterField: String = "Reporter"
|
||
var createdAtField: String = "Created At"
|
||
var updatedAtField: String = "Updated At"
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 3. OAuth & Token Management
|
||
|
||
### 3.1 OAuth Flow
|
||
|
||
```
|
||
User App Feishu
|
||
│ │ │
|
||
│ Click "Connect Feishu" │ │
|
||
│ ───────────────────────────▶│ │
|
||
│ │ Open browser to: │
|
||
│ │ https://open.feishu.cn/ │
|
||
│ │ open-apis/authen/v1/ │
|
||
│ │ authorize? │
|
||
│ │ app_id=xxx& │
|
||
│ │ redirect_uri=bugger:// │
|
||
│ │ oauth/callback& │
|
||
│ │ scope=bitable:app:readonly│
|
||
│ ─────────────────────────────────────────────────────────▶│
|
||
│ │ │
|
||
│ Authorize in browser │ │
|
||
│ ───────────────────────────────────────────────────────────│
|
||
│ │ │
|
||
│ Redirect: bugger:// │ │
|
||
│ oauth/callback?code=xxx │ │
|
||
│ ◀──────────────────────────│ │
|
||
│ │ │
|
||
│ │ POST /authen/v1/oidc/ │
|
||
│ │ access_token │
|
||
│ │ { code, grant_type } │
|
||
│ │ ────────────────────────────▶│
|
||
│ │ │
|
||
│ │ { access_token, │
|
||
│ │ refresh_token } │
|
||
│ │ ◀────────────────────────────│
|
||
│ │ │
|
||
│ │ Store tokens in Keychain │
|
||
│ │ Start polling │
|
||
```
|
||
|
||
### 3.2 TokenManager Implementation
|
||
|
||
```swift
|
||
// TokenManager.swift
|
||
|
||
@Observable
|
||
final class TokenManager {
|
||
static let shared = TokenManager()
|
||
|
||
private let keychain = KeychainHelper.shared
|
||
private let service = FeishuAuthService()
|
||
|
||
private let accessTokenKey = "feishu.access_token"
|
||
private let refreshTokenKey = "feishu.refresh_token"
|
||
private let tokenExpiryKey = "feishu.token_expiry"
|
||
|
||
enum State {
|
||
case unauthenticated
|
||
case authenticating
|
||
case authenticated
|
||
case refreshing
|
||
case error(Error)
|
||
}
|
||
|
||
private(set) var state: State = .unauthenticated
|
||
|
||
/// Returns a valid access token. Refreshes if expired.
|
||
func getAccessToken() async throws -> String {
|
||
// 1. Check cached token
|
||
if let token = keychain.read(accessTokenKey),
|
||
let expiry = UserDefaults.standard.object(forKey: tokenExpiryKey) as? Date,
|
||
expiry > Date().addingTimeInterval(60) { // 60s buffer
|
||
return token
|
||
}
|
||
|
||
// 2. Refresh
|
||
guard let refreshToken = keychain.read(refreshTokenKey) else {
|
||
state = .unauthenticated
|
||
throw TokenError.noRefreshToken
|
||
}
|
||
|
||
state = .refreshing
|
||
do {
|
||
let response = try await service.refreshAccessToken(refreshToken)
|
||
storeTokens(access: response.accessToken,
|
||
refresh: response.refreshToken ?? refreshToken,
|
||
expiresIn: response.expiresIn)
|
||
state = .authenticated
|
||
return response.accessToken
|
||
} catch {
|
||
state = .error(error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
func storeTokens(access: String, refresh: String, expiresIn: Int) {
|
||
keychain.write(access, forKey: accessTokenKey)
|
||
keychain.write(refresh, forKey: refreshTokenKey)
|
||
let expiry = Date().addingTimeInterval(TimeInterval(expiresIn))
|
||
UserDefaults.standard.set(expiry, forKey: tokenExpiryKey)
|
||
}
|
||
|
||
func clearTokens() {
|
||
keychain.delete(accessTokenKey)
|
||
keychain.delete(refreshTokenKey)
|
||
UserDefaults.standard.removeObject(forKey: tokenExpiryKey)
|
||
state = .unauthenticated
|
||
}
|
||
|
||
func handleCallback(url: URL) async throws {
|
||
guard let code = extractCode(from: url) else {
|
||
throw TokenError.invalidCallback
|
||
}
|
||
state = .authenticating
|
||
let response = try await service.exchangeCode(code)
|
||
storeTokens(access: response.accessToken,
|
||
refresh: response.refreshToken ?? "",
|
||
expiresIn: response.expiresIn)
|
||
state = .authenticated
|
||
}
|
||
}
|
||
|
||
enum TokenError: Error {
|
||
case noRefreshToken
|
||
case invalidCallback
|
||
case refreshFailed
|
||
}
|
||
```
|
||
|
||
### 3.3 KeychainHelper
|
||
|
||
```swift
|
||
// KeychainHelper.swift
|
||
|
||
final class KeychainHelper {
|
||
static let shared = KeychainHelper()
|
||
private let service = "com.xorbitlab.bugger"
|
||
|
||
func write(_ value: String, forKey key: String) {
|
||
let data = Data(value.utf8)
|
||
let query: [String: Any] = [
|
||
kSecClass as String: kSecClassGenericPassword,
|
||
kSecAttrService as String: service,
|
||
kSecAttrAccount as String: key,
|
||
kSecValueData as String: data
|
||
]
|
||
SecItemDelete(query as CFDictionary) // Remove existing
|
||
SecItemAdd(query as CFDictionary, nil)
|
||
}
|
||
|
||
func read(_ key: String) -> String? {
|
||
let query: [String: Any] = [
|
||
kSecClass as String: kSecClassGenericPassword,
|
||
kSecAttrService as String: service,
|
||
kSecAttrAccount as String: key,
|
||
kSecReturnData as String: true,
|
||
kSecMatchLimit as String: kSecMatchLimitOne
|
||
]
|
||
var result: AnyObject?
|
||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||
let data = result as? Data else { return nil }
|
||
return String(data: data, encoding: .utf8)
|
||
}
|
||
|
||
func delete(_ key: String) {
|
||
let query: [String: Any] = [
|
||
kSecClass as String: kSecClassGenericPassword,
|
||
kSecAttrService as String: service,
|
||
kSecAttrAccount as String: key
|
||
]
|
||
SecItemDelete(query as CFDictionary)
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Feishu API Integration
|
||
|
||
### 4.1 FeishuService — Bitable Record Fetching
|
||
|
||
```swift
|
||
// FeishuService.swift
|
||
|
||
final class FeishuService {
|
||
private let baseURL = "https://open.feishu.cn/open-apis"
|
||
private let session = URLSession.shared
|
||
private let decoder: JSONDecoder = {
|
||
let d = JSONDecoder()
|
||
d.keyDecodingStrategy = .convertFromSnakeCase
|
||
return d
|
||
}()
|
||
|
||
/// Fetch all bug records assigned to the current user.
|
||
/// Handles pagination internally.
|
||
func fetchBugs(appToken: String,
|
||
tableId: String,
|
||
assigneeName: String,
|
||
accessToken: String) async throws -> [Bug] {
|
||
|
||
var allRecords: [RecordItem] = []
|
||
var pageToken: String? = nil
|
||
|
||
repeat {
|
||
let response = try await fetchPage(
|
||
appToken: appToken,
|
||
tableId: tableId,
|
||
pageToken: pageToken,
|
||
accessToken: accessToken
|
||
)
|
||
allRecords.append(contentsOf: response.data?.items ?? [])
|
||
pageToken = response.data?.hasMore == true ? response.data?.pageToken : nil
|
||
} while pageToken != nil
|
||
|
||
return allRecords
|
||
.map { BugMapper.map($0, appToken: appToken, tableId: tableId) }
|
||
.filter { $0.assignee == assigneeName }
|
||
}
|
||
|
||
private func fetchPage(appToken: String,
|
||
tableId: String,
|
||
pageToken: String?,
|
||
accessToken: String) async throws -> RecordListResponse {
|
||
|
||
var components = URLComponents(string:
|
||
"\(baseURL)/bitable/v1/apps/\(appToken)/tables/\(tableId)/records")!
|
||
|
||
var queryItems: [URLQueryItem] = [
|
||
URLQueryItem(name: "page_size", value: "500"),
|
||
]
|
||
if let token = pageToken {
|
||
queryItems.append(URLQueryItem(name: "page_token", value: token))
|
||
}
|
||
components.queryItems = queryItems
|
||
|
||
var request = URLRequest(url: components.url!)
|
||
request.httpMethod = "GET"
|
||
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||
request.timeoutInterval = 30
|
||
|
||
let (data, response) = try await session.data(for: request)
|
||
|
||
guard let httpResponse = response as? HTTPURLResponse else {
|
||
throw FeishuError.networkError(nil)
|
||
}
|
||
|
||
if httpResponse.statusCode == 401 {
|
||
throw FeishuError.unauthorized
|
||
}
|
||
|
||
let apiResponse = try decoder.decode(RecordListResponse.self, from: data)
|
||
|
||
guard apiResponse.code == 0 else {
|
||
throw FeishuError.apiError(code: apiResponse.code, message: apiResponse.msg)
|
||
}
|
||
|
||
return apiResponse
|
||
}
|
||
}
|
||
|
||
// BugMapper (internal to FeishuService file or Models/)
|
||
enum BugMapper {
|
||
static func map(_ record: RecordItem, appToken: String, tableId: String) -> Bug {
|
||
Bug(
|
||
id: record.recordId,
|
||
title: record.fields.title ?? "Untitled",
|
||
priority: BugPriority(rawValue: record.fields.priority ?? "") ?? .unknown,
|
||
status: BugStatus(rawValue: record.fields.status ?? "") ?? .unknown,
|
||
assignee: record.fields.assignee?.first?.name ?? "Unknown",
|
||
reporter: record.fields.reporter?.first?.name,
|
||
createdAt: parseTimestamp(record.fields.createdAt) ?? Date.distantPast,
|
||
updatedAt: parseTimestamp(record.fields.updatedAt) ?? Date.distantPast,
|
||
feishuURL: URL(string: "https://xorbitlab.feishu.cn/base/\(appToken)/table/\(tableId)/record/\(record.recordId)")!
|
||
)
|
||
}
|
||
|
||
private static func parseTimestamp(_ ms: String?) -> Date? {
|
||
guard let ms = ms, let milliseconds = Double(ms) else { return nil }
|
||
return Date(timeIntervalSince1970: milliseconds / 1000.0)
|
||
}
|
||
}
|
||
```
|
||
|
||
### 4.2 FeishuAuthService
|
||
|
||
```swift
|
||
// FeishuAuthService.swift
|
||
|
||
final class FeishuAuthService {
|
||
private let baseURL = "https://open.feishu.cn/open-apis"
|
||
private let appId: String
|
||
private let appSecret: String
|
||
private let redirectURI = "bugger://oauth/callback"
|
||
|
||
init() {
|
||
// Read from Info.plist (injected at build time)
|
||
guard let id = Bundle.main.object(forInfoDictionaryKey: "FEISHU_APP_ID") as? String,
|
||
let secret = Bundle.main.object(forInfoDictionaryKey: "FEISHU_APP_SECRET") as? String else {
|
||
fatalError("Feishu app credentials missing from Info.plist")
|
||
}
|
||
self.appId = id
|
||
self.appSecret = secret
|
||
}
|
||
|
||
var authorizeURL: URL {
|
||
var components = URLComponents(string: "\(baseURL)/authen/v1/authorize")!
|
||
components.queryItems = [
|
||
URLQueryItem(name: "app_id", value: appId),
|
||
URLQueryItem(name: "redirect_uri", value: redirectURI),
|
||
URLQueryItem(name: "scope", value: "bitable:app:readonly"),
|
||
]
|
||
return components.url!
|
||
}
|
||
|
||
func exchangeCode(_ code: String) async throws -> OAuthTokenResponse {
|
||
let url = URL(string: "\(baseURL)/authen/v1/oidc/access_token")!
|
||
var request = URLRequest(url: url)
|
||
request.httpMethod = "POST"
|
||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||
|
||
let body: [String: String] = [
|
||
"grant_type": "authorization_code",
|
||
"code": code,
|
||
]
|
||
request.httpBody = try JSONEncoder().encode(body)
|
||
|
||
// App-level auth for token exchange
|
||
request.setValue("Bearer \(appAccessToken)", forHTTPHeaderField: "Authorization")
|
||
|
||
let (data, _) = try await URLSession.shared.data(for: request)
|
||
return try JSONDecoder().decode(OAuthTokenResponse.self, from: data)
|
||
}
|
||
|
||
func refreshAccessToken(_ refreshToken: String) async throws -> OAuthTokenResponse {
|
||
let url = URL(string: "\(baseURL)/authen/v1/refresh_access_token")!
|
||
var request = URLRequest(url: url)
|
||
request.httpMethod = "POST"
|
||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||
|
||
let body: [String: String] = [
|
||
"grant_type": "refresh_token",
|
||
"refresh_token": refreshToken,
|
||
]
|
||
request.httpBody = try JSONEncoder().encode(body)
|
||
|
||
// App-level auth for refresh
|
||
request.setValue("Bearer \(appAccessToken)", forHTTPHeaderField: "Authorization")
|
||
|
||
let (data, _) = try await URLSession.shared.data(for: request)
|
||
return try JSONDecoder().decode(OAuthTokenResponse.self, from: data)
|
||
}
|
||
|
||
/// App access token for OAuth endpoints (not user token)
|
||
/// Cached for 2 hours
|
||
private var appAccessToken: String {
|
||
get async throws {
|
||
// This is a tenant_access_token obtained with app_id + app_secret
|
||
// Cache it; it's valid for 2 hours
|
||
// Implementation: similar to user token but uses
|
||
// POST /authen/v1/tenant_access_token
|
||
// with { app_id, app_secret } body
|
||
// ...
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 4.3 Error Types
|
||
|
||
```swift
|
||
enum FeishuError: Error, LocalizedError {
|
||
case unauthorized
|
||
case networkError(Error?)
|
||
case apiError(code: Int, message: String)
|
||
case decodingError(Error)
|
||
case notConfigured // No app_token / table_id set
|
||
|
||
var errorDescription: String? {
|
||
switch self {
|
||
case .unauthorized:
|
||
return "Feishu authorization expired. Please re-authenticate."
|
||
case .networkError(let err):
|
||
return "Network error: \(err?.localizedDescription ?? "Unknown")"
|
||
case .apiError(let code, let message):
|
||
return "Feishu API error (\(code)): \(message)"
|
||
case .decodingError:
|
||
return "Failed to parse Feishu response. Field mappings may be incorrect."
|
||
case .notConfigured:
|
||
return "Feishu table not configured. Open Settings."
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 5. State Management
|
||
|
||
### 5.1 BugStore — Single Source of Truth
|
||
|
||
```swift
|
||
// BugStore.swift
|
||
|
||
@Observable
|
||
final class BugStore {
|
||
static let shared = BugStore()
|
||
|
||
// Published state
|
||
private(set) var bugs: [Bug] = []
|
||
private(set) var unseenBugs: Set<String> = [] // record IDs
|
||
private(set) var lastUpdated: Date?
|
||
private(set) var isLoading = false
|
||
private(set) var error: Error?
|
||
|
||
// Derived
|
||
var activeBugs: [Bug] {
|
||
bugs.filter { $0.status != .closed && $0.status != .resolved }
|
||
}
|
||
|
||
var unseenActiveCount: Int {
|
||
activeBugs.filter { unseenBugs.contains($0.id) }.count
|
||
}
|
||
|
||
var bugsSortedByPriority: [Bug] {
|
||
activeBugs.sorted { a, b in
|
||
if a.priority != b.priority { return a.priority < b.priority }
|
||
return a.createdAt < b.createdAt // Older first within same priority
|
||
}
|
||
}
|
||
|
||
/// Called by PollerService after each successful fetch
|
||
func update(with newBugs: [Bug]) {
|
||
let oldBugs = bugs
|
||
|
||
// Diff
|
||
let oldIds = Set(oldBugs.map(\.id))
|
||
let newIds = Set(newBugs.map(\.id))
|
||
let added = newIds.subtracting(oldIds)
|
||
|
||
// Mark newly-assigned bugs as unseen
|
||
unseenBugs.formUnion(added)
|
||
|
||
// Detect status changes for notifications
|
||
let changes = detectChanges(old: oldBugs, new: newBugs)
|
||
if !changes.isEmpty {
|
||
Task { await NotificationService.shared.handleChanges(changes) }
|
||
}
|
||
|
||
// Update state
|
||
bugs = newBugs
|
||
lastUpdated = Date()
|
||
error = nil
|
||
}
|
||
|
||
func markSeen(_ bugId: String) {
|
||
unseenBugs.remove(bugId)
|
||
}
|
||
|
||
func markAllSeen() {
|
||
unseenBugs.removeAll()
|
||
}
|
||
|
||
private func detectChanges(old: [Bug], new: [Bug]) -> [BugChange] {
|
||
let oldMap = Dictionary(uniqueKeysWithValues: old.map { ($0.id, $0) })
|
||
var changes: [BugChange] = []
|
||
|
||
for newBug in new {
|
||
guard let oldBug = oldMap[newBug.id] else {
|
||
changes.append(BugChange(type: .newBug, bug: newBug,
|
||
oldStatus: nil, oldAssignee: nil))
|
||
continue
|
||
}
|
||
if oldBug.status != newBug.status {
|
||
changes.append(BugChange(type: .statusChanged, bug: newBug,
|
||
oldStatus: oldBug.status, oldAssignee: nil))
|
||
}
|
||
if oldBug.priority != newBug.priority {
|
||
changes.append(BugChange(type: .priorityChanged, bug: newBug,
|
||
oldStatus: nil, oldAssignee: nil))
|
||
}
|
||
if oldBug.assignee != newBug.assignee {
|
||
changes.append(BugChange(type: .assigneeChanged, bug: newBug,
|
||
oldStatus: nil, oldAssignee: oldBug.assignee))
|
||
}
|
||
}
|
||
return changes
|
||
}
|
||
|
||
func setLoading(_ loading: Bool) { isLoading = loading }
|
||
func setError(_ error: Error) { self.error = error }
|
||
}
|
||
```
|
||
|
||
### 5.2 State Transitions
|
||
|
||
```
|
||
┌──────────────┐
|
||
launch ──▶ │ unconfigured │ ◀── (no table config)
|
||
└──────┬───────┘
|
||
│ config saved
|
||
┌──────▼───────┐
|
||
│unauthenticated│ ◀── (no token / refresh failed)
|
||
└──────┬───────┘
|
||
│ OAuth flow complete
|
||
┌──────▼───────┐
|
||
│ polling │ ◀──────┐
|
||
│ (fetching ...)│ │ timer fires
|
||
└──────┬───────┘ │
|
||
│ fetch │
|
||
┌───────┴────────┐ │
|
||
▼ ▼ │
|
||
┌──────────┐ ┌───────────┐ │
|
||
│ success │ │ error │───┘
|
||
│ (update │ │ (retry │
|
||
│ BugStore)│ │ next poll)│
|
||
└──────────┘ └─────┬─────┘
|
||
│ 401
|
||
▼
|
||
┌──────────────┐
|
||
│ re-auth needed│
|
||
└──────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 6. View Layer
|
||
|
||
### 6.1 BuggerApp — Entry Point
|
||
|
||
```swift
|
||
// BuggerApp.swift
|
||
|
||
import SwiftUI
|
||
|
||
@main
|
||
struct BuggerApp: App {
|
||
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||
@State private var bugStore = BugStore.shared
|
||
|
||
var body: some Scene {
|
||
// Note: We use MenuBarExtra for the menu bar icon.
|
||
// The popover is managed by AppDelegate via NSStatusItem + NSPopover
|
||
// for finer control over positioning and behavior.
|
||
|
||
Settings {
|
||
SettingsView()
|
||
}
|
||
.windowResizability(.contentSize)
|
||
}
|
||
}
|
||
```
|
||
|
||
### 6.2 AppDelegate — Menu Bar Setup
|
||
|
||
```swift
|
||
// AppDelegate.swift
|
||
|
||
import AppKit
|
||
import SwiftUI
|
||
|
||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||
private var statusItem: NSStatusItem!
|
||
private var popover: NSPopover!
|
||
private var floatingWidget: FloatingWidgetWindow?
|
||
private let bugStore = BugStore.shared
|
||
|
||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||
// Hide from dock — menu bar only
|
||
NSApp.setActivationPolicy(.accessory)
|
||
|
||
// Register custom URL scheme for OAuth callback
|
||
NSAppleEventManager.shared().setEventHandler(
|
||
self,
|
||
andSelector: #selector(handleURLEvent(_:withReplyEvent:)),
|
||
forEventClass: AEEventClass(kInternetEventClass),
|
||
andEventID: AEEventID(kAEGetURL)
|
||
)
|
||
|
||
setupStatusItem()
|
||
setupPopover()
|
||
|
||
// Start polling if configured
|
||
Task {
|
||
await PollerService.shared.startIfConfigured()
|
||
}
|
||
}
|
||
|
||
// MARK: - Status Item
|
||
|
||
private func setupStatusItem() {
|
||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||
|
||
if let button = statusItem.button {
|
||
// Template image: macOS auto-tints for dark/light mode
|
||
button.image = NSImage(
|
||
systemSymbolName: "ant.fill",
|
||
accessibilityDescription: "Bugger"
|
||
)
|
||
button.action = #selector(togglePopover)
|
||
button.target = self
|
||
}
|
||
}
|
||
|
||
// Badge is rendered as an NSView overlay or via attributed title
|
||
func updateBadge(count: Int) {
|
||
if count > 0 {
|
||
statusItem.button?.title = " \(count)"
|
||
} else {
|
||
statusItem.button?.title = ""
|
||
}
|
||
}
|
||
|
||
// MARK: - Popover
|
||
|
||
private func setupPopover() {
|
||
popover = NSPopover()
|
||
popover.contentSize = NSSize(width: 360, height: 500)
|
||
popover.behavior = .transient // Dismisses on click outside
|
||
popover.contentViewController = NSHostingController(
|
||
rootView: BugListPopover()
|
||
)
|
||
}
|
||
|
||
@objc private func togglePopover() {
|
||
guard let button = statusItem.button else { return }
|
||
|
||
if popover.isShown {
|
||
popover.performClose(nil)
|
||
} else {
|
||
popover.show(
|
||
relativeTo: button.bounds,
|
||
of: button,
|
||
preferredEdge: .minY
|
||
)
|
||
// Ensure popover becomes key so it can receive keyboard events
|
||
popover.contentViewController?.view.window?.makeKey()
|
||
}
|
||
}
|
||
|
||
// MARK: - Floating Widget
|
||
|
||
func showFloatingWidget() {
|
||
guard floatingWidget == nil else { return }
|
||
floatingWidget = FloatingWidgetWindow()
|
||
floatingWidget?.makeKeyAndOrderFront(nil)
|
||
}
|
||
|
||
func hideFloatingWidget() {
|
||
floatingWidget?.close()
|
||
floatingWidget = nil
|
||
}
|
||
|
||
// MARK: - URL Handler
|
||
|
||
@objc private func handleURLEvent(_ event: NSAppleEventDescriptor,
|
||
withReplyEvent: NSAppleEventDescriptor) {
|
||
guard let urlString = event.paramDescriptor(forKeyword: keyDirectObject)?
|
||
.stringValue,
|
||
let url = URL(string: urlString) else { return }
|
||
Task {
|
||
try? await TokenManager.shared.handleCallback(url: url)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 6.3 BugListPopover
|
||
|
||
```swift
|
||
// BugListPopover.swift
|
||
|
||
import SwiftUI
|
||
|
||
struct BugListPopover: View {
|
||
@State private var bugStore = BugStore.shared
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
// Header
|
||
HStack {
|
||
Text("My Bugs")
|
||
.font(.headline)
|
||
Spacer()
|
||
if bugStore.isLoading {
|
||
ProgressView()
|
||
.scaleEffect(0.7)
|
||
.frame(width: 16, height: 16)
|
||
}
|
||
Text(bugStore.lastUpdated.map(formatted) ?? "")
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
Button("Mark all seen") {
|
||
bugStore.markAllSeen()
|
||
}
|
||
.font(.caption)
|
||
}
|
||
.padding(.horizontal)
|
||
.padding(.vertical, 8)
|
||
|
||
Divider()
|
||
|
||
// List
|
||
if bugStore.bugsSortedByPriority.isEmpty && !bugStore.isLoading {
|
||
EmptyStateView()
|
||
} else if let error = bugStore.error {
|
||
ErrorStateView(error: error)
|
||
} else {
|
||
ScrollView {
|
||
LazyVStack(spacing: 0) {
|
||
ForEach(bugStore.bugsSortedByPriority) { bug in
|
||
BugRow(bug: bug,
|
||
isUnseen: bugStore.unseenBugs.contains(bug.id))
|
||
.onTapGesture {
|
||
openInFeishu(bug)
|
||
bugStore.markSeen(bug.id)
|
||
}
|
||
Divider().padding(.leading, 44)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Divider()
|
||
|
||
// Footer
|
||
HStack {
|
||
Button(action: openFeishuTable) {
|
||
Label("Open in Feishu", systemImage: "arrow.up.forward.app")
|
||
}
|
||
Spacer()
|
||
SettingsLink {
|
||
Label("Settings", systemImage: "gear")
|
||
}
|
||
}
|
||
.padding(.horizontal)
|
||
.padding(.vertical, 6)
|
||
.buttonStyle(.plain)
|
||
.font(.caption)
|
||
}
|
||
.frame(minWidth: 340, idealWidth: 360, maxWidth: 400)
|
||
}
|
||
|
||
private func openInFeishu(_ bug: Bug) {
|
||
NSWorkspace.shared.open(bug.feishuURL)
|
||
// Dismiss popover
|
||
NSApp.keyWindow?.close()
|
||
}
|
||
|
||
private func openFeishuTable() {
|
||
guard let config = AppStateService.shared.config,
|
||
let url = URL(string: "https://xorbitlab.feishu.cn/base/\(config.appToken)") else {
|
||
return
|
||
}
|
||
NSWorkspace.shared.open(url)
|
||
}
|
||
|
||
private func formatted(_ date: Date) -> String {
|
||
let formatter = RelativeDateTimeFormatter()
|
||
formatter.unitsStyle = .abbreviated
|
||
return formatter.localizedString(for: date, relativeTo: Date())
|
||
}
|
||
}
|
||
|
||
struct EmptyStateView: View {
|
||
var body: some View {
|
||
VStack(spacing: 8) {
|
||
Image(systemName: "checkmark.circle")
|
||
.font(.largeTitle)
|
||
.foregroundColor(.green)
|
||
Text("No active bugs")
|
||
.font(.headline)
|
||
Text("You're all clear! 🎉")
|
||
.font(.subheadline)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.padding()
|
||
}
|
||
}
|
||
|
||
struct ErrorStateView: View {
|
||
let error: Error
|
||
|
||
var body: some View {
|
||
VStack(spacing: 8) {
|
||
Image(systemName: "exclamationmark.triangle")
|
||
.font(.largeTitle)
|
||
.foregroundColor(.orange)
|
||
Text("Failed to load bugs")
|
||
.font(.headline)
|
||
Text(error.localizedDescription)
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.padding()
|
||
}
|
||
}
|
||
```
|
||
|
||
### 6.4 BugRow
|
||
|
||
```swift
|
||
// BugRow.swift
|
||
|
||
struct BugRow: View {
|
||
let bug: Bug
|
||
let isUnseen: Bool
|
||
|
||
var body: some View {
|
||
HStack(spacing: 10) {
|
||
// Priority indicator
|
||
PriorityBadge(priority: bug.priority)
|
||
|
||
// Content
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
HStack {
|
||
Text(bug.title)
|
||
.font(.system(size: 13))
|
||
.lineLimit(1)
|
||
if isUnseen {
|
||
Circle()
|
||
.fill(.blue)
|
||
.frame(width: 6, height: 6)
|
||
}
|
||
}
|
||
HStack(spacing: 8) {
|
||
StatusPill(status: bug.status)
|
||
Text(ageString(bug.age))
|
||
.font(.caption2)
|
||
.foregroundColor(.secondary)
|
||
if let reporter = bug.reporter {
|
||
Text("by \(reporter)")
|
||
.font(.caption2)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
Spacer()
|
||
|
||
// Open chevron
|
||
Image(systemName: "chevron.right")
|
||
.font(.caption2)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 8)
|
||
.background(isUnseen ? Color.blue.opacity(0.05) : Color.clear)
|
||
}
|
||
|
||
private func ageString(_ age: TimeInterval) -> String {
|
||
let days = Int(age / 86400)
|
||
let hours = Int(age / 3600)
|
||
if days > 0 { return "\(days)d" }
|
||
if hours > 0 { return "\(hours)h" }
|
||
return "just now"
|
||
}
|
||
}
|
||
|
||
struct PriorityBadge: View {
|
||
let priority: BugPriority
|
||
|
||
var body: some View {
|
||
Text(priority.rawValue)
|
||
.font(.caption)
|
||
.fontWeight(.bold)
|
||
.foregroundColor(.white)
|
||
.padding(.horizontal, 6)
|
||
.padding(.vertical, 2)
|
||
.background(priorityColor)
|
||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||
}
|
||
|
||
var priorityColor: Color {
|
||
switch priority {
|
||
case .p0: .red
|
||
case .p1: .orange
|
||
case .p2: .blue
|
||
case .p3: .gray
|
||
case .unknown: .gray.opacity(0.5)
|
||
}
|
||
}
|
||
}
|
||
|
||
struct StatusPill: View {
|
||
let status: BugStatus
|
||
|
||
var body: some View {
|
||
Text(status.rawValue)
|
||
.font(.caption2)
|
||
.padding(.horizontal, 5)
|
||
.padding(.vertical, 1)
|
||
.background(statusColor.opacity(0.15))
|
||
.foregroundColor(statusColor)
|
||
.clipShape(RoundedRectangle(cornerRadius: 3))
|
||
}
|
||
|
||
var statusColor: Color {
|
||
switch status {
|
||
case .open: .red
|
||
case .inProgress: .yellow
|
||
case .inReview: .purple
|
||
case .resolved: .green
|
||
case .closed: .gray
|
||
case .unknown: .gray.opacity(0.5)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 7. Notification Service
|
||
|
||
```swift
|
||
// NotificationService.swift
|
||
|
||
import UserNotifications
|
||
|
||
final class NotificationService: NSObject, UNUserNotificationCenterDelegate {
|
||
static let shared = NotificationService()
|
||
|
||
private let center = UNUserNotificationCenter.current()
|
||
private var isAuthorized = false
|
||
|
||
private override init() {
|
||
super.init()
|
||
center.delegate = self
|
||
}
|
||
|
||
func requestAuthorization() async throws {
|
||
isAuthorized = try await center.requestAuthorization(options: [.alert, .sound, .badge])
|
||
}
|
||
|
||
/// Called by BugStore after detecting changes
|
||
func handleChanges(_ changes: [BugChange]) async {
|
||
guard isAuthorized else { return }
|
||
|
||
// Rate-limit: max 3 notifications per poll cycle
|
||
let significant = changes
|
||
.filter { $0.type == .newBug || $0.type == .priorityChanged }
|
||
.prefix(3)
|
||
|
||
for change in significant {
|
||
deliver(change)
|
||
}
|
||
|
||
// Batch summary for status changes
|
||
let statusChanges = changes.filter { $0.type == .statusChanged }
|
||
if statusChanges.count > 1 {
|
||
deliverBatchStatusChange(statusChanges)
|
||
} else if let single = statusChanges.first {
|
||
deliver(single)
|
||
}
|
||
}
|
||
|
||
private func deliver(_ change: BugChange) {
|
||
let content = UNMutableNotificationContent()
|
||
switch change.type {
|
||
case .newBug:
|
||
content.title = "🔴 New Bug Assigned"
|
||
content.body = "[\(change.bug.priority.rawValue)] \(change.bug.title)"
|
||
content.sound = .default
|
||
case .statusChanged:
|
||
content.title = "📝 Bug Status Changed"
|
||
content.body = "\(change.bug.title) → \(change.bug.status.rawValue)"
|
||
case .priorityChanged:
|
||
content.title = "⚠️ Bug Priority Changed"
|
||
content.body = "\(change.bug.title) → \(change.bug.priority.rawValue)"
|
||
case .assigneeChanged:
|
||
return // Don't notify for this unless re-assigned to you
|
||
}
|
||
content.userInfo = ["bugId": change.bug.id, "feishuURL": change.bug.feishuURL.absoluteString]
|
||
|
||
let request = UNNotificationRequest(
|
||
identifier: "bugger-\(change.bug.id)-\(Date().timeIntervalSince1970)",
|
||
content: content,
|
||
trigger: nil // Deliver immediately
|
||
)
|
||
center.add(request)
|
||
}
|
||
|
||
private func deliverBatchStatusChange(_ changes: [BugChange]) {
|
||
let content = UNMutableNotificationContent()
|
||
content.title = "📝 \(changes.count) Bugs Updated"
|
||
content.body = changes.prefix(3).map { "• \($0.bug.title)" }.joined(separator: "\n")
|
||
content.sound = .default
|
||
|
||
let request = UNNotificationRequest(
|
||
identifier: "bugger-batch-\(Date().timeIntervalSince1970)",
|
||
content: content,
|
||
trigger: nil
|
||
)
|
||
center.add(request)
|
||
}
|
||
|
||
// Deliver notification even when app is in foreground
|
||
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
||
willPresent notification: UNNotification,
|
||
withCompletionHandler completionHandler:
|
||
@escaping (UNNotificationPresentationOptions) -> Void) {
|
||
completionHandler([.banner, .sound])
|
||
}
|
||
|
||
// Handle notification click
|
||
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
||
didReceive response: UNNotificationResponse,
|
||
withCompletionHandler completionHandler: @escaping () -> Void) {
|
||
if let urlString = response.notification.request.content.userInfo["feishuURL"] as? String,
|
||
let url = URL(string: urlString) {
|
||
NSWorkspace.shared.open(url)
|
||
}
|
||
completionHandler()
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 8. Poller Service
|
||
|
||
```swift
|
||
// PollerService.swift
|
||
|
||
import Foundation
|
||
|
||
@Observable
|
||
final class PollerService {
|
||
static let shared = PollerService()
|
||
|
||
private let bugStore = BugStore.shared
|
||
private let feishuService = FeishuService()
|
||
private let tokenManager = TokenManager.shared
|
||
private let configService = AppStateService.shared
|
||
|
||
private var timer: Timer?
|
||
private var isFetching = false
|
||
|
||
private(set) var isRunning = false
|
||
|
||
func startIfConfigured() async {
|
||
guard let config = configService.config,
|
||
!config.appToken.isEmpty,
|
||
!config.tableId.isEmpty,
|
||
tokenManager.state == .authenticated else {
|
||
return
|
||
}
|
||
start(interval: TimeInterval(config.pollIntervalSeconds))
|
||
}
|
||
|
||
func start(interval: TimeInterval) {
|
||
guard !isRunning else { return }
|
||
isRunning = true
|
||
|
||
// Fire immediately, then on interval
|
||
Task { await performFetch() }
|
||
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||
Task { await self?.performFetch() }
|
||
}
|
||
timer?.tolerance = interval * 0.1 // 10% tolerance for energy efficiency
|
||
}
|
||
|
||
func stop() {
|
||
timer?.invalidate()
|
||
timer = nil
|
||
isRunning = false
|
||
}
|
||
|
||
func restart(interval: TimeInterval) {
|
||
stop()
|
||
start(interval: interval)
|
||
}
|
||
|
||
/// Force an immediate fetch (e.g., user pulls to refresh)
|
||
func fetchNow() async {
|
||
await performFetch()
|
||
}
|
||
|
||
private func performFetch() async {
|
||
guard !isFetching else { return }
|
||
isFetching = true
|
||
defer { isFetching = false }
|
||
|
||
bugStore.setLoading(true)
|
||
bugStore.setError(nil) // clear previous error
|
||
|
||
do {
|
||
let config = try getConfig()
|
||
let token = try await tokenManager.getAccessToken()
|
||
let assignee = try await getCurrentUser(config: config, token: token)
|
||
|
||
let bugs = try await feishuService.fetchBugs(
|
||
appToken: config.appToken,
|
||
tableId: config.tableId,
|
||
assigneeName: assignee,
|
||
accessToken: token
|
||
)
|
||
await MainActor.run {
|
||
bugStore.update(with: bugs)
|
||
bugStore.setLoading(false)
|
||
// Update menu bar badge
|
||
if let appDelegate = NSApp.delegate as? AppDelegate {
|
||
appDelegate.updateBadge(count: bugStore.unseenActiveCount)
|
||
}
|
||
}
|
||
} catch FeishuError.unauthorized {
|
||
await MainActor.run {
|
||
bugStore.setLoading(false)
|
||
tokenManager.clearTokens()
|
||
}
|
||
} catch {
|
||
await MainActor.run {
|
||
bugStore.setLoading(false)
|
||
bugStore.setError(error)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func getConfig() throws -> AppConfig {
|
||
guard let config = configService.config,
|
||
!config.appToken.isEmpty,
|
||
!config.tableId.isEmpty else {
|
||
throw FeishuError.notConfigured
|
||
}
|
||
return config
|
||
}
|
||
|
||
/// Resolve the current user's name from Feishu or config
|
||
private func getCurrentUser(config: AppConfig, token: String) async throws -> String {
|
||
// Option A: Store assignee name in config ("always filter to this person")
|
||
// Option B: Call GET /authen/v1/user_info to get current user info
|
||
// For v1, we'll use a simple config field
|
||
return config.assigneeName ?? "Unknown"
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 9. Floating Widget
|
||
|
||
```swift
|
||
// FloatingWidgetWindow.swift
|
||
|
||
import AppKit
|
||
import SwiftUI
|
||
|
||
final class FloatingWidgetWindow: NSPanel {
|
||
init() {
|
||
super.init(
|
||
contentRect: NSRect(x: 0, y: 0, width: 80, height: 60),
|
||
styleMask: [.borderless, .nonactivatingPanel],
|
||
backing: .buffered,
|
||
defer: false
|
||
)
|
||
|
||
// Configuration for a floating widget
|
||
self.isFloatingPanel = true
|
||
self.level = .floating
|
||
self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||
self.isOpaque = false
|
||
self.backgroundColor = .clear
|
||
self.hasShadow = true
|
||
self.isMovableByWindowBackground = true
|
||
self.hidesOnDeactivate = false
|
||
self.animationBehavior = .none
|
||
|
||
// Position: bottom-right of screen
|
||
if let screen = NSScreen.main {
|
||
let screenFrame = screen.visibleFrame
|
||
let x = screenFrame.maxX - 100
|
||
let y = screenFrame.minY + 200
|
||
self.setFrameOrigin(NSPoint(x: x, y: y))
|
||
}
|
||
|
||
// Host SwiftUI content
|
||
self.contentView = NSHostingView(
|
||
rootView: FloatingWidgetView()
|
||
)
|
||
}
|
||
|
||
override var canBecomeKey: Bool { false }
|
||
override var canBecomeMain: Bool { false }
|
||
}
|
||
|
||
// FloatingWidgetView.swift
|
||
|
||
struct FloatingWidgetView: View {
|
||
@State private var bugStore = BugStore.shared
|
||
|
||
var body: some View {
|
||
Button(action: {
|
||
// Toggle the menu bar popover
|
||
if let appDelegate = NSApp.delegate as? AppDelegate {
|
||
appDelegate.togglePopover()
|
||
}
|
||
}) {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "ant.fill")
|
||
.font(.title3)
|
||
Text("\(bugStore.unseenActiveCount)")
|
||
.font(.title2)
|
||
.fontWeight(.bold)
|
||
.contentTransition(.numericText())
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 8)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 12)
|
||
.fill(.ultraThinMaterial)
|
||
.shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 4)
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 10. Settings & Persistence
|
||
|
||
### 10.1 AppStateService
|
||
|
||
```swift
|
||
// AppStateService.swift
|
||
|
||
import Foundation
|
||
|
||
final class AppStateService {
|
||
static let shared = AppStateService()
|
||
|
||
private let configKey = "bugger.config"
|
||
private let seenBugsKey = "bugger.seenBugs"
|
||
|
||
private(set) var config: AppConfig? {
|
||
didSet { persist() }
|
||
}
|
||
|
||
private(set) var persistedSeenBugs: Set<String> {
|
||
didSet { persist() }
|
||
}
|
||
|
||
private init() {
|
||
if let data = UserDefaults.standard.data(forKey: configKey),
|
||
let config = try? JSONDecoder().decode(AppConfig.self, from: data) {
|
||
self.config = config
|
||
} else {
|
||
self.config = nil
|
||
}
|
||
if let data = UserDefaults.standard.data(forKey: seenBugsKey),
|
||
let ids = try? JSONDecoder().decode(Set<String>.self, from: data) {
|
||
self.persistedSeenBugs = ids
|
||
} else {
|
||
self.persistedSeenBugs = []
|
||
}
|
||
}
|
||
|
||
func saveConfig(_ config: AppConfig) {
|
||
self.config = config
|
||
}
|
||
|
||
private func persist() {
|
||
if let config = config,
|
||
let data = try? JSONEncoder().encode(config) {
|
||
UserDefaults.standard.set(data, forKey: configKey)
|
||
}
|
||
if let data = try? JSONEncoder().encode(persistedSeenBugs) {
|
||
UserDefaults.standard.set(data, forKey: seenBugsKey)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 10.2 SettingsView
|
||
|
||
```swift
|
||
// SettingsView.swift
|
||
|
||
import SwiftUI
|
||
|
||
struct SettingsView: View {
|
||
@State private var config: AppConfig
|
||
@State private var isTestingConnection = false
|
||
@State private var connectionResult: String?
|
||
|
||
init() {
|
||
_config = State(initialValue: AppStateService.shared.config ?? AppConfig())
|
||
}
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section("Feishu Bitable") {
|
||
TextField("App Token (from Bitable URL)", text: $config.appToken)
|
||
.textFieldStyle(.roundedBorder)
|
||
|
||
TextField("Table ID", text: $config.tableId)
|
||
.textFieldStyle(.roundedBorder)
|
||
|
||
TextField("Your Name (as it appears in Assignee column)",
|
||
text: $config.assigneeName.toUnwrapped(defaultValue: ""))
|
||
.textFieldStyle(.roundedBorder)
|
||
|
||
HStack {
|
||
Button("Test Connection") {
|
||
testConnection()
|
||
}
|
||
.disabled(isTestingConnection)
|
||
|
||
if isTestingConnection {
|
||
ProgressView()
|
||
.scaleEffect(0.7)
|
||
}
|
||
if let result = connectionResult {
|
||
Text(result)
|
||
.font(.caption)
|
||
.foregroundColor(result.contains("✓") ? .green : .red)
|
||
}
|
||
}
|
||
}
|
||
|
||
Section("Polling") {
|
||
Picker("Check every", selection: $config.pollIntervalSeconds) {
|
||
Text("1 minute").tag(60)
|
||
Text("5 minutes").tag(300)
|
||
Text("10 minutes").tag(600)
|
||
Text("30 minutes").tag(1800)
|
||
}
|
||
}
|
||
|
||
Section("Display") {
|
||
Toggle("Show floating widget", isOn: $config.showFloatingWidget)
|
||
Toggle("Launch at login", isOn: $config.launchAtLogin)
|
||
}
|
||
|
||
Section("Field Mappings") {
|
||
TextField("Title field", text: $config.fieldMappings.titleField)
|
||
TextField("Priority field", text: $config.fieldMappings.priorityField)
|
||
TextField("Status field", text: $config.fieldMappings.statusField)
|
||
TextField("Assignee field", text: $config.fieldMappings.assigneeField)
|
||
}
|
||
|
||
HStack {
|
||
Button("Save") {
|
||
AppStateService.shared.saveConfig(config)
|
||
connectionResult = "Saved ✓"
|
||
Task {
|
||
await PollerService.shared.restart(
|
||
interval: TimeInterval(config.pollIntervalSeconds)
|
||
)
|
||
}
|
||
}
|
||
.keyboardShortcut(.return)
|
||
|
||
Button("Disconnect Feishu") {
|
||
TokenManager.shared.clearTokens()
|
||
}
|
||
.foregroundColor(.red)
|
||
}
|
||
}
|
||
.padding()
|
||
.frame(width: 400, height: 500)
|
||
}
|
||
|
||
private func testConnection() {
|
||
isTestingConnection = true
|
||
connectionResult = nil
|
||
Task {
|
||
do {
|
||
// Try to fetch 1 record as a connectivity test
|
||
let token = try await TokenManager.shared.getAccessToken()
|
||
// ... light API call
|
||
connectionResult = "Connected ✓"
|
||
} catch {
|
||
connectionResult = "Failed: \(error.localizedDescription)"
|
||
}
|
||
isTestingConnection = false
|
||
}
|
||
}
|
||
}
|
||
|
||
// Helper for optional binding in TextField
|
||
extension Binding where Value == String? {
|
||
func toUnwrapped(defaultValue: String) -> Binding<String> {
|
||
Binding<String>(
|
||
get: { self.wrappedValue ?? defaultValue },
|
||
set: { self.wrappedValue = $0 }
|
||
)
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 11. Error Handling
|
||
|
||
### Strategy
|
||
|
||
```
|
||
┌──────────────┐
|
||
│ Error Occurs │
|
||
└──────┬───────┘
|
||
│
|
||
┌────────┴────────┐
|
||
▼ ▼
|
||
Transient Permanent
|
||
(network, (401, bad config,
|
||
timeout) invalid token)
|
||
│ │
|
||
▼ ▼
|
||
Retry next Show error in UI
|
||
poll cycle + notification
|
||
(exponential if critical
|
||
backoff: 1m,
|
||
5m, 15m, cap)
|
||
```
|
||
|
||
### Error Recovery
|
||
|
||
| Error | UX | Recovery |
|
||
|---|---|---|
|
||
| No network | Grey out icon, show stale data | Auto-retry next poll |
|
||
| 401 Unauthorized | Notification + menu bar alert | Re-auth flow |
|
||
| Rate limited | Backoff next poll | Auto |
|
||
| Bad config | "Configure Bugger" prompt | Settings window |
|
||
| Parse error | Log + show stale data | Check field mappings |
|
||
| Token expired | Transparent refresh | Auto via refresh_token |
|
||
|
||
---
|
||
|
||
## 12. App Lifecycle
|
||
|
||
### 12.1 Info.plist Configuration
|
||
|
||
```xml
|
||
<!-- Key entries: -->
|
||
<key>LSUIElement</key>
|
||
<true/> <!-- No dock icon -->
|
||
|
||
<key>CFBundleURLTypes</key>
|
||
<array>
|
||
<dict>
|
||
<key>CFBundleURLSchemes</key>
|
||
<array>
|
||
<string>bugger</string>
|
||
</array>
|
||
<key>CFBundleURLName</key>
|
||
<string>com.xorbitlab.bugger</string>
|
||
</dict>
|
||
</array>
|
||
|
||
<key>FEISHU_APP_ID</key>
|
||
<string>cli_xxxxxxxxxxxx</string>
|
||
|
||
<key>FEISHU_APP_SECRET</key>
|
||
<string>xxxxxxxxxxxx</string>
|
||
```
|
||
|
||
### 12.2 Entitlements
|
||
|
||
```xml
|
||
<key>com.apple.security.app-sandbox</key>
|
||
<false/> <!-- Menu bar apps typically not sandboxed -->
|
||
|
||
<key>com.apple.security.network.client</key>
|
||
<true/>
|
||
|
||
<key>keychain-access-groups</key>
|
||
<array>
|
||
<string>$(AppIdentifierPrefix)com.xorbitlab.bugger</string>
|
||
</array>
|
||
```
|
||
|
||
### 12.3 Launch at Login
|
||
|
||
Use `SMAppService` (macOS 14+):
|
||
|
||
```swift
|
||
// In Settings or on first launch:
|
||
do {
|
||
try SMAppService.mainApp.register()
|
||
} catch {
|
||
print("Failed to register launch at login: \(error)")
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 13. Testing Strategy
|
||
|
||
### Unit Tests
|
||
|
||
| Test | Scope |
|
||
|---|---|
|
||
| `FeishuServiceTests` | Mock `URLProtocol`, verify request construction, response parsing, pagination |
|
||
| `TokenManagerTests` | Mock `KeychainHelper`, test token refresh logic, expiry detection |
|
||
| `BugStoreTests` | Feed known bug sets, verify diff detection, unseen tracking, sorting |
|
||
| `NotificationServiceTests` | Verify change → notification content mapping |
|
||
|
||
### Integration Tests
|
||
|
||
| Test | Scope |
|
||
|---|---|
|
||
| `FeishuAuthServiceTests` | Against Feishu dev tenant with test app |
|
||
| `BuggerUITests` | Launch app, verify menu bar icon appears, popover opens |
|
||
|
||
### Manual Test Checklist
|
||
|
||
- [ ] First launch: OAuth setup flow works
|
||
- [ ] Menu bar icon visible immediately after launch
|
||
- [ ] Badge count correct
|
||
- [ ] Popover lists bugs, sorted by priority
|
||
- [ ] Click bug → opens Feishu in browser
|
||
- [ ] "Mark all seen" clears badge
|
||
- [ ] Notification appears when new bug assigned
|
||
- [ ] Notification click opens Feishu to that bug
|
||
- [ ] Token refresh works (wait 2h or force-expire)
|
||
- [ ] Floating widget (if enabled) shows correct count
|
||
- [ ] Settings changes apply without restart
|
||
- [ ] Launch at login works
|
||
- [ ] Dark mode: icon and popover adapt
|
||
- [ ] Offline: graceful error display
|
||
|
||
---
|
||
|
||
## 14. Implementation Sequence
|
||
|
||
### Phase 0 — Scaffold (30 min)
|
||
- [ ] Create Xcode project with SwiftUI app target
|
||
- [ ] Configure `LSUIElement = YES`
|
||
- [ ] Add `MenuBarExtra` with placeholder icon
|
||
- [ ] Verify: app runs, no dock icon, icon in menu bar
|
||
- [ ] Set up project structure (folders, files)
|
||
|
||
### Phase 1 — OAuth & Token Flow (1-2 hours)
|
||
- [ ] Implement `KeychainHelper`
|
||
- [ ] Implement `FeishuAuthService` (authorize URL, exchange code, refresh)
|
||
- [ ] Implement `TokenManager`
|
||
- [ ] Set up custom URL scheme `bugger://`
|
||
- [ ] Build `OAuthSetupView` and `OAuthCallbackHandler`
|
||
- [ ] Manual test: full OAuth flow → token in Keychain
|
||
|
||
### Phase 2 — API Client & Data Layer (2-3 hours)
|
||
- [ ] Implement `FeishuModels` (API DTOs)
|
||
- [ ] Implement `Bug`, `BugPriority`, `BugStatus` models
|
||
- [ ] Implement `FeishuService.fetchBugs()`
|
||
- [ ] Implement `BugMapper`
|
||
- [ ] Implement `AppConfig` and `AppStateService`
|
||
- [ ] Unit tests for service and models
|
||
|
||
### Phase 3 — Polling + State (1-2 hours)
|
||
- [ ] Implement `PollerService`
|
||
- [ ] Implement `BugStore` with diff logic
|
||
- [ ] Wire up: PollerService → FeishuService → BugStore
|
||
- [ ] Unit tests for BugStore diff
|
||
|
||
### Phase 4 — Menu Bar UI (2-3 hours)
|
||
- [ ] Implement `AppDelegate` with `NSStatusItem` + `NSPopover`
|
||
- [ ] Implement `BugListPopover`
|
||
- [ ] Implement `BugRow` with `PriorityBadge` and `StatusPill`
|
||
- [ ] Implement `EmptyStateView` and `ErrorStateView`
|
||
- [ ] Wire badge count to `BugStore.unseenActiveCount`
|
||
|
||
### Phase 5 — Notifications (1 hour)
|
||
- [ ] Implement `NotificationService`
|
||
- [ ] Request notification permission on first new bug
|
||
- [ ] Wire into `BugStore.update()`
|
||
|
||
### Phase 6 — Settings (1 hour)
|
||
- [ ] Implement `SettingsView`
|
||
- [ ] Implement `Settings` scene
|
||
- [ ] Save/load `AppConfig`
|
||
- [ ] Test connection button
|
||
|
||
### Phase 7 — Floating Widget (1-2 hours)
|
||
- [ ] Implement `FloatingWidgetWindow` (NSPanel subclass)
|
||
- [ ] Implement `FloatingWidgetView`
|
||
- [ ] Wire to `AppConfig.showFloatingWidget` toggle
|
||
|
||
### Phase 8 — Polish (2-3 hours)
|
||
- [ ] Launch at login via `SMAppService`
|
||
- [ ] Loading states and error recovery
|
||
- [ ] Keyboard shortcut to toggle popover
|
||
- [ ] Dark/light mode asset variants
|
||
- [ ] Accessibility labels
|
||
- [ ] App icon
|
||
|
||
### Phase 9 — Testing & Hardening (2-3 hours)
|
||
- [ ] Complete unit test suite
|
||
- [ ] Manual test checklist pass
|
||
- [ ] Edge cases: network offline, 401 mid-session, pagination >500 records
|
||
- [ ] Memory profiling (Instruments)
|
||
- [ ] Notarization (if distributing outside App Store)
|
||
|
||
---
|
||
|
||
## Appendix A: Feishu Bitable Table Schema (Expected)
|
||
|
||
For reference, the Feishu table should have columns matching:
|
||
|
||
| Column Name | Type | Purpose |
|
||
|---|---|---|
|
||
| Title | Text | Bug title |
|
||
| Priority | Single Select | P0 / P1 / P2 / P3 |
|
||
| Status | Single Select | Open / In Progress / In Review / Resolved / Closed |
|
||
| Assignee | User | Who's fixing it |
|
||
| Reporter | User | Who filed it |
|
||
| Created At | Date | When it was filed |
|
||
| Updated At | Date | Last modified |
|
||
|
||
The app supports custom field names via `AppConfig.fieldMappings`.
|
||
|
||
## Appendix B: Menu Bar Icon Specification
|
||
|
||
- Template image (monochrome, macOS applies tint)
|
||
- PDF or SVG source, rendered at 18×18pt (36×36px @2x)
|
||
- Simple ant/bug silhouette
|
||
- Badge: text rendered via `statusItem.button?.title`, no custom drawing needed
|
||
|
||
## Appendix C: Build Configuration
|
||
|
||
```bash
|
||
# Debug build
|
||
xcodebuild -project Bugger.xcodeproj -scheme Bugger -configuration Debug
|
||
|
||
# Release build (for distribution)
|
||
xcodebuild -project Bugger.xcodeproj -scheme Bugger -configuration Release archive
|
||
|
||
# Notarize
|
||
xcrun notarytool submit Bugger.dmg \
|
||
--apple-id "your@email.com" \
|
||
--team-id "XXXXXXXXXX" \
|
||
--password "@keychain:AC_PASSWORD" \
|
||
--wait
|
||
```
|