bugger/docs/DETAILED_DESIGN.md

1918 lines
60 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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