fix: 增强配置容错与启动稳定性,优化错误状态交互
This commit is contained in:
parent
7047c51a07
commit
55d8b50f1d
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
if fetchImmediately {
|
||||
Task { await performFetch() }
|
||||
}
|
||||
|
||||
if interval < 0 {
|
||||
// Daily schedule mode: check every 60s, fire at configured times
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
Loading…
Reference in New Issue