60 KiB
60 KiB
Bugger for macOS — Detailed Design & Implementation Document
Table of Contents
- Project Structure
- Data Models
- OAuth & Token Management
- Feishu API Integration
- State Management
- View Layer
- Notification Service
- Poller Service
- Floating Widget
- Settings & Persistence
- Error Handling
- App Lifecycle
- Testing Strategy
- 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
MenuBarExtrawith 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
OAuthSetupViewandOAuthCallbackHandler - Manual test: full OAuth flow → token in Keychain
Phase 2 — API Client & Data Layer (2-3 hours)
- Implement
FeishuModels(API DTOs) - Implement
Bug,BugPriority,BugStatusmodels - Implement
FeishuService.fetchBugs() - Implement
BugMapper - Implement
AppConfigandAppStateService - Unit tests for service and models
Phase 3 — Polling + State (1-2 hours)
- Implement
PollerService - Implement
BugStorewith diff logic - Wire up: PollerService → FeishuService → BugStore
- Unit tests for BugStore diff
Phase 4 — Menu Bar UI (2-3 hours)
- Implement
AppDelegatewithNSStatusItem+NSPopover - Implement
BugListPopover - Implement
BugRowwithPriorityBadgeandStatusPill - Implement
EmptyStateViewandErrorStateView - 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
Settingsscene - Save/load
AppConfig - Test connection button
Phase 7 — Floating Widget (1-2 hours)
- Implement
FloatingWidgetWindow(NSPanel subclass) - Implement
FloatingWidgetView - Wire to
AppConfig.showFloatingWidgettoggle
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