fix: 增强配置容错与启动稳定性,优化错误状态交互
This commit is contained in:
parent
7047c51a07
commit
55d8b50f1d
|
|
@ -12,6 +12,10 @@ struct AppConfig: Codable, Equatable {
|
||||||
var showFloatingWidget: Bool = false
|
var showFloatingWidget: Bool = false
|
||||||
var launchAtLogin: Bool = true
|
var launchAtLogin: Bool = true
|
||||||
var feishuBaseDomain: String = "xorbitlab.feishu.cn"
|
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 {
|
struct FieldMappings: Codable, Equatable {
|
||||||
var titleField: String = "Title"
|
var titleField: String = "Title"
|
||||||
|
|
@ -49,4 +53,28 @@ struct AppConfig: Codable, Equatable {
|
||||||
var isConfigured: Bool {
|
var isConfigured: Bool {
|
||||||
!appToken.isEmpty && !tableId.isEmpty
|
!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 customer: String?
|
||||||
let createdAt: Date
|
let createdAt: Date
|
||||||
let updatedAt: Date
|
let updatedAt: Date
|
||||||
let feishuURL: URL
|
let feishuURL: URL?
|
||||||
|
|
||||||
var age: TimeInterval {
|
var age: TimeInterval {
|
||||||
Date().timeIntervalSince(createdAt)
|
Date().timeIntervalSince(createdAt)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ enum FeishuError: Error, LocalizedError {
|
||||||
case decodingError(Error, rawBody: String? = nil)
|
case decodingError(Error, rawBody: String? = nil)
|
||||||
case notConfigured
|
case notConfigured
|
||||||
case missingCredentials
|
case missingCredentials
|
||||||
|
case invalidConfiguration(String)
|
||||||
|
case paginationLimitExceeded
|
||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
|
|
@ -25,6 +27,10 @@ enum FeishuError: Error, LocalizedError {
|
||||||
return "Feishu table not configured. Open Settings."
|
return "Feishu table not configured. Open Settings."
|
||||||
case .missingCredentials:
|
case .missingCredentials:
|
||||||
return "Feishu app credentials are missing. Set FEISHU_APP_ID and FEISHU_APP_SECRET."
|
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] {
|
) async throws -> [Bug] {
|
||||||
var allRecords: [RecordItem] = []
|
var allRecords: [RecordItem] = []
|
||||||
var pageToken: String?
|
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 {
|
repeat {
|
||||||
|
pages += 1
|
||||||
|
guard pages <= maxPages else {
|
||||||
|
throw FeishuError.paginationLimitExceeded
|
||||||
|
}
|
||||||
let page = try await fetchPage(
|
let page = try await fetchPage(
|
||||||
appToken: config.appToken,
|
appToken: config.appToken,
|
||||||
tableId: config.tableId,
|
tableId: config.tableId,
|
||||||
|
|
@ -60,16 +68,25 @@ final class FeishuService {
|
||||||
pageSize: Int = 500,
|
pageSize: Int = 500,
|
||||||
accessToken: String
|
accessToken: String
|
||||||
) async throws -> RecordListData {
|
) 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"
|
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))]
|
var queryItems = [URLQueryItem(name: "page_size", value: String(pageSize))]
|
||||||
if let pageToken {
|
if let pageToken {
|
||||||
queryItems.append(URLQueryItem(name: "page_token", value: pageToken))
|
queryItems.append(URLQueryItem(name: "page_token", value: pageToken))
|
||||||
}
|
}
|
||||||
components.queryItems = queryItems
|
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.httpMethod = "GET"
|
||||||
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||||||
request.timeoutInterval = 30
|
request.timeoutInterval = 30
|
||||||
|
|
|
||||||
|
|
@ -56,10 +56,11 @@ final class NotificationService: NSObject, UNUserNotificationCenterDelegate {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
content.userInfo = [
|
var userInfo: [String: Any] = ["bugId": change.bug.id]
|
||||||
"bugId": change.bug.id,
|
if let url = change.bug.feishuURL {
|
||||||
"feishuURL": change.bug.feishuURL.absoluteString
|
userInfo["feishuURL"] = url.absoluteString
|
||||||
]
|
}
|
||||||
|
content.userInfo = userInfo
|
||||||
|
|
||||||
let request = UNNotificationRequest(
|
let request = UNNotificationRequest(
|
||||||
identifier: "bugger-\(change.bug.id)-\(Date().timeIntervalSince1970)",
|
identifier: "bugger-\(change.bug.id)-\(Date().timeIntervalSince1970)",
|
||||||
|
|
|
||||||
|
|
@ -24,14 +24,16 @@ final class PollerService {
|
||||||
tokenManager.isAuthenticated else {
|
tokenManager.isAuthenticated else {
|
||||||
return
|
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 }
|
guard !isRunning else { return }
|
||||||
isRunning = true
|
isRunning = true
|
||||||
|
|
||||||
Task { await performFetch() }
|
if fetchImmediately {
|
||||||
|
Task { await performFetch() }
|
||||||
|
}
|
||||||
|
|
||||||
if interval < 0 {
|
if interval < 0 {
|
||||||
// Daily schedule mode: check every 60s, fire at configured times
|
// Daily schedule mode: check every 60s, fire at configured times
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,26 @@ enum FeishuURLBuilder {
|
||||||
appToken: String,
|
appToken: String,
|
||||||
tableId: String,
|
tableId: String,
|
||||||
recordId: String
|
recordId: String
|
||||||
) -> URL {
|
) -> URL? {
|
||||||
// Feishu Bitable record deep link: uses query parameters
|
// Feishu Bitable record deep link: uses query parameters.
|
||||||
URL(string: "https://\(baseDomain)/base/\(appToken)?table=\(tableId)&record=\(recordId)")!
|
// 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 {
|
static func tableURL(baseDomain: String, appToken: String) -> URL? {
|
||||||
URL(string: "https://\(baseDomain)/base/\(appToken)")!
|
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) {
|
private func openInFeishu(_ bug: Bug) {
|
||||||
NSWorkspace.shared.open(bug.feishuURL)
|
guard let url = bug.feishuURL else { return }
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
AppDelegate.shared?.closePopover()
|
AppDelegate.shared?.closePopover()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openFeishuTable() {
|
private func openFeishuTable() {
|
||||||
guard let config = AppStateService.shared.config else { return }
|
guard let config = AppStateService.shared.config,
|
||||||
let url = FeishuURLBuilder.tableURL(
|
let url = FeishuURLBuilder.tableURL(
|
||||||
baseDomain: config.feishuBaseDomain,
|
baseDomain: config.feishuBaseDomain,
|
||||||
appToken: config.appToken
|
appToken: config.appToken
|
||||||
)
|
) else { return }
|
||||||
NSWorkspace.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,6 +166,11 @@ struct ErrorStateView: View {
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
Button("Retry") {
|
||||||
|
Task { await PollerService.shared.fetchNow() }
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.padding()
|
.padding()
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,13 @@ struct SettingsView: View {
|
||||||
.buttonStyle(.plain)
|
.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") {
|
Section("Display") {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue