diff --git a/Sources/Models/AppConfig.swift b/Sources/Models/AppConfig.swift index 1ccd90f..0df6f29 100644 --- a/Sources/Models/AppConfig.swift +++ b/Sources/Models/AppConfig.swift @@ -12,6 +12,10 @@ struct AppConfig: Codable, Equatable { var showFloatingWidget: Bool = false var launchAtLogin: Bool = true var feishuBaseDomain: String = "xorbitlab.feishu.cn" + /// When false (the default), Bugger waits for the next scheduled poll + /// instead of fetching immediately on launch. Avoids a crash-on-start + /// loop if the table fetch is failing. + var refreshOnStart: Bool = false struct FieldMappings: Codable, Equatable { var titleField: String = "Title" @@ -49,4 +53,28 @@ struct AppConfig: Codable, Equatable { var isConfigured: Bool { !appToken.isEmpty && !tableId.isEmpty } + + // Explicit keys + tolerant decoding so a config persisted before a new + // field was added (missing key) doesn't fail to decode and wipe settings. + enum CodingKeys: String, CodingKey { + case appToken, tableId, assigneeName, fieldMappings + case pollIntervalSeconds, dailyRefreshTimes + case showFloatingWidget, launchAtLogin, feishuBaseDomain, refreshOnStart + } + + init() {} + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + appToken = try c.decodeIfPresent(String.self, forKey: .appToken) ?? "" + tableId = try c.decodeIfPresent(String.self, forKey: .tableId) ?? "" + assigneeName = try c.decodeIfPresent(String.self, forKey: .assigneeName) ?? "" + fieldMappings = try c.decodeIfPresent(FieldMappings.self, forKey: .fieldMappings) ?? FieldMappings() + pollIntervalSeconds = try c.decodeIfPresent(Int.self, forKey: .pollIntervalSeconds) ?? 300 + dailyRefreshTimes = try c.decodeIfPresent([String].self, forKey: .dailyRefreshTimes) ?? [] + showFloatingWidget = try c.decodeIfPresent(Bool.self, forKey: .showFloatingWidget) ?? false + launchAtLogin = try c.decodeIfPresent(Bool.self, forKey: .launchAtLogin) ?? true + feishuBaseDomain = try c.decodeIfPresent(String.self, forKey: .feishuBaseDomain) ?? "xorbitlab.feishu.cn" + refreshOnStart = try c.decodeIfPresent(Bool.self, forKey: .refreshOnStart) ?? false + } } diff --git a/Sources/Models/Bug.swift b/Sources/Models/Bug.swift index 11ac118..b6811f3 100644 --- a/Sources/Models/Bug.swift +++ b/Sources/Models/Bug.swift @@ -10,7 +10,7 @@ struct Bug: Identifiable, Equatable, Hashable { let customer: String? let createdAt: Date let updatedAt: Date - let feishuURL: URL + let feishuURL: URL? var age: TimeInterval { Date().timeIntervalSince(createdAt) diff --git a/Sources/Services/Feishu/FeishuError.swift b/Sources/Services/Feishu/FeishuError.swift index 0f10347..49f2da4 100644 --- a/Sources/Services/Feishu/FeishuError.swift +++ b/Sources/Services/Feishu/FeishuError.swift @@ -7,6 +7,8 @@ enum FeishuError: Error, LocalizedError { case decodingError(Error, rawBody: String? = nil) case notConfigured case missingCredentials + case invalidConfiguration(String) + case paginationLimitExceeded var errorDescription: String? { switch self { @@ -25,6 +27,10 @@ enum FeishuError: Error, LocalizedError { return "Feishu table not configured. Open Settings." case .missingCredentials: return "Feishu app credentials are missing. Set FEISHU_APP_ID and FEISHU_APP_SECRET." + case .invalidConfiguration(let detail): + return "Invalid Feishu configuration: \(detail)" + case .paginationLimitExceeded: + return "Feishu table has too many records to load in one pass." } } } diff --git a/Sources/Services/Feishu/FeishuService.swift b/Sources/Services/Feishu/FeishuService.swift index cf35e86..b2c3b43 100644 --- a/Sources/Services/Feishu/FeishuService.swift +++ b/Sources/Services/Feishu/FeishuService.swift @@ -19,8 +19,16 @@ final class FeishuService { ) async throws -> [Bug] { var allRecords: [RecordItem] = [] var pageToken: String? + var pages = 0 + // 25,000 records at 500/page — guard against a runaway cursor that + // would otherwise loop forever accumulating memory. + let maxPages = 50 repeat { + pages += 1 + guard pages <= maxPages else { + throw FeishuError.paginationLimitExceeded + } let page = try await fetchPage( appToken: config.appToken, tableId: config.tableId, @@ -60,16 +68,25 @@ final class FeishuService { pageSize: Int = 500, accessToken: String ) async throws -> RecordListData { - var components = URLComponents( + guard !appToken.isEmpty, !tableId.isEmpty else { + throw FeishuError.invalidConfiguration("App token and table ID must not be empty.") + } + guard var components = URLComponents( string: "\(baseURL)/bitable/v1/apps/\(appToken)/tables/\(tableId)/records" - )! + ) else { + throw FeishuError.invalidConfiguration("App token or table ID contains invalid characters.") + } var queryItems = [URLQueryItem(name: "page_size", value: String(pageSize))] if let pageToken { queryItems.append(URLQueryItem(name: "page_token", value: pageToken)) } components.queryItems = queryItems - var request = URLRequest(url: components.url!) + guard let url = components.url else { + throw FeishuError.invalidConfiguration("Could not build records request URL.") + } + + var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.timeoutInterval = 30 diff --git a/Sources/Services/NotificationService.swift b/Sources/Services/NotificationService.swift index dd3088f..0043369 100644 --- a/Sources/Services/NotificationService.swift +++ b/Sources/Services/NotificationService.swift @@ -56,10 +56,11 @@ final class NotificationService: NSObject, UNUserNotificationCenterDelegate { return } - content.userInfo = [ - "bugId": change.bug.id, - "feishuURL": change.bug.feishuURL.absoluteString - ] + var userInfo: [String: Any] = ["bugId": change.bug.id] + if let url = change.bug.feishuURL { + userInfo["feishuURL"] = url.absoluteString + } + content.userInfo = userInfo let request = UNNotificationRequest( identifier: "bugger-\(change.bug.id)-\(Date().timeIntervalSince1970)", diff --git a/Sources/Services/PollerService.swift b/Sources/Services/PollerService.swift index 3fb0a23..0710974 100644 --- a/Sources/Services/PollerService.swift +++ b/Sources/Services/PollerService.swift @@ -24,14 +24,16 @@ final class PollerService { tokenManager.isAuthenticated else { return } - start(interval: TimeInterval(config.pollIntervalSeconds)) + start(interval: TimeInterval(config.pollIntervalSeconds), fetchImmediately: config.refreshOnStart) } - func start(interval: TimeInterval) { + func start(interval: TimeInterval, fetchImmediately: Bool = true) { guard !isRunning else { return } isRunning = true - Task { await performFetch() } + if fetchImmediately { + Task { await performFetch() } + } if interval < 0 { // Daily schedule mode: check every 60s, fire at configured times diff --git a/Sources/Utils/URL+Feishu.swift b/Sources/Utils/URL+Feishu.swift index b0001b8..7b66677 100644 --- a/Sources/Utils/URL+Feishu.swift +++ b/Sources/Utils/URL+Feishu.swift @@ -6,12 +6,26 @@ enum FeishuURLBuilder { appToken: String, tableId: String, recordId: String - ) -> URL { - // Feishu Bitable record deep link: uses query parameters - URL(string: "https://\(baseDomain)/base/\(appToken)?table=\(tableId)&record=\(recordId)")! + ) -> URL? { + // Feishu Bitable record deep link: uses query parameters. + // Returns nil for malformed inputs (e.g. config containing spaces or + // slashes) instead of force-unwrapping and crashing. + var components = URLComponents() + components.scheme = "https" + components.host = baseDomain + components.path = "/base/\(appToken)" + components.queryItems = [ + URLQueryItem(name: "table", value: tableId), + URLQueryItem(name: "record", value: recordId) + ] + return components.url } - static func tableURL(baseDomain: String, appToken: String) -> URL { - URL(string: "https://\(baseDomain)/base/\(appToken)")! + static func tableURL(baseDomain: String, appToken: String) -> URL? { + var components = URLComponents() + components.scheme = "https" + components.host = baseDomain + components.path = "/base/\(appToken)" + return components.url } } diff --git a/Sources/Views/MenuBar/BugListPopover.swift b/Sources/Views/MenuBar/BugListPopover.swift index 967aa04..2e44ac4 100644 --- a/Sources/Views/MenuBar/BugListPopover.swift +++ b/Sources/Views/MenuBar/BugListPopover.swift @@ -104,16 +104,17 @@ struct BugListPopover: View { } private func openInFeishu(_ bug: Bug) { - NSWorkspace.shared.open(bug.feishuURL) + guard let url = bug.feishuURL else { return } + NSWorkspace.shared.open(url) AppDelegate.shared?.closePopover() } private func openFeishuTable() { - guard let config = AppStateService.shared.config else { return } - let url = FeishuURLBuilder.tableURL( - baseDomain: config.feishuBaseDomain, - appToken: config.appToken - ) + guard let config = AppStateService.shared.config, + let url = FeishuURLBuilder.tableURL( + baseDomain: config.feishuBaseDomain, + appToken: config.appToken + ) else { return } NSWorkspace.shared.open(url) } @@ -165,6 +166,11 @@ struct ErrorStateView: View { .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) + Button("Retry") { + Task { await PollerService.shared.fetchNow() } + } + .buttonStyle(.bordered) + .padding(.top, 4) } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() diff --git a/Sources/Views/Settings/SettingsView.swift b/Sources/Views/Settings/SettingsView.swift index 684d3fb..d64f75d 100644 --- a/Sources/Views/Settings/SettingsView.swift +++ b/Sources/Views/Settings/SettingsView.swift @@ -86,6 +86,13 @@ struct SettingsView: View { .buttonStyle(.plain) } } + + VStack(alignment: .leading, spacing: 4) { + Toggle("Refresh on launch", isOn: $config.refreshOnStart) + Text("Fetch bugs immediately when Bugger starts. Off = wait for the next scheduled poll.") + .font(.caption) + .foregroundStyle(.secondary) + } } Section("Display") {