Compare commits

..

No commits in common. "01db8e53478c2f4ab20b6cf2620078c39dd6d2ed" and "4d67d8bffd934a4e9fd0b6443aef5cfcd6029eb4" have entirely different histories.

6 changed files with 6 additions and 269 deletions

View File

@ -31,7 +31,6 @@
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 */; };
@ -68,7 +67,6 @@
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 */
@ -173,7 +171,6 @@
isa = PBXGroup;
children = (
F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */,
8F2E3D4C5B6A7988061A2B3C /* BitableEventService.swift */,
59CC175DE8E9FE3250757D33 /* NotificationService.swift */,
4F4628E7B599B69B499E09E9 /* PollerService.swift */,
0E7E3775F77DD36C27160A8C /* TokenManager.swift */,
@ -303,7 +300,6 @@
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 */,
@ -357,7 +353,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 4;
CURRENT_PROJECT_VERSION = 3;
FEISHU_APP_ID = cli_aacb82a1c0b8dcc0;
FEISHU_APP_SECRET = jUkU9ygRuQ0KaZFV2BSPfe33iXCahzNM;
FEISHU_BASE_DOMAIN = xmi5p6bgsnf8.feishu.cn;
@ -368,7 +364,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.3;
MARKETING_VERSION = 1.2;
PRODUCT_BUNDLE_IDENTIFIER = com.xorbitlab.bugger;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@ -381,7 +377,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 4;
CURRENT_PROJECT_VERSION = 3;
FEISHU_APP_ID = cli_aacb82a1c0b8dcc0;
FEISHU_APP_SECRET = jUkU9ygRuQ0KaZFV2BSPfe33iXCahzNM;
FEISHU_BASE_DOMAIN = xmi5p6bgsnf8.feishu.cn;
@ -392,7 +388,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.3;
MARKETING_VERSION = 1.2;
PRODUCT_BUNDLE_IDENTIFIER = com.xorbitlab.bugger;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;

View File

@ -15,18 +15,13 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.3</string>
<string>1.2</string>
<key>CFBundleVersion</key>
<string>4</string>
<string>3</string>
<key>LSMinimumSystemVersion</key>
<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>

View File

@ -28,9 +28,6 @@ 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()
}
}

View File

@ -16,11 +16,6 @@ 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"
@ -65,7 +60,6 @@ struct AppConfig: Codable, Equatable {
case appToken, tableId, assigneeName, fieldMappings
case pollIntervalSeconds, dailyRefreshTimes
case showFloatingWidget, launchAtLogin, feishuBaseDomain, refreshOnStart
case feishuAppBaseURL
}
init() {}
@ -82,7 +76,6 @@ 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) ?? ""
}
}

View File

@ -1,227 +0,0 @@
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,18 +113,6 @@ 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
@ -178,7 +166,6 @@ struct SettingsView: View {
Button("Disconnect Feishu") {
TokenManager.shared.clearTokens()
PollerService.shared.stop()
BitableEventService.shared.disconnect()
}
.foregroundStyle(.red)
}
@ -287,10 +274,6 @@ 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()
}
}