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 */; };
|
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 */,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,11 @@
|
||||||
<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,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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) ?? ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue