From c275c71b5568fb8a979c61f2aa8726cf5b5f8c00 Mon Sep 17 00:00:00 2001 From: tigerenwork Date: Wed, 8 Jul 2026 11:26:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(bitable):=20=E6=94=AF=E6=8C=81=E9=80=9A?= =?UTF-8?q?=E8=BF=87=20SSE=20=E8=AE=A2=E9=98=85=20Bitable=20=E5=8F=98?= =?UTF-8?q?=E6=9B=B4=E5=B9=B6=E5=AE=9E=E6=97=B6=E5=88=B7=E6=96=B0=20-=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20BitableEventService=EF=BC=8C=E9=80=9A?= =?UTF-8?q?=E8=BF=87=20SSE=20=E7=9B=91=E5=90=AC=E9=A3=9E=E4=B9=A6=E5=8F=98?= =?UTF-8?q?=E6=9B=B4=E6=8E=A8=E9=80=81=EF=BC=8C=E6=94=B6=E5=88=B0=20change?= =?UTF-8?q?=20=E4=BA=8B=E4=BB=B6=E5=8D=B3=E8=A7=A6=E5=8F=91=20fetchNow=20-?= =?UTF-8?q?=20AppConfig=20=E6=96=B0=E5=A2=9E=20feishuAppBaseURL=20?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=EF=BC=8C=E7=BD=AE=E7=A9=BA=E6=97=B6=E7=A6=81?= =?UTF-8?q?=E7=94=A8=E5=AE=9E=E6=97=B6=E6=8E=A8=E9=80=81=E3=80=81=E5=9B=9E?= =?UTF-8?q?=E9=80=80=E8=BD=AE=E8=AF=A2=20-=20=E8=AE=BE=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20"Real-time=20updates"=20=E5=8C=BA=E5=9D=97?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E9=85=8D=E7=BD=AE=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E5=9C=B0=E5=9D=80=E5=B9=B6=E9=9A=8F=E4=BF=9D=E5=AD=98=E9=87=8D?= =?UTF-8?q?=E8=BF=9E=20-=20AppDelegate=20=E5=90=AF=E5=8A=A8=E6=97=B6?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E8=BF=9E=E6=8E=A5=20SSE=EF=BC=9B=E6=96=AD?= =?UTF-8?q?=E5=BC=80=E9=A3=9E=E4=B9=A6=E6=97=B6=E5=90=8C=E6=AD=A5=E6=96=AD?= =?UTF-8?q?=E5=BC=80=20-=20=E6=B5=81=E6=96=AD=E5=BC=80=E5=90=8E=2030s=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E9=87=8D=E8=BF=9E=EF=BC=8C=E6=98=BE=E5=BC=8F?= =?UTF-8?q?=20disconnect=20=E4=B8=8D=E9=87=8D=E8=BF=9E=20-=20Info.plist=20?= =?UTF-8?q?=E6=94=BE=E5=BC=80=20ATS=20=E4=BB=A5=E6=94=AF=E6=8C=81=20HTTP?= =?UTF-8?q?=20=E8=AE=A2=E9=98=85=E6=9C=8D=E5=8A=A1=EF=BC=88localhost/?= =?UTF-8?q?=E5=B1=80=E5=9F=9F=E7=BD=91=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Bugger.xcodeproj/project.pbxproj | 4 + Resources/Info.plist | 5 + Sources/AppDelegate.swift | 3 + Sources/Models/AppConfig.swift | 7 + Sources/Services/BitableEventService.swift | 227 +++++++++++++++++++++ Sources/Views/Settings/SettingsView.swift | 17 ++ 6 files changed, 263 insertions(+) create mode 100644 Sources/Services/BitableEventService.swift diff --git a/Bugger.xcodeproj/project.pbxproj b/Bugger.xcodeproj/project.pbxproj index cc60784..3cb4cf5 100644 --- a/Bugger.xcodeproj/project.pbxproj +++ b/Bugger.xcodeproj/project.pbxproj @@ -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 = ""; }; F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateService.swift; sourceTree = ""; }; + 8F2E3D4C5B6A7988061A2B3C /* BitableEventService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitableEventService.swift; sourceTree = ""; }; F888AA5F655343B19839B4C2 /* URL+Feishu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Feishu.swift"; sourceTree = ""; }; FE9FBC39502FA635EF1A6A5C /* FeishuModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuModels.swift; sourceTree = ""; }; /* 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 */, diff --git a/Resources/Info.plist b/Resources/Info.plist index b483d56..222deeb 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -22,6 +22,11 @@ 14.0 LSUIElement + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + FEISHU_APP_ID $(FEISHU_APP_ID) FEISHU_APP_SECRET diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index bf03572..aa0185c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -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() } } diff --git a/Sources/Models/AppConfig.swift b/Sources/Models/AppConfig.swift index 6a6578b..012b3fd 100644 --- a/Sources/Models/AppConfig.swift +++ b/Sources/Models/AppConfig.swift @@ -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) ?? "" } } diff --git a/Sources/Services/BitableEventService.swift b/Sources/Services/BitableEventService.swift new file mode 100644 index 0000000..3577382 --- /dev/null +++ b/Sources/Services/BitableEventService.swift @@ -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[..