feat(bitable): 支持通过 SSE 订阅 Bitable 变更并实时刷新
- 新增 BitableEventService,通过 SSE 监听飞书变更推送,收到 change 事件即触发 fetchNow - AppConfig 新增 feishuAppBaseURL 字段,置空时禁用实时推送、回退轮询 - 设置页新增 "Real-time updates" 区块,支持配置订阅地址并随保存重连 - AppDelegate 启动时自动连接 SSE;断开飞书时同步断开 - 流断开后 30s 自动重连,显式 disconnect 不重连 - Info.plist 放开 ATS 以支持 HTTP 订阅服务(localhost/局域网)
This commit is contained in:
parent
4d67d8bffd
commit
c275c71b55
|
|
@ -31,6 +31,7 @@
|
|||
CAE624453A3F0375D872170A /* FeishuModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9FBC39502FA635EF1A6A5C /* FeishuModels.swift */; };
|
||||
CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A83E76FBB83849E4B6704D3 /* FeishuError.swift */; };
|
||||
D2855F82E68FADA8BEA43086 /* AppStateService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */; };
|
||||
7E1F2A3B4C5D6E7F8091A2B3 /* BitableEventService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F2E3D4C5B6A7988061A2B3C /* BitableEventService.swift */; };
|
||||
DDB9EB345FEBB95DD9976364 /* BugChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B24E1E1D32F7B774D12DD85 /* BugChange.swift */; };
|
||||
E4CBAE27491AF1C52944FA91 /* BugStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646C676356A4125B8E8D5FA5 /* BugStatus.swift */; };
|
||||
EEC5853D00ED565270DC867F /* LocalOAuthServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D11523ACB81DBB9D412995 /* LocalOAuthServer.swift */; };
|
||||
|
|
@ -67,6 +68,7 @@
|
|||
EF0182DF85A7CD0319333F24 /* Bugger.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Bugger.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EF06E1030AA21DA8190169A8 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; };
|
||||
F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateService.swift; sourceTree = "<group>"; };
|
||||
8F2E3D4C5B6A7988061A2B3C /* BitableEventService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitableEventService.swift; sourceTree = "<group>"; };
|
||||
F888AA5F655343B19839B4C2 /* URL+Feishu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Feishu.swift"; sourceTree = "<group>"; };
|
||||
FE9FBC39502FA635EF1A6A5C /* FeishuModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuModels.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
|
@ -171,6 +173,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */,
|
||||
8F2E3D4C5B6A7988061A2B3C /* BitableEventService.swift */,
|
||||
59CC175DE8E9FE3250757D33 /* NotificationService.swift */,
|
||||
4F4628E7B599B69B499E09E9 /* PollerService.swift */,
|
||||
0E7E3775F77DD36C27160A8C /* TokenManager.swift */,
|
||||
|
|
@ -300,6 +303,7 @@
|
|||
A33561284CA44A0045902BCC /* BugPriority.swift in Sources */,
|
||||
E4CBAE27491AF1C52944FA91 /* BugStatus.swift in Sources */,
|
||||
D2855F82E68FADA8BEA43086 /* AppStateService.swift in Sources */,
|
||||
7E1F2A3B4C5D6E7F8091A2B3 /* BitableEventService.swift in Sources */,
|
||||
4309E8C70AE99466545A3E37 /* FeishuAuthService.swift in Sources */,
|
||||
CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */,
|
||||
CAE624453A3F0375D872170A /* FeishuModels.swift in Sources */,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@
|
|||
<string>14.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>FEISHU_APP_ID</key>
|
||||
<string>$(FEISHU_APP_ID)</string>
|
||||
<key>FEISHU_APP_SECRET</key>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
|
||||
Task {
|
||||
await PollerService.shared.startIfConfigured()
|
||||
// Real-time change push is non-blocking; no-op if the subscribe
|
||||
// base URL isn't configured (falls back to polling).
|
||||
BitableEventService.shared.connect()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ struct AppConfig: Codable, Equatable {
|
|||
/// instead of fetching immediately on launch. Avoids a crash-on-start
|
||||
/// loop if the table fetch is failing.
|
||||
var refreshOnStart: Bool = false
|
||||
/// Base URL of the feishu-app change-notification service, e.g.
|
||||
/// "http://localhost:8000". When set, Bugger opens an SSE stream to
|
||||
/// `{feishuAppBaseURL}/api/v1/bitable/events` and refreshes immediately
|
||||
/// on a change push. Empty = disabled (polling only).
|
||||
var feishuAppBaseURL: String = ""
|
||||
|
||||
struct FieldMappings: Codable, Equatable {
|
||||
var titleField: String = "Title"
|
||||
|
|
@ -60,6 +65,7 @@ struct AppConfig: Codable, Equatable {
|
|||
case appToken, tableId, assigneeName, fieldMappings
|
||||
case pollIntervalSeconds, dailyRefreshTimes
|
||||
case showFloatingWidget, launchAtLogin, feishuBaseDomain, refreshOnStart
|
||||
case feishuAppBaseURL
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
|
@ -76,6 +82,7 @@ struct AppConfig: Codable, Equatable {
|
|||
launchAtLogin = try c.decodeIfPresent(Bool.self, forKey: .launchAtLogin) ?? true
|
||||
feishuBaseDomain = try c.decodeIfPresent(String.self, forKey: .feishuBaseDomain) ?? BundledDefault.feishuBaseDomain
|
||||
refreshOnStart = try c.decodeIfPresent(Bool.self, forKey: .refreshOnStart) ?? false
|
||||
feishuAppBaseURL = try c.decodeIfPresent(String.self, forKey: .feishuAppBaseURL) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,227 @@
|
|||
import Foundation
|
||||
|
||||
/// Connects to the feishu-app change-notification service via Server-Sent
|
||||
/// Events (SSE). When the assignee of a tracked Bitable record changes, the
|
||||
/// server pushes a `change` event down this stream; Bugger responds by
|
||||
/// triggering `PollerService.fetchNow()` so the bug list refreshes instantly.
|
||||
///
|
||||
/// The stream URL is built from `AppConfig.feishuAppBaseURL`:
|
||||
/// `{feishuAppBaseURL}/api/v1/bitable/events?file_token=...&assignee_field=...&assignee_name=...`
|
||||
///
|
||||
/// When `feishuAppBaseURL` is empty the service stays disabled and no
|
||||
/// network requests are made — polling remains the only refresh mechanism.
|
||||
final class BitableEventService {
|
||||
static let shared = BitableEventService()
|
||||
|
||||
private var session: URLSession?
|
||||
private var task: URLSessionDataTask?
|
||||
private var delegate: SSESessionDelegate?
|
||||
private var reconnectWorkItem: DispatchWorkItem?
|
||||
|
||||
/// The user's intent to be connected. Distinguishes an unexpected stream
|
||||
/// drop (should auto-reconnect) from an explicit `disconnect()` (should not).
|
||||
private var isEnabled = false
|
||||
|
||||
private let reconnectDelay: TimeInterval = 30
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
/// Open the SSE connection. Safe to call repeatedly — a no-op if already
|
||||
/// enabled, and a no-op if `feishuAppBaseURL` is not configured.
|
||||
func connect() {
|
||||
guard !isEnabled else { return }
|
||||
guard let url = buildURL() else {
|
||||
BuggerLog.info("BitableEventService: subscribe base URL not configured, skipping SSE")
|
||||
return
|
||||
}
|
||||
isEnabled = true
|
||||
openStream(at: url)
|
||||
}
|
||||
|
||||
/// Close the SSE connection. No auto-reconnect will be attempted.
|
||||
func disconnect() {
|
||||
isEnabled = false
|
||||
closeStream()
|
||||
}
|
||||
|
||||
/// Apply the current config: tear down any existing stream, then connect
|
||||
/// if the subscribe base URL is set. Call after the user changes the URL.
|
||||
func reconnect() {
|
||||
disconnect()
|
||||
connect()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func openStream(at url: URL) {
|
||||
closeStream()
|
||||
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
// The server sends a heartbeat every ~30s, so 5 min of silence is a
|
||||
// safe "something went wrong" threshold.
|
||||
config.timeoutIntervalForRequest = 300
|
||||
config.timeoutIntervalForResource = .infinity
|
||||
config.waitsForConnectivity = true
|
||||
|
||||
let delegate = SSESessionDelegate(
|
||||
onEvent: { [weak self] event, data in
|
||||
self?.handleEvent(event, data: data)
|
||||
},
|
||||
onCompletion: { [weak self] error in
|
||||
self?.handleStreamEnd(error: error)
|
||||
}
|
||||
)
|
||||
self.delegate = delegate
|
||||
|
||||
let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
|
||||
self.session = session
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
|
||||
request.timeoutInterval = 300
|
||||
|
||||
let task = session.dataTask(with: request)
|
||||
self.task = task
|
||||
task.resume()
|
||||
|
||||
BuggerLog.info("BitableEventService: connecting to \(url.absoluteString)")
|
||||
}
|
||||
|
||||
private func closeStream() {
|
||||
reconnectWorkItem?.cancel()
|
||||
reconnectWorkItem = nil
|
||||
task?.cancel()
|
||||
task = nil
|
||||
session?.invalidateAndCancel()
|
||||
session = nil
|
||||
delegate = nil
|
||||
}
|
||||
|
||||
private func handleStreamEnd(error: Error?) {
|
||||
task = nil
|
||||
session = nil
|
||||
delegate = nil
|
||||
|
||||
if let error {
|
||||
BuggerLog.error("BitableEventService: stream ended (\(error.localizedDescription))")
|
||||
} else {
|
||||
BuggerLog.info("BitableEventService: stream ended")
|
||||
}
|
||||
|
||||
// Only auto-reconnect if the user still wants to be connected.
|
||||
guard isEnabled else { return }
|
||||
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
guard let self, self.isEnabled else { return }
|
||||
guard let url = self.buildURL() else { return }
|
||||
BuggerLog.info("BitableEventService: reconnecting...")
|
||||
self.openStream(at: url)
|
||||
}
|
||||
reconnectWorkItem = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + reconnectDelay, execute: work)
|
||||
}
|
||||
|
||||
private func handleEvent(_ event: String, data: String) {
|
||||
switch event {
|
||||
case "change":
|
||||
BuggerLog.info("BitableEventService: change push received (\(data)), refreshing now")
|
||||
Task { await PollerService.shared.fetchNow() }
|
||||
case "error":
|
||||
BuggerLog.error("BitableEventService: server error \(data)")
|
||||
case "connected", "heartbeat":
|
||||
break
|
||||
default:
|
||||
BuggerLog.debug("BitableEventService: unknown event \(event)")
|
||||
}
|
||||
}
|
||||
|
||||
private func buildURL() -> URL? {
|
||||
guard let config = AppStateService.shared.config,
|
||||
config.isConfigured,
|
||||
!config.feishuAppBaseURL.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let assigneeName = config.assigneeName
|
||||
let assigneeField = config.fieldMappings.assigneeField
|
||||
|
||||
guard !assigneeName.isEmpty, !assigneeField.isEmpty else {
|
||||
BuggerLog.info("BitableEventService: assignee name or field not configured")
|
||||
return nil
|
||||
}
|
||||
|
||||
let base = config.feishuAppBaseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard var comps = URLComponents(string: "\(base)/api/v1/bitable/events") else {
|
||||
return nil
|
||||
}
|
||||
comps.queryItems = [
|
||||
URLQueryItem(name: "file_token", value: config.appToken),
|
||||
URLQueryItem(name: "assignee_field", value: assigneeField),
|
||||
URLQueryItem(name: "assignee_name", value: assigneeName),
|
||||
]
|
||||
return comps.url
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SSE parsing
|
||||
|
||||
private final class SSESessionDelegate: NSObject, URLSessionDataDelegate {
|
||||
private let onEvent: (String, String) -> Void
|
||||
private let onCompletion: (Error?) -> Void
|
||||
private var buffer = ""
|
||||
|
||||
init(onEvent: @escaping (String, String) -> Void, onCompletion: @escaping (Error?) -> Void) {
|
||||
self.onEvent = onEvent
|
||||
self.onCompletion = onCompletion
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
||||
guard let chunk = String(data: data, encoding: .utf8) else { return }
|
||||
// Normalize line endings: SSE allows \n, \r, or \r\n.
|
||||
buffer.append(chunk.replacingOccurrences(of: "\r\n", with: "\n")
|
||||
.replacingOccurrences(of: "\r", with: "\n"))
|
||||
|
||||
// SSE frames are delimited by a blank line (\n\n).
|
||||
while let range = buffer.range(of: "\n\n") {
|
||||
let frame = String(buffer[..<range.lowerBound])
|
||||
buffer.removeSubrange(..<range.upperBound)
|
||||
processFrame(frame)
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
// Flush any trailing frame that wasn't followed by a blank line.
|
||||
if !buffer.isEmpty {
|
||||
processFrame(buffer)
|
||||
buffer = ""
|
||||
}
|
||||
// Route back to the main queue so BitableEventService state is only
|
||||
// ever touched from one thread.
|
||||
DispatchQueue.main.async { [self] in
|
||||
self.onCompletion(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func processFrame(_ frame: String) {
|
||||
var eventType = ""
|
||||
var data = ""
|
||||
|
||||
for line in frame.split(separator: "\n", omittingEmptySubsequences: false) {
|
||||
if line.hasPrefix("event:") {
|
||||
eventType = String(line.dropFirst("event:".count)).trimmingCharacters(in: .whitespaces)
|
||||
} else if line.hasPrefix("data:") {
|
||||
data = String(line.dropFirst("data:".count)).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
// Lines starting with ":" are comments (heartbeats) — ignored.
|
||||
}
|
||||
|
||||
// Frames without an event type (e.g. heartbeat-only frames) are skipped.
|
||||
guard !eventType.isEmpty else { return }
|
||||
|
||||
DispatchQueue.main.async { [self] in
|
||||
self.onEvent(eventType, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -113,6 +113,18 @@ struct SettingsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
Section("Real-time updates") {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
TextField("Subscribe base URL", text: $config.feishuAppBaseURL)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.autocorrectionDisabled()
|
||||
Text("Base URL of the change-notification service, e.g. http://localhost:8000. When set, Bugger listens for change pushes and refreshes immediately — auto-refresh on change is enabled.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Display") {
|
||||
Toggle("Show floating widget", isOn: $config.showFloatingWidget)
|
||||
.onChange(of: config.showFloatingWidget) { _, enabled in
|
||||
|
|
@ -166,6 +178,7 @@ struct SettingsView: View {
|
|||
Button("Disconnect Feishu") {
|
||||
TokenManager.shared.clearTokens()
|
||||
PollerService.shared.stop()
|
||||
BitableEventService.shared.disconnect()
|
||||
}
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
|
@ -274,6 +287,10 @@ struct SettingsView: View {
|
|||
|
||||
Task {
|
||||
PollerService.shared.restart(interval: TimeInterval(config.pollIntervalSeconds))
|
||||
// (Re)establish the SSE stream using the (possibly new) subscribe URL.
|
||||
// connect() is a no-op when the URL is empty, so clearing the field
|
||||
// disables real-time push.
|
||||
BitableEventService.shared.reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue