commit a9c90f64b99a3eefb7c1d9a7cf40568d4a1ca195 Author: tigerenwork Date: Sun Jun 28 20:54:51 2026 +0800 first commit: project README, high-level design and detailed implementation docs Co-Authored-By: Claude diff --git a/README.md b/README.md new file mode 100644 index 0000000..e156db5 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# Bugger for macOS + +A lightweight macOS menu bar app that surfaces bugs assigned to you from a [Feishu Bitable](https://www.feishu.cn/). Shows a badge in the menu bar, a popover with your active bugs, and native notifications when bugs are assigned or changed. + +Inspired by the Windows Bugger tool. + +## Status + +Early design phase. See [docs/](docs/) for the high-level design and detailed implementation plan. + +## Docs + +- [High-Level Design](docs/HIGH_LEVEL_DESIGN.md) +- [Detailed Design & Implementation](docs/DETAILED_DESIGN.md) diff --git a/docs/DETAILED_DESIGN.md b/docs/DETAILED_DESIGN.md new file mode 100644 index 0000000..7706b27 --- /dev/null +++ b/docs/DETAILED_DESIGN.md @@ -0,0 +1,1917 @@ +# 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 = [] // 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 { + 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.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 { + Binding( + 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 + +LSUIElement + + +CFBundleURLTypes + + + CFBundleURLSchemes + + bugger + + CFBundleURLName + com.xorbitlab.bugger + + + +FEISHU_APP_ID +cli_xxxxxxxxxxxx + +FEISHU_APP_SECRET +xxxxxxxxxxxx +``` + +### 12.2 Entitlements + +```xml +com.apple.security.app-sandbox + + +com.apple.security.network.client + + +keychain-access-groups + + $(AppIdentifierPrefix)com.xorbitlab.bugger + +``` + +### 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 +``` diff --git a/docs/HIGH_LEVEL_DESIGN.md b/docs/HIGH_LEVEL_DESIGN.md new file mode 100644 index 0000000..4c9eef5 --- /dev/null +++ b/docs/HIGH_LEVEL_DESIGN.md @@ -0,0 +1,147 @@ +# Bugger for macOS — High-Level Design + +## Overview + +**Bugger** is a lightweight macOS menu bar application that surfaces bugs assigned to +you from a Feishu Bitable. It mirrors the experience of the Windows Bugger tool: +a glanceable badge in the menu bar, a popover with your active bugs, native +notifications when bugs are assigned or changed, and an optional floating widget. + +## Architecture + +``` +┌──────────────────────────────────────────────────────┐ +│ User's Mac │ +│ │ +│ ┌─────────────┐ ┌──────────────┐ │ +│ │ Menu Bar │ │ Floating │ │ +│ │ Icon+Badge │ │ Widget │ │ +│ │ [🐛 3] │ │ (optional) │ │ +│ └──────┬──────┘ └──────┬───────┘ │ +│ │ │ │ +│ ┌──────┴──────────────────┴───────┐ │ +│ │ BugStore │ │ +│ │ (ObservableObject) │ │ +│ │ - bugs: [Bug] │ │ +│ │ - unseenCount: Int │ │ +│ │ - lastUpdated: Date │ │ +│ └──────┬──────────────────────────┘ │ +│ │ │ +│ ┌──────┴──────────┐ ┌─────────────┐ │ +│ │ PollerService │───▶│ FeishuService│ │ +│ │ (Timer, 5min) │ │ │ │ +│ └─────────────────┘ └──────┬──────┘ │ +│ │ │ +│ ┌──────────────┐ ┌────────┴───────┐ │ +│ │ Notification │ │ TokenManager │ │ +│ │ Service │ │ (Keychain) │ │ +│ └──────────────┘ └────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────┘ + │ + │ HTTPS (OAuth Bearer) + ▼ +┌──────────────────────────────────────────────────────┐ +│ Feishu Cloud │ +│ ┌──────────────┐ ┌──────────────────┐ │ +│ │ OAuth │ │ Bitable API │ │ +│ │ /authen/v1/ │ │ /bitable/v1/ │ │ +│ │ oidc/ │ │ apps/{token}/ │ │ +│ │ access_token│ │ tables/{id}/ │ │ +│ └──────────────┘ │ records │ │ +│ └──────────────────┘ │ +└──────────────────────────────────────────────────────┘ +``` + +## Key Design Decisions + +### 1. Native SwiftUI (not cross-platform) +The app targets macOS exclusively. SwiftUI + AppKit bridging gives the best +integration with macOS idioms — `MenuBarExtra`, `NSPanel` floating windows, +Keychain, and `UserNotifications` — while keeping the binary under 10 MB. + +### 2. Poll-based sync, not push +Feishu webhooks require a public-facing HTTP endpoint. For a personal tool, +polling the Bitable API every N minutes is simpler, safer, and sufficient. +Default interval: 5 minutes. + +### 3. OAuth user_access_token with refresh +Uses Feishu's OAuth 2.0 authorization code flow to get a `user_access_token` +(valid 2h) and `refresh_token` (valid 30 days). Tokens are stored in the macOS +Keychain. The app acts on behalf of the user — no admin approval needed. + +### 4. No backend server +Everything runs locally. No database, no server, no cloud hosting. The app +stores only: +- OAuth tokens (Keychain) +- Configuration (UserDefaults: table ID, polling interval, etc.) +- Cached bug state (in-memory, rebuilt on launch) + +### 5. Menu bar first, floating widget second +The menu bar popover is the primary interaction surface. The floating widget is +an optional companion — a small always-on-top window showing the count, which +clicks through to open the menu bar popover. + +## Component Map + +| Component | Responsibility | +|---|---| +| `App` | `@main` entry, sets activation policy to `.accessory` (no dock icon) | +| `AppDelegate` | Menu bar icon registration, window management | +| `BugStore` | Central observable state: bug list, unseen count, loading/error | +| `FeishuService` | HTTP client for Feishu Open API (bitable records, OAuth) | +| `TokenManager` | OAuth flow orchestration, Keychain read/write, token refresh | +| `PollerService` | Configurable timer driving the fetch cycle | +| `NotificationService` | Diff engine + `UNUserNotificationCenter` push | +| `MenuBarView` | Menu bar icon with badge count | +| `BugListPopover` | Popover containing the scrollable bug list | +| `BugRow` | Single bug: title, priority pill, age, clickable | +| `FloatingWidget` | Optional `NSPanel`-based always-on-top overlay | +| `SettingsWindow` | Preferences: table config, poll interval, widget toggle | +| `KeychainHelper` | Thin wrapper around Security framework | + +## Data Flow + +``` +1. App Launch + ├── Load tokens from Keychain + ├── If no token → show OAuth setup window + ├── If token expired → refresh via refresh_token + └── Start PollerService + +2. Poll Cycle (every 5 min, configurable) + ├── FeishuService.fetchRecords(table_id, filter=assignee=me) + ├── Parse response → [Bug] + ├── BugStore.diff(old, new) + │ ├── New bugs → NotificationService.fire() + │ ├── Status changes → NotificationService.fire() + │ └── Assignee changes → NotificationService.fire() + └── BugStore.bugs = new; BugStore.lastUpdated = now + +3. User Interaction + ├── Click menu bar icon → toggle popover + ├── Click bug in popover → open in Feishu (browser / Feishu app URL) + ├── "Mark all seen" → reset badge count + └── Floating widget click → open menu bar popover +``` + +## Feishu API Surface + +| API | Method | Purpose | +|---|---|---| +| `/authen/v1/oidc/access_token` | POST | Exchange authorization code for user_access_token | +| `/authen/v1/refresh_access_token` | POST | Refresh expired user_access_token | +| `/bitable/v1/apps/{app_token}/tables/{table_id}/records` | GET | Fetch bug records with filter | + +All calls use `Authorization: Bearer ` and go to +`https://open.feishu.cn`. + +## Non-Functional Requirements + +- **Memory**: < 80 MB at steady state +- **CPU**: idle < 0.1%, spike < 2% during poll +- **Binary**: < 15 MB +- **Startup**: < 2 seconds to menu bar icon visible +- **Offline**: graceful degradation (show stale data, retry on next interval) +- **macOS**: 14.0+ (Sonoma) — `MenuBarExtra` is stable from 13.3, but 14.0 is a + reasonable floor for 2024+