Compare commits
No commits in common. "01db8e53478c2f4ab20b6cf2620078c39dd6d2ed" and "4d67d8bffd934a4e9fd0b6443aef5cfcd6029eb4" have entirely different histories.
01db8e5347
...
4d67d8bffd
|
|
@ -31,7 +31,6 @@
|
||||||
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 */; };
|
||||||
|
|
@ -68,7 +67,6 @@
|
||||||
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 */
|
||||||
|
|
@ -173,7 +171,6 @@
|
||||||
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 */,
|
||||||
|
|
@ -303,7 +300,6 @@
|
||||||
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 */,
|
||||||
|
|
@ -357,7 +353,7 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 4;
|
CURRENT_PROJECT_VERSION = 3;
|
||||||
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;
|
||||||
|
|
@ -368,7 +364,7 @@
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.3;
|
MARKETING_VERSION = 1.2;
|
||||||
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;
|
||||||
|
|
@ -381,7 +377,7 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 4;
|
CURRENT_PROJECT_VERSION = 3;
|
||||||
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;
|
||||||
|
|
@ -392,7 +388,7 @@
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.3;
|
MARKETING_VERSION = 1.2;
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -15,18 +15,13 @@
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.3</string>
|
<string>1.2</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>4</string>
|
<string>3</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>
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,6 @@ 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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,6 @@ 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"
|
||||||
|
|
@ -65,7 +60,6 @@ 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() {}
|
||||||
|
|
@ -82,7 +76,6 @@ 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) ?? ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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") {
|
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
|
||||||
|
|
@ -178,7 +166,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
@ -287,10 +274,6 @@ 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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue