Compare commits

...

2 Commits

Author SHA1 Message Date
tigerenwork 01db8e5347 chore: 版本升级至 1.3 并更新构建号
- MARKETING_VERSION 更新为 1.3
- CURRENT_PROJECT_VERSION 更新为 4
- Info.plist 同步更新版本号与构建号
2026-07-08 12:06:16 +08:00
tigerenwork c275c71b55 feat(bitable): 支持通过 SSE 订阅 Bitable 变更并实时刷新
- 新增 BitableEventService,通过 SSE 监听飞书变更推送,收到 change 事件即触发 fetchNow
- AppConfig 新增 feishuAppBaseURL 字段,置空时禁用实时推送、回退轮询
- 设置页新增 "Real-time updates" 区块,支持配置订阅地址并随保存重连
- AppDelegate 启动时自动连接 SSE;断开飞书时同步断开
- 流断开后 30s 自动重连,显式 disconnect 不重连
- Info.plist 放开 ATS 以支持 HTTP 订阅服务(localhost/局域网)
2026-07-08 11:26:47 +08:00
6 changed files with 269 additions and 6 deletions

View File

@ -31,6 +31,7 @@
CAE624453A3F0375D872170A /* FeishuModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9FBC39502FA635EF1A6A5C /* FeishuModels.swift */; }; CAE624453A3F0375D872170A /* FeishuModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9FBC39502FA635EF1A6A5C /* FeishuModels.swift */; };
CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A83E76FBB83849E4B6704D3 /* FeishuError.swift */; }; CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A83E76FBB83849E4B6704D3 /* FeishuError.swift */; };
D2855F82E68FADA8BEA43086 /* AppStateService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1DD178095EFCE7BC121C1C4 /* AppStateService.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 */; }; DDB9EB345FEBB95DD9976364 /* BugChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B24E1E1D32F7B774D12DD85 /* BugChange.swift */; };
E4CBAE27491AF1C52944FA91 /* BugStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646C676356A4125B8E8D5FA5 /* BugStatus.swift */; }; E4CBAE27491AF1C52944FA91 /* BugStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646C676356A4125B8E8D5FA5 /* BugStatus.swift */; };
EEC5853D00ED565270DC867F /* LocalOAuthServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D11523ACB81DBB9D412995 /* LocalOAuthServer.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; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; FE9FBC39502FA635EF1A6A5C /* FeishuModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuModels.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@ -171,6 +173,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */, F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */,
8F2E3D4C5B6A7988061A2B3C /* BitableEventService.swift */,
59CC175DE8E9FE3250757D33 /* NotificationService.swift */, 59CC175DE8E9FE3250757D33 /* NotificationService.swift */,
4F4628E7B599B69B499E09E9 /* PollerService.swift */, 4F4628E7B599B69B499E09E9 /* PollerService.swift */,
0E7E3775F77DD36C27160A8C /* TokenManager.swift */, 0E7E3775F77DD36C27160A8C /* TokenManager.swift */,
@ -300,6 +303,7 @@
A33561284CA44A0045902BCC /* BugPriority.swift in Sources */, A33561284CA44A0045902BCC /* BugPriority.swift in Sources */,
E4CBAE27491AF1C52944FA91 /* BugStatus.swift in Sources */, E4CBAE27491AF1C52944FA91 /* BugStatus.swift in Sources */,
D2855F82E68FADA8BEA43086 /* AppStateService.swift in Sources */, D2855F82E68FADA8BEA43086 /* AppStateService.swift in Sources */,
7E1F2A3B4C5D6E7F8091A2B3 /* BitableEventService.swift in Sources */,
4309E8C70AE99466545A3E37 /* FeishuAuthService.swift in Sources */, 4309E8C70AE99466545A3E37 /* FeishuAuthService.swift in Sources */,
CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */, CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */,
CAE624453A3F0375D872170A /* FeishuModels.swift in Sources */, CAE624453A3F0375D872170A /* FeishuModels.swift in Sources */,
@ -353,7 +357,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 3; CURRENT_PROJECT_VERSION = 4;
FEISHU_APP_ID = cli_aacb82a1c0b8dcc0; FEISHU_APP_ID = cli_aacb82a1c0b8dcc0;
FEISHU_APP_SECRET = jUkU9ygRuQ0KaZFV2BSPfe33iXCahzNM; FEISHU_APP_SECRET = jUkU9ygRuQ0KaZFV2BSPfe33iXCahzNM;
FEISHU_BASE_DOMAIN = xmi5p6bgsnf8.feishu.cn; FEISHU_BASE_DOMAIN = xmi5p6bgsnf8.feishu.cn;
@ -364,7 +368,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.2; MARKETING_VERSION = 1.3;
PRODUCT_BUNDLE_IDENTIFIER = com.xorbitlab.bugger; PRODUCT_BUNDLE_IDENTIFIER = com.xorbitlab.bugger;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@ -377,7 +381,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 3; CURRENT_PROJECT_VERSION = 4;
FEISHU_APP_ID = cli_aacb82a1c0b8dcc0; FEISHU_APP_ID = cli_aacb82a1c0b8dcc0;
FEISHU_APP_SECRET = jUkU9ygRuQ0KaZFV2BSPfe33iXCahzNM; FEISHU_APP_SECRET = jUkU9ygRuQ0KaZFV2BSPfe33iXCahzNM;
FEISHU_BASE_DOMAIN = xmi5p6bgsnf8.feishu.cn; FEISHU_BASE_DOMAIN = xmi5p6bgsnf8.feishu.cn;
@ -388,7 +392,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.2; MARKETING_VERSION = 1.3;
PRODUCT_BUNDLE_IDENTIFIER = com.xorbitlab.bugger; PRODUCT_BUNDLE_IDENTIFIER = com.xorbitlab.bugger;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;

View File

@ -15,13 +15,18 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.2</string> <string>1.3</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>3</string> <string>4</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>14.0</string> <string>14.0</string>
<key>LSUIElement</key> <key>LSUIElement</key>
<true/> <true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>FEISHU_APP_ID</key> <key>FEISHU_APP_ID</key>
<string>$(FEISHU_APP_ID)</string> <string>$(FEISHU_APP_ID)</string>
<key>FEISHU_APP_SECRET</key> <key>FEISHU_APP_SECRET</key>

View File

@ -28,6 +28,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
Task { Task {
await PollerService.shared.startIfConfigured() 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()
} }
} }

View File

@ -16,6 +16,11 @@ struct AppConfig: Codable, Equatable {
/// instead of fetching immediately on launch. Avoids a crash-on-start /// instead of fetching immediately on launch. Avoids a crash-on-start
/// loop if the table fetch is failing. /// loop if the table fetch is failing.
var refreshOnStart: Bool = false 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 { struct FieldMappings: Codable, Equatable {
var titleField: String = "Title" var titleField: String = "Title"
@ -60,6 +65,7 @@ struct AppConfig: Codable, Equatable {
case appToken, tableId, assigneeName, fieldMappings case appToken, tableId, assigneeName, fieldMappings
case pollIntervalSeconds, dailyRefreshTimes case pollIntervalSeconds, dailyRefreshTimes
case showFloatingWidget, launchAtLogin, feishuBaseDomain, refreshOnStart case showFloatingWidget, launchAtLogin, feishuBaseDomain, refreshOnStart
case feishuAppBaseURL
} }
init() {} init() {}
@ -76,6 +82,7 @@ struct AppConfig: Codable, Equatable {
launchAtLogin = try c.decodeIfPresent(Bool.self, forKey: .launchAtLogin) ?? true launchAtLogin = try c.decodeIfPresent(Bool.self, forKey: .launchAtLogin) ?? true
feishuBaseDomain = try c.decodeIfPresent(String.self, forKey: .feishuBaseDomain) ?? BundledDefault.feishuBaseDomain feishuBaseDomain = try c.decodeIfPresent(String.self, forKey: .feishuBaseDomain) ?? BundledDefault.feishuBaseDomain
refreshOnStart = try c.decodeIfPresent(Bool.self, forKey: .refreshOnStart) ?? false refreshOnStart = try c.decodeIfPresent(Bool.self, forKey: .refreshOnStart) ?? false
feishuAppBaseURL = try c.decodeIfPresent(String.self, forKey: .feishuAppBaseURL) ?? ""
} }
} }

View File

@ -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)
}
}
}

View File

@ -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") { Section("Display") {
Toggle("Show floating widget", isOn: $config.showFloatingWidget) Toggle("Show floating widget", isOn: $config.showFloatingWidget)
.onChange(of: config.showFloatingWidget) { _, enabled in .onChange(of: config.showFloatingWidget) { _, enabled in
@ -166,6 +178,7 @@ struct SettingsView: View {
Button("Disconnect Feishu") { Button("Disconnect Feishu") {
TokenManager.shared.clearTokens() TokenManager.shared.clearTokens()
PollerService.shared.stop() PollerService.shared.stop()
BitableEventService.shared.disconnect()
} }
.foregroundStyle(.red) .foregroundStyle(.red)
} }
@ -274,6 +287,10 @@ struct SettingsView: View {
Task { Task {
PollerService.shared.restart(interval: TimeInterval(config.pollIntervalSeconds)) 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()
} }
} }