bugger/docs/DETAILED_DESIGN.md

60 KiB
Raw Blame History

Bugger for macOS — Detailed Design & Implementation Document


Table of Contents

  1. Project Structure
  2. Data Models
  3. OAuth & Token Management
  4. Feishu API Integration
  5. State Management
  6. View Layer
  7. Notification Service
  8. Poller Service
  9. Floating Widget
  10. Settings & Persistence
  11. Error Handling
  12. App Lifecycle
  13. Testing Strategy
  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

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

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

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

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

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

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

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

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

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

// BugStore.swift

@Observable
final class BugStore {
    static let shared = BugStore()

    // Published state
    private(set) var bugs: [Bug] = []
    private(set) var unseenBugs: Set<String> = []  // record IDs
    private(set) var lastUpdated: Date?
    private(set) var isLoading = false
    private(set) var error: Error?

    // Derived
    var activeBugs: [Bug] {
        bugs.filter { $0.status != .closed && $0.status != .resolved }
    }

    var unseenActiveCount: Int {
        activeBugs.filter { unseenBugs.contains($0.id) }.count
    }

    var bugsSortedByPriority: [Bug] {
        activeBugs.sorted { a, b in
            if a.priority != b.priority { return a.priority < b.priority }
            return a.createdAt < b.createdAt  // Older first within same priority
        }
    }

    /// Called by PollerService after each successful fetch
    func update(with newBugs: [Bug]) {
        let oldBugs = bugs

        // Diff
        let oldIds = Set(oldBugs.map(\.id))
        let newIds = Set(newBugs.map(\.id))
        let added = newIds.subtracting(oldIds)

        // Mark newly-assigned bugs as unseen
        unseenBugs.formUnion(added)

        // Detect status changes for notifications
        let changes = detectChanges(old: oldBugs, new: newBugs)
        if !changes.isEmpty {
            Task { await NotificationService.shared.handleChanges(changes) }
        }

        // Update state
        bugs = newBugs
        lastUpdated = Date()
        error = nil
    }

    func markSeen(_ bugId: String) {
        unseenBugs.remove(bugId)
    }

    func markAllSeen() {
        unseenBugs.removeAll()
    }

    private func detectChanges(old: [Bug], new: [Bug]) -> [BugChange] {
        let oldMap = Dictionary(uniqueKeysWithValues: old.map { ($0.id, $0) })
        var changes: [BugChange] = []

        for newBug in new {
            guard let oldBug = oldMap[newBug.id] else {
                changes.append(BugChange(type: .newBug, bug: newBug,
                                         oldStatus: nil, oldAssignee: nil))
                continue
            }
            if oldBug.status != newBug.status {
                changes.append(BugChange(type: .statusChanged, bug: newBug,
                                         oldStatus: oldBug.status, oldAssignee: nil))
            }
            if oldBug.priority != newBug.priority {
                changes.append(BugChange(type: .priorityChanged, bug: newBug,
                                         oldStatus: nil, oldAssignee: nil))
            }
            if oldBug.assignee != newBug.assignee {
                changes.append(BugChange(type: .assigneeChanged, bug: newBug,
                                         oldStatus: nil, oldAssignee: oldBug.assignee))
            }
        }
        return changes
    }

    func setLoading(_ loading: Bool) { isLoading = loading }
    func setError(_ error: Error) { self.error = error }
}

5.2 State Transitions

                     ┌──────────────┐
          launch ──▶ │ unconfigured │ ◀── (no table config)
                     └──────┬───────┘
                            │ config saved
                     ┌──────▼───────┐
                     │unauthenticated│ ◀── (no token / refresh failed)
                     └──────┬───────┘
                            │ OAuth flow complete
                     ┌──────▼───────┐
                     │   polling     │ ◀──────┐
                     │ (fetching ...)│        │ timer fires
                     └──────┬───────┘        │
                            │ fetch           │
                    ┌───────┴────────┐       │
                    ▼                ▼       │
              ┌──────────┐   ┌───────────┐   │
              │ success   │   │  error     │───┘
              │ (update   │   │ (retry     │
              │  BugStore)│   │  next poll)│
              └──────────┘   └─────┬─────┘
                                   │ 401
                                   ▼
                            ┌──────────────┐
                            │ re-auth needed│
                            └──────────────┘

6. View Layer

6.1 BuggerApp — Entry Point

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

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

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

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

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

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

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

// AppStateService.swift

import Foundation

final class AppStateService {
    static let shared = AppStateService()

    private let configKey = "bugger.config"
    private let seenBugsKey = "bugger.seenBugs"

    private(set) var config: AppConfig? {
        didSet { persist() }
    }

    private(set) var persistedSeenBugs: Set<String> {
        didSet { persist() }
    }

    private init() {
        if let data = UserDefaults.standard.data(forKey: configKey),
           let config = try? JSONDecoder().decode(AppConfig.self, from: data) {
            self.config = config
        } else {
            self.config = nil
        }
        if let data = UserDefaults.standard.data(forKey: seenBugsKey),
           let ids = try? JSONDecoder().decode(Set<String>.self, from: data) {
            self.persistedSeenBugs = ids
        } else {
            self.persistedSeenBugs = []
        }
    }

    func saveConfig(_ config: AppConfig) {
        self.config = config
    }

    private func persist() {
        if let config = config,
           let data = try? JSONEncoder().encode(config) {
            UserDefaults.standard.set(data, forKey: configKey)
        }
        if let data = try? JSONEncoder().encode(persistedSeenBugs) {
            UserDefaults.standard.set(data, forKey: seenBugsKey)
        }
    }
}

10.2 SettingsView

// SettingsView.swift

import SwiftUI

struct SettingsView: View {
    @State private var config: AppConfig
    @State private var isTestingConnection = false
    @State private var connectionResult: String?

    init() {
        _config = State(initialValue: AppStateService.shared.config ?? AppConfig())
    }

    var body: some View {
        Form {
            Section("Feishu Bitable") {
                TextField("App Token (from Bitable URL)", text: $config.appToken)
                    .textFieldStyle(.roundedBorder)

                TextField("Table ID", text: $config.tableId)
                    .textFieldStyle(.roundedBorder)

                TextField("Your Name (as it appears in Assignee column)",
                          text: $config.assigneeName.toUnwrapped(defaultValue: ""))
                    .textFieldStyle(.roundedBorder)

                HStack {
                    Button("Test Connection") {
                        testConnection()
                    }
                    .disabled(isTestingConnection)

                    if isTestingConnection {
                        ProgressView()
                            .scaleEffect(0.7)
                    }
                    if let result = connectionResult {
                        Text(result)
                            .font(.caption)
                            .foregroundColor(result.contains("✓") ? .green : .red)
                    }
                }
            }

            Section("Polling") {
                Picker("Check every", selection: $config.pollIntervalSeconds) {
                    Text("1 minute").tag(60)
                    Text("5 minutes").tag(300)
                    Text("10 minutes").tag(600)
                    Text("30 minutes").tag(1800)
                }
            }

            Section("Display") {
                Toggle("Show floating widget", isOn: $config.showFloatingWidget)
                Toggle("Launch at login", isOn: $config.launchAtLogin)
            }

            Section("Field Mappings") {
                TextField("Title field", text: $config.fieldMappings.titleField)
                TextField("Priority field", text: $config.fieldMappings.priorityField)
                TextField("Status field", text: $config.fieldMappings.statusField)
                TextField("Assignee field", text: $config.fieldMappings.assigneeField)
            }

            HStack {
                Button("Save") {
                    AppStateService.shared.saveConfig(config)
                    connectionResult = "Saved ✓"
                    Task {
                        await PollerService.shared.restart(
                            interval: TimeInterval(config.pollIntervalSeconds)
                        )
                    }
                }
                .keyboardShortcut(.return)

                Button("Disconnect Feishu") {
                    TokenManager.shared.clearTokens()
                }
                .foregroundColor(.red)
            }
        }
        .padding()
        .frame(width: 400, height: 500)
    }

    private func testConnection() {
        isTestingConnection = true
        connectionResult = nil
        Task {
            do {
                // Try to fetch 1 record as a connectivity test
                let token = try await TokenManager.shared.getAccessToken()
                // ... light API call
                connectionResult = "Connected ✓"
            } catch {
                connectionResult = "Failed: \(error.localizedDescription)"
            }
            isTestingConnection = false
        }
    }
}

// Helper for optional binding in TextField
extension Binding where Value == String? {
    func toUnwrapped(defaultValue: String) -> Binding<String> {
        Binding<String>(
            get: { self.wrappedValue ?? defaultValue },
            set: { self.wrappedValue = $0 }
        )
    }
}

11. Error Handling

Strategy

          ┌──────────────┐
          │  Error Occurs │
          └──────┬───────┘
                 │
        ┌────────┴────────┐
        ▼                 ▼
   Transient           Permanent
   (network,           (401, bad config,
    timeout)            invalid token)
        │                 │
        ▼                 ▼
   Retry next        Show error in UI
   poll cycle        + notification
   (exponential      if critical
    backoff: 1m,
    5m, 15m, cap)

Error Recovery

Error UX Recovery
No network Grey out icon, show stale data Auto-retry next poll
401 Unauthorized Notification + menu bar alert Re-auth flow
Rate limited Backoff next poll Auto
Bad config "Configure Bugger" prompt Settings window
Parse error Log + show stale data Check field mappings
Token expired Transparent refresh Auto via refresh_token

12. App Lifecycle

12.1 Info.plist Configuration

<!-- Key entries: -->
<key>LSUIElement</key>
<true/>  <!-- No dock icon -->

<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>bugger</string>
        </array>
        <key>CFBundleURLName</key>
        <string>com.xorbitlab.bugger</string>
    </dict>
</array>

<key>FEISHU_APP_ID</key>
<string>cli_xxxxxxxxxxxx</string>

<key>FEISHU_APP_SECRET</key>
<string>xxxxxxxxxxxx</string>

12.2 Entitlements

<key>com.apple.security.app-sandbox</key>
<false/>  <!-- Menu bar apps typically not sandboxed -->

<key>com.apple.security.network.client</key>
<true/>

<key>keychain-access-groups</key>
<array>
    <string>$(AppIdentifierPrefix)com.xorbitlab.bugger</string>
</array>

12.3 Launch at Login

Use SMAppService (macOS 14+):

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

# 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