# 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 ```