Compare commits
No commits in common. "main" and "首个可用版本" have entirely different histories.
|
|
@ -4,4 +4,3 @@ DerivedData/
|
||||||
xcuserdata/
|
xcuserdata/
|
||||||
Config.xcconfig
|
Config.xcconfig
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.idea/
|
|
||||||
|
|
@ -31,8 +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 */; };
|
|
||||||
A1B2C3D4E5F60718293A4B5C /* CalibrationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F70819293A4B5 /* CalibrationService.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 */; };
|
||||||
|
|
@ -69,8 +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>"; };
|
|
||||||
1A2B3C4D5E6F70819293A4B5 /* CalibrationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalibrationService.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 */
|
||||||
|
|
@ -175,8 +171,6 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */,
|
F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */,
|
||||||
8F2E3D4C5B6A7988061A2B3C /* BitableEventService.swift */,
|
|
||||||
1A2B3C4D5E6F70819293A4B5 /* CalibrationService.swift */,
|
|
||||||
59CC175DE8E9FE3250757D33 /* NotificationService.swift */,
|
59CC175DE8E9FE3250757D33 /* NotificationService.swift */,
|
||||||
4F4628E7B599B69B499E09E9 /* PollerService.swift */,
|
4F4628E7B599B69B499E09E9 /* PollerService.swift */,
|
||||||
0E7E3775F77DD36C27160A8C /* TokenManager.swift */,
|
0E7E3775F77DD36C27160A8C /* TokenManager.swift */,
|
||||||
|
|
@ -306,8 +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 */,
|
|
||||||
A1B2C3D4E5F60718293A4B5C /* CalibrationService.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 */,
|
||||||
|
|
@ -361,10 +353,10 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 5;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
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 = mi5p6bgsnf8.feishu.cn;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Resources/Info.plist;
|
INFOPLIST_FILE = Resources/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
|
@ -372,7 +364,7 @@
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.5;
|
MARKETING_VERSION = 1.0;
|
||||||
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;
|
||||||
|
|
@ -385,10 +377,10 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 5;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
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 = mi5p6bgsnf8.feishu.cn;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Resources/Info.plist;
|
INFOPLIST_FILE = Resources/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
|
@ -396,7 +388,7 @@
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.5;
|
MARKETING_VERSION = 1.0;
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,3 @@
|
||||||
FEISHU_APP_ID = cli_your_app_id
|
FEISHU_APP_ID = cli_your_app_id
|
||||||
FEISHU_APP_SECRET = your_app_secret
|
FEISHU_APP_SECRET = your_app_secret
|
||||||
FEISHU_BASE_DOMAIN = xorbitlab.feishu.cn
|
FEISHU_BASE_DOMAIN = xorbitlab.feishu.cn
|
||||||
|
|
||||||
// Bitable app token & table ID (from the Bitable URL). Baked into the app at
|
|
||||||
// build time as defaults so internal users don't have to enter them in Settings.
|
|
||||||
FEISHU_APP_TOKEN = your_bitable_app_token
|
|
||||||
FEISHU_TABLE_ID = your_bitable_table_id
|
|
||||||
|
|
|
||||||
|
|
@ -1,68 +1,58 @@
|
||||||
{
|
{
|
||||||
"images": [
|
"images" : [
|
||||||
{
|
{
|
||||||
"size": "16x16",
|
"idiom" : "mac",
|
||||||
"idiom": "mac",
|
"scale" : "1x",
|
||||||
"filename": "icon_16x16@x1.png",
|
"size" : "16x16"
|
||||||
"scale": "1x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size": "16x16",
|
"idiom" : "mac",
|
||||||
"idiom": "mac",
|
"scale" : "2x",
|
||||||
"filename": "icon_16x16@x2.png",
|
"size" : "16x16"
|
||||||
"scale": "2x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size": "32x32",
|
"idiom" : "mac",
|
||||||
"idiom": "mac",
|
"scale" : "1x",
|
||||||
"filename": "icon_32x32@x1.png",
|
"size" : "32x32"
|
||||||
"scale": "1x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size": "32x32",
|
"idiom" : "mac",
|
||||||
"idiom": "mac",
|
"scale" : "2x",
|
||||||
"filename": "icon_32x32@x2.png",
|
"size" : "32x32"
|
||||||
"scale": "2x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size": "128x128",
|
"idiom" : "mac",
|
||||||
"idiom": "mac",
|
"scale" : "1x",
|
||||||
"filename": "icon_128x128@x1.png",
|
"size" : "128x128"
|
||||||
"scale": "1x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size": "128x128",
|
"idiom" : "mac",
|
||||||
"idiom": "mac",
|
"scale" : "2x",
|
||||||
"filename": "icon_128x128@x2.png",
|
"size" : "128x128"
|
||||||
"scale": "2x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size": "256x256",
|
"idiom" : "mac",
|
||||||
"idiom": "mac",
|
"scale" : "1x",
|
||||||
"filename": "icon_256x256@x1.png",
|
"size" : "256x256"
|
||||||
"scale": "1x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size": "256x256",
|
"idiom" : "mac",
|
||||||
"idiom": "mac",
|
"scale" : "2x",
|
||||||
"filename": "icon_256x256@x2.png",
|
"size" : "256x256"
|
||||||
"scale": "2x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size": "512x512",
|
"idiom" : "mac",
|
||||||
"idiom": "mac",
|
"scale" : "1x",
|
||||||
"filename": "icon_512x512@x1.png",
|
"size" : "512x512"
|
||||||
"scale": "1x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size": "512x512",
|
"idiom" : "mac",
|
||||||
"idiom": "mac",
|
"scale" : "2x",
|
||||||
"filename": "icon_512x512@x2.png",
|
"size" : "512x512"
|
||||||
"scale": "2x"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info": {
|
"info" : {
|
||||||
"author": "xcode",
|
"author" : "xcode",
|
||||||
"version": 1
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 712 B |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
|
@ -15,27 +15,18 @@
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.5</string>
|
<string>1.0</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>5</string>
|
<string>1</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>
|
||||||
<string>$(FEISHU_APP_SECRET)</string>
|
<string>$(FEISHU_APP_SECRET)</string>
|
||||||
<key>FEISHU_BASE_DOMAIN</key>
|
<key>FEISHU_BASE_DOMAIN</key>
|
||||||
<string>$(FEISHU_BASE_DOMAIN)</string>
|
<string>$(FEISHU_BASE_DOMAIN)</string>
|
||||||
<key>FEISHU_APP_TOKEN</key>
|
|
||||||
<string>$(FEISHU_APP_TOKEN)</string>
|
|
||||||
<key>FEISHU_TABLE_ID</key>
|
|
||||||
<string>$(FEISHU_TABLE_ID)</string>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -28,10 +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()
|
|
||||||
CalibrationService.shared.start()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,6 @@ struct BuggerApp: App {
|
||||||
Settings {
|
Settings {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
}
|
}
|
||||||
.windowResizability(.contentMinSize)
|
.windowResizability(.contentSize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,23 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct AppConfig: Codable, Equatable {
|
struct AppConfig: Codable, Equatable {
|
||||||
var appToken: String = BundledDefault.appToken
|
var appToken: String = ""
|
||||||
var tableId: String = BundledDefault.tableId
|
var tableId: String = ""
|
||||||
var assigneeName: String = ""
|
var assigneeName: String = ""
|
||||||
var fieldMappings: FieldMappings = FieldMappings()
|
var fieldMappings: FieldMappings = FieldMappings()
|
||||||
var pollIntervalSeconds: Int = 300
|
var pollIntervalSeconds: Int = 300
|
||||||
/// Daily refresh times in "HH:mm" format, e.g. ["08:00", "14:00"].
|
|
||||||
/// Only used when pollIntervalSeconds == -1.
|
|
||||||
var dailyRefreshTimes: [String] = []
|
|
||||||
var showFloatingWidget: Bool = false
|
var showFloatingWidget: Bool = false
|
||||||
var launchAtLogin: Bool = true
|
var launchAtLogin: Bool = true
|
||||||
var feishuBaseDomain: String = BundledDefault.feishuBaseDomain
|
var feishuBaseDomain: String = "xorbitlab.feishu.cn"
|
||||||
/// When false (the default), Bugger waits for the next scheduled poll
|
|
||||||
/// 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 = ""
|
|
||||||
/// When true, Bugger periodically queries bugger-feishu for the list of
|
|
||||||
/// record IDs assigned to the current user and triggers a fetchNow() if
|
|
||||||
/// the list doesn't match the bugs in BugStore. Disabled by default —
|
|
||||||
/// only useful when feishuAppBaseURL is set and push reliability is a
|
|
||||||
/// concern.
|
|
||||||
var calibrationEnabled: Bool = false
|
|
||||||
|
|
||||||
struct FieldMappings: Codable, Equatable {
|
struct FieldMappings: Codable, Equatable {
|
||||||
var titleField: String = "问题描述"
|
var titleField: String = "Title"
|
||||||
var priorityField: String = "优先级"
|
var priorityField: String = "Priority"
|
||||||
var statusField: String = "状态"
|
var statusField: String = "Status"
|
||||||
var assigneeField: String = "负责人"
|
var assigneeField: String = "Assignee"
|
||||||
var reporterField: String = "创建人"
|
var reporterField: String = "Reporter"
|
||||||
var customerField: String = "客户名称"
|
var createdAtField: String = "Created At"
|
||||||
var createdAtField: String = "提出时间"
|
var updatedAtField: String = "Updated At"
|
||||||
var updatedAtField: String = "最后更新时间"
|
|
||||||
|
|
||||||
/// Map Feishu display values to standard BugStatus cases.
|
/// Map Feishu display values to standard BugStatus cases.
|
||||||
/// Keys are the Feishu select-option text; values are the standard status.
|
/// Keys are the Feishu select-option text; values are the standard status.
|
||||||
|
|
@ -64,47 +45,4 @@ struct AppConfig: Codable, Equatable {
|
||||||
var isConfigured: Bool {
|
var isConfigured: Bool {
|
||||||
!appToken.isEmpty && !tableId.isEmpty
|
!appToken.isEmpty && !tableId.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
// Explicit keys + tolerant decoding so a config persisted before a new
|
|
||||||
// field was added (missing key) doesn't fail to decode and wipe settings.
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case appToken, tableId, assigneeName, fieldMappings
|
|
||||||
case pollIntervalSeconds, dailyRefreshTimes
|
|
||||||
case showFloatingWidget, launchAtLogin, feishuBaseDomain, refreshOnStart
|
|
||||||
case feishuAppBaseURL, calibrationEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {}
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
appToken = try c.decodeIfPresent(String.self, forKey: .appToken) ?? BundledDefault.appToken
|
|
||||||
tableId = try c.decodeIfPresent(String.self, forKey: .tableId) ?? BundledDefault.tableId
|
|
||||||
assigneeName = try c.decodeIfPresent(String.self, forKey: .assigneeName) ?? ""
|
|
||||||
fieldMappings = try c.decodeIfPresent(FieldMappings.self, forKey: .fieldMappings) ?? FieldMappings()
|
|
||||||
pollIntervalSeconds = try c.decodeIfPresent(Int.self, forKey: .pollIntervalSeconds) ?? 300
|
|
||||||
dailyRefreshTimes = try c.decodeIfPresent([String].self, forKey: .dailyRefreshTimes) ?? []
|
|
||||||
showFloatingWidget = try c.decodeIfPresent(Bool.self, forKey: .showFloatingWidget) ?? false
|
|
||||||
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) ?? ""
|
|
||||||
calibrationEnabled = try c.decodeIfPresent(Bool.self, forKey: .calibrationEnabled) ?? false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Values baked into the app at build time via Config.xcconfig → Info.plist.
|
|
||||||
/// Used as defaults so internal users don't have to enter them in Settings.
|
|
||||||
enum BundledDefault {
|
|
||||||
static var appToken: String { string(for: "FEISHU_APP_TOKEN") }
|
|
||||||
static var tableId: String { string(for: "FEISHU_TABLE_ID") }
|
|
||||||
static var feishuBaseDomain: String { string(for: "FEISHU_BASE_DOMAIN") }
|
|
||||||
|
|
||||||
private static func string(for key: String) -> String {
|
|
||||||
let raw = (Bundle.main.object(forInfoDictionaryKey: key) as? String) ?? ""
|
|
||||||
// Xcode leaves an undefined $(...) substitution as the literal string;
|
|
||||||
// treat that as "not set" rather than showing "$(FEISHU_APP_TOKEN)".
|
|
||||||
if raw.hasPrefix("$(") { return "" }
|
|
||||||
return raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,9 @@ struct Bug: Identifiable, Equatable, Hashable {
|
||||||
let status: BugStatus
|
let status: BugStatus
|
||||||
let assignee: String
|
let assignee: String
|
||||||
let reporter: String?
|
let reporter: String?
|
||||||
let customer: String?
|
|
||||||
let createdAt: Date
|
let createdAt: Date
|
||||||
let updatedAt: Date
|
let updatedAt: Date
|
||||||
let feishuURL: URL?
|
let feishuURL: URL
|
||||||
|
|
||||||
var age: TimeInterval {
|
var age: TimeInterval {
|
||||||
Date().timeIntervalSince(createdAt)
|
Date().timeIntervalSince(createdAt)
|
||||||
|
|
|
||||||
|
|
@ -1,259 +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
|
|
||||||
|
|
||||||
// -- Exponential backoff with jitter -----------------------------------
|
|
||||||
|
|
||||||
/// Consecutive reconnect attempts since the last successful `connected`
|
|
||||||
/// event. Reset to 0 when the server confirms the stream is alive.
|
|
||||||
private var reconnectAttempt = 0
|
|
||||||
|
|
||||||
/// Base delay (seconds) for the first reconnect attempt.
|
|
||||||
private let reconnectBaseDelay: TimeInterval = 1
|
|
||||||
/// Multiplier applied per attempt.
|
|
||||||
private let reconnectBackoffFactor: Double = 2
|
|
||||||
/// Maximum delay (seconds) — preserves the previous steady-state cadence.
|
|
||||||
private let reconnectMaxDelay: TimeInterval = 30
|
|
||||||
/// Jitter range applied to the computed delay (uniform in [1-range, 1+range]).
|
|
||||||
private let reconnectJitter: Double = 0.25
|
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
/// Compute the reconnect delay for the current attempt count using
|
|
||||||
/// exponential backoff capped at `reconnectMaxDelay`, then apply ±jitter.
|
|
||||||
/// Formula: delay = min(cap, base * factor^(attempt-1)) * random(1±jitter)
|
|
||||||
private func computeReconnectDelay() -> TimeInterval {
|
|
||||||
let exponent = Double(max(reconnectAttempt, 1) - 1)
|
|
||||||
let raw = min(reconnectMaxDelay, reconnectBaseDelay * pow(reconnectBackoffFactor, exponent))
|
|
||||||
let lo = 1 - reconnectJitter
|
|
||||||
let hi = 1 + reconnectJitter
|
|
||||||
let jitterFactor = Double.random(in: lo...hi)
|
|
||||||
return raw * jitterFactor
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
reconnectAttempt = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }
|
|
||||||
|
|
||||||
reconnectAttempt += 1
|
|
||||||
let delay = computeReconnectDelay()
|
|
||||||
BuggerLog.info("BitableEventService: reconnecting in \(String(format: "%.1f", delay))s (attempt \(reconnectAttempt))")
|
|
||||||
|
|
||||||
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() + delay, 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 "connected":
|
|
||||||
reconnectAttempt = 0
|
|
||||||
case "error":
|
|
||||||
BuggerLog.error("BitableEventService: server error \(data)")
|
|
||||||
case "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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Periodically verifies that Bugger's local bug list matches the record IDs
|
|
||||||
/// the notification server (bugger-feishu) believes are assigned to the
|
|
||||||
/// current user. If there's a mismatch — meaning a push was missed — this
|
|
||||||
/// service triggers `PollerService.fetchNow()` to recalibrate.
|
|
||||||
///
|
|
||||||
/// Disabled by default. Enabled via the "Enable calibration check" toggle
|
|
||||||
/// in Advanced settings. Only active when `feishuAppBaseURL` is configured,
|
|
||||||
/// since the calibration endpoint lives on the same server as the SSE stream.
|
|
||||||
final class CalibrationService {
|
|
||||||
static let shared = CalibrationService()
|
|
||||||
|
|
||||||
private var timer: Timer?
|
|
||||||
private let interval: TimeInterval = 300
|
|
||||||
private var isRunning = false
|
|
||||||
private let session = URLSession.shared
|
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
// MARK: - Public
|
|
||||||
|
|
||||||
func start() {
|
|
||||||
guard !isRunning else { return }
|
|
||||||
guard let config = AppStateService.shared.config,
|
|
||||||
config.calibrationEnabled,
|
|
||||||
!config.feishuAppBaseURL.isEmpty else { return }
|
|
||||||
isRunning = true
|
|
||||||
|
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
|
||||||
Task { await self?.calibrate() }
|
|
||||||
}
|
|
||||||
timer?.tolerance = 30
|
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 60) { [weak self] in
|
|
||||||
Task { await self?.calibrate() }
|
|
||||||
}
|
|
||||||
|
|
||||||
BuggerLog.info("CalibrationService: started (interval=\(Int(interval))s)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
timer?.invalidate()
|
|
||||||
timer = nil
|
|
||||||
isRunning = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func restart() {
|
|
||||||
stop()
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private
|
|
||||||
|
|
||||||
private func calibrate() async {
|
|
||||||
guard let config = AppStateService.shared.config,
|
|
||||||
!config.feishuAppBaseURL.isEmpty,
|
|
||||||
!config.assigneeName.isEmpty,
|
|
||||||
!config.fieldMappings.assigneeField.isEmpty else { return }
|
|
||||||
|
|
||||||
guard let url = buildURL(config: config) else { return }
|
|
||||||
|
|
||||||
do {
|
|
||||||
let (data, response) = try await session.data(from: url)
|
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
|
||||||
BuggerLog.error("CalibrationService: non-200 response (\((response as? HTTPURLResponse)?.statusCode ?? -1))")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = try JSONDecoder().decode(OwnedRecordsResponse.self, from: data)
|
|
||||||
|
|
||||||
guard result.cacheStatus != "cold" else {
|
|
||||||
BuggerLog.info("CalibrationService: server cache cold, skipping")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let serverIDs = Set(result.recordIds)
|
|
||||||
let localIDs = Set(BugStore.shared.bugs.map(\.id))
|
|
||||||
|
|
||||||
guard !localIDs.isEmpty else {
|
|
||||||
BuggerLog.info("CalibrationService: no local bugs yet, skipping")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if serverIDs != localIDs {
|
|
||||||
let added = serverIDs.subtracting(localIDs)
|
|
||||||
let removed = localIDs.subtracting(serverIDs)
|
|
||||||
BuggerLog.info("CalibrationService: mismatch (server=\(serverIDs.count), local=\(localIDs.count), +\(added.count) -\(removed.count)), triggering fetchNow")
|
|
||||||
await PollerService.shared.fetchNow()
|
|
||||||
} else {
|
|
||||||
BuggerLog.debug("CalibrationService: in sync (\(serverIDs.count) records)")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
BuggerLog.error("CalibrationService: error — \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func buildURL(config: AppConfig) -> URL? {
|
|
||||||
let base = config.feishuAppBaseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
||||||
guard var comps = URLComponents(string: "\(base)/api/v1/bitable/owned_records") else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
comps.queryItems = [
|
|
||||||
URLQueryItem(name: "file_token", value: config.appToken),
|
|
||||||
URLQueryItem(name: "assignee_field", value: config.fieldMappings.assigneeField),
|
|
||||||
URLQueryItem(name: "assignee_name", value: config.assigneeName),
|
|
||||||
]
|
|
||||||
return comps.url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Response model
|
|
||||||
|
|
||||||
struct OwnedRecordsResponse: Decodable {
|
|
||||||
let recordIds: [String]
|
|
||||||
let cacheStatus: String
|
|
||||||
let count: Int
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case recordIds = "record_ids"
|
|
||||||
case cacheStatus = "cache_status"
|
|
||||||
case count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -80,11 +80,6 @@ final class FeishuAuthService {
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchCurrentUserName(accessToken: String) async throws -> String {
|
func fetchCurrentUserName(accessToken: String) async throws -> String {
|
||||||
let info = try await fetchUserInfo(accessToken: accessToken)
|
|
||||||
return info.name ?? info.enName ?? "Unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchUserInfo(accessToken: String) async throws -> UserInfoData {
|
|
||||||
let url = URL(string: "\(baseURL)/authen/v1/user_info")!
|
let url = URL(string: "\(baseURL)/authen/v1/user_info")!
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "GET"
|
request.httpMethod = "GET"
|
||||||
|
|
@ -93,7 +88,7 @@ final class FeishuAuthService {
|
||||||
guard let data = response.data else {
|
guard let data = response.data else {
|
||||||
throw FeishuError.apiError(code: response.code, message: response.msg)
|
throw FeishuError.apiError(code: response.code, message: response.msg)
|
||||||
}
|
}
|
||||||
return data
|
return data.name ?? data.enName ?? "Unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tenantAccessToken() async throws -> String {
|
private func tenantAccessToken() async throws -> String {
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,9 @@ enum FeishuError: Error, LocalizedError {
|
||||||
case unauthorized
|
case unauthorized
|
||||||
case networkError(Error?)
|
case networkError(Error?)
|
||||||
case apiError(code: Int, message: String)
|
case apiError(code: Int, message: String)
|
||||||
case decodingError(Error, rawBody: String? = nil)
|
case decodingError(Error)
|
||||||
case notConfigured
|
case notConfigured
|
||||||
case missingCredentials
|
case missingCredentials
|
||||||
case invalidConfiguration(String)
|
|
||||||
case paginationLimitExceeded
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
|
|
@ -18,19 +16,12 @@ enum FeishuError: Error, LocalizedError {
|
||||||
return "Network error: \(error?.localizedDescription ?? "Unknown")"
|
return "Network error: \(error?.localizedDescription ?? "Unknown")"
|
||||||
case .apiError(let code, let message):
|
case .apiError(let code, let message):
|
||||||
return "Feishu API error (\(code)): \(message)"
|
return "Feishu API error (\(code)): \(message)"
|
||||||
case .decodingError(let error, let rawBody):
|
case .decodingError:
|
||||||
if let rawBody {
|
return "Failed to parse Feishu response. Field mappings may be incorrect."
|
||||||
return "Failed to parse Feishu response: \(rawBody)"
|
|
||||||
}
|
|
||||||
return "Failed to parse Feishu response. Field mappings may be incorrect. (\(error.localizedDescription))"
|
|
||||||
case .notConfigured:
|
case .notConfigured:
|
||||||
return "Feishu table not configured. Open Settings."
|
return "Feishu table not configured. Open Settings."
|
||||||
case .missingCredentials:
|
case .missingCredentials:
|
||||||
return "Feishu app credentials are missing. Set FEISHU_APP_ID and FEISHU_APP_SECRET."
|
return "Feishu app credentials are missing. Set FEISHU_APP_ID and FEISHU_APP_SECRET."
|
||||||
case .invalidConfiguration(let detail):
|
|
||||||
return "Invalid Feishu configuration: \(detail)"
|
|
||||||
case .paginationLimitExceeded:
|
|
||||||
return "Feishu table has too many records to load in one pass."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,9 +55,6 @@ struct UserInfoData: Decodable {
|
||||||
let name: String?
|
let name: String?
|
||||||
// en_name → enName via .convertFromSnakeCase
|
// en_name → enName via .convertFromSnakeCase
|
||||||
let enName: String?
|
let enName: String?
|
||||||
// open_id → openId via .convertFromSnakeCase
|
|
||||||
// Needed for server-side filtering of Person-type fields on the search endpoint.
|
|
||||||
let openId: String?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum JSONValue: Decodable {
|
enum JSONValue: Decodable {
|
||||||
|
|
@ -96,26 +93,6 @@ enum JSONValue: Decodable {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Display text for Feishu fields whose values are represented as
|
|
||||||
/// structured content, such as text fields (`[{"type": "text", "text": "..."}]`).
|
|
||||||
var textValue: String? {
|
|
||||||
switch self {
|
|
||||||
case let .string(value):
|
|
||||||
return value
|
|
||||||
case let .number(value):
|
|
||||||
return String(value)
|
|
||||||
case let .bool(value):
|
|
||||||
return String(value)
|
|
||||||
case let .array(items):
|
|
||||||
let text = items.compactMap(\.textValue).joined()
|
|
||||||
return text.isEmpty ? nil : text
|
|
||||||
case let .object(fields):
|
|
||||||
return fields["text"]?.textValue ?? fields["name"]?.textValue
|
|
||||||
case .null:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var firstUserName: String? {
|
var firstUserName: String? {
|
||||||
guard case let .array(items) = self else { return nil }
|
guard case let .array(items) = self else { return nil }
|
||||||
for item in items {
|
for item in items {
|
||||||
|
|
@ -143,7 +120,6 @@ enum BugMapper {
|
||||||
status: mapStatus(stringValue(in: fields, key: mappings.statusField), mappings: mappings),
|
status: mapStatus(stringValue(in: fields, key: mappings.statusField), mappings: mappings),
|
||||||
assignee: userName(in: fields, key: mappings.assigneeField) ?? "Unknown",
|
assignee: userName(in: fields, key: mappings.assigneeField) ?? "Unknown",
|
||||||
reporter: userName(in: fields, key: mappings.reporterField),
|
reporter: userName(in: fields, key: mappings.reporterField),
|
||||||
customer: rawValue(in: fields, key: mappings.customerField).map { "\($0)" },
|
|
||||||
createdAt: FeishuDateParser.parse(rawValue(in: fields, key: mappings.createdAtField)) ?? .distantPast,
|
createdAt: FeishuDateParser.parse(rawValue(in: fields, key: mappings.createdAtField)) ?? .distantPast,
|
||||||
updatedAt: FeishuDateParser.parse(rawValue(in: fields, key: mappings.updatedAtField)) ?? .distantPast,
|
updatedAt: FeishuDateParser.parse(rawValue(in: fields, key: mappings.updatedAtField)) ?? .distantPast,
|
||||||
feishuURL: FeishuURLBuilder.recordURL(
|
feishuURL: FeishuURLBuilder.recordURL(
|
||||||
|
|
@ -162,16 +138,21 @@ enum BugMapper {
|
||||||
case let .number(number): return number
|
case let .number(number): return number
|
||||||
case let .bool(bool): return bool
|
case let .bool(bool): return bool
|
||||||
case let .array(array):
|
case let .array(array):
|
||||||
return array.compactMap(\.textValue)
|
return array.compactMap { item -> String? in
|
||||||
|
if case let .object(fields) = item {
|
||||||
|
return fields["name"]?.stringValue
|
||||||
|
}
|
||||||
|
return item.stringValue
|
||||||
|
}
|
||||||
case let .object(object):
|
case let .object(object):
|
||||||
return object["name"]?.textValue ?? object["text"]?.textValue
|
return object["name"]?.stringValue ?? object["text"]?.stringValue
|
||||||
case .null:
|
case .null:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func stringValue(in fields: [String: JSONValue], key: String) -> String? {
|
private static func stringValue(in fields: [String: JSONValue], key: String) -> String? {
|
||||||
fields[key]?.textValue
|
fields[key]?.stringValue
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func userName(in fields: [String: JSONValue], key: String) -> String? {
|
private static func userName(in fields: [String: JSONValue], key: String) -> String? {
|
||||||
|
|
|
||||||
|
|
@ -15,16 +15,23 @@ final class FeishuService {
|
||||||
func fetchBugs(
|
func fetchBugs(
|
||||||
config: AppConfig,
|
config: AppConfig,
|
||||||
assigneeName: String,
|
assigneeName: String,
|
||||||
accessToken: String,
|
accessToken: String
|
||||||
userOpenId: String? = nil
|
|
||||||
) async throws -> [Bug] {
|
) async throws -> [Bug] {
|
||||||
let records = try await fetchRecordsFiltered(
|
var allRecords: [RecordItem] = []
|
||||||
config: config,
|
var pageToken: String?
|
||||||
assigneeName: assigneeName,
|
|
||||||
accessToken: accessToken,
|
repeat {
|
||||||
userOpenId: userOpenId
|
let page = try await fetchPage(
|
||||||
)
|
appToken: config.appToken,
|
||||||
return records
|
tableId: config.tableId,
|
||||||
|
pageToken: pageToken,
|
||||||
|
accessToken: accessToken
|
||||||
|
)
|
||||||
|
allRecords.append(contentsOf: page.items)
|
||||||
|
pageToken = page.hasMore ? page.pageToken : nil
|
||||||
|
} while pageToken != nil
|
||||||
|
|
||||||
|
return allRecords
|
||||||
.map { BugMapper.map($0, config: config) }
|
.map { BugMapper.map($0, config: config) }
|
||||||
.filter { bug in
|
.filter { bug in
|
||||||
bug.assignee.localizedCaseInsensitiveContains(assigneeName)
|
bug.assignee.localizedCaseInsensitiveContains(assigneeName)
|
||||||
|
|
@ -46,124 +53,23 @@ final class FeishuService {
|
||||||
return page.total ?? page.items.count
|
return page.total ?? page.items.count
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Filtered fetch (3-tier fallback)
|
|
||||||
|
|
||||||
/// Fetch records using a 3-tier strategy:
|
|
||||||
/// 1. Search endpoint with open_id filter (Person-type fields, requires OAuth)
|
|
||||||
/// 2. List endpoint with CurrentValue filter (text-type fields)
|
|
||||||
/// 3. Unfiltered list (current behavior — fetch all, filter client-side)
|
|
||||||
private func fetchRecordsFiltered(
|
|
||||||
config: AppConfig,
|
|
||||||
assigneeName: String,
|
|
||||||
accessToken: String,
|
|
||||||
userOpenId: String?
|
|
||||||
) async throws -> [RecordItem] {
|
|
||||||
// Tier 1: search endpoint with open_id (Person fields)
|
|
||||||
if let openId = userOpenId, !openId.isEmpty {
|
|
||||||
do {
|
|
||||||
let records = try await searchRecords(
|
|
||||||
config: config,
|
|
||||||
accessToken: accessToken,
|
|
||||||
assigneeField: config.fieldMappings.assigneeField,
|
|
||||||
openId: openId
|
|
||||||
)
|
|
||||||
if !records.isEmpty {
|
|
||||||
BuggerLog.debug("FeishuService: tier 1 (search by open_id) returned \(records.count) records")
|
|
||||||
return records
|
|
||||||
}
|
|
||||||
BuggerLog.debug("FeishuService: tier 1 returned 0 records, falling back")
|
|
||||||
} catch {
|
|
||||||
BuggerLog.debug("FeishuService: tier 1 failed (\(error.localizedDescription)), falling back")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tier 2: list endpoint with CurrentValue filter (text fields)
|
|
||||||
let filterStr = "CurrentValue.[\(config.fieldMappings.assigneeField)]=\"\(assigneeName)\""
|
|
||||||
do {
|
|
||||||
let records = try await listAllRecords(
|
|
||||||
config: config,
|
|
||||||
accessToken: accessToken,
|
|
||||||
filter: filterStr
|
|
||||||
)
|
|
||||||
if !records.isEmpty {
|
|
||||||
BuggerLog.debug("FeishuService: tier 2 (list with filter) returned \(records.count) records")
|
|
||||||
return records
|
|
||||||
}
|
|
||||||
BuggerLog.debug("FeishuService: tier 2 returned 0 records, falling back")
|
|
||||||
} catch {
|
|
||||||
BuggerLog.debug("FeishuService: tier 2 failed (\(error.localizedDescription)), falling back")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tier 3: unfiltered list (original behavior)
|
|
||||||
BuggerLog.debug("FeishuService: tier 3 (unfiltered list)")
|
|
||||||
return try await listAllRecords(
|
|
||||||
config: config,
|
|
||||||
accessToken: accessToken,
|
|
||||||
filter: nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - List endpoint (GET /records)
|
|
||||||
|
|
||||||
private func listAllRecords(
|
|
||||||
config: AppConfig,
|
|
||||||
accessToken: String,
|
|
||||||
filter: String?
|
|
||||||
) async throws -> [RecordItem] {
|
|
||||||
var allRecords: [RecordItem] = []
|
|
||||||
var pageToken: String?
|
|
||||||
var pages = 0
|
|
||||||
let maxPages = 50
|
|
||||||
|
|
||||||
repeat {
|
|
||||||
pages += 1
|
|
||||||
guard pages <= maxPages else {
|
|
||||||
throw FeishuError.paginationLimitExceeded
|
|
||||||
}
|
|
||||||
let page = try await fetchPage(
|
|
||||||
appToken: config.appToken,
|
|
||||||
tableId: config.tableId,
|
|
||||||
pageToken: pageToken,
|
|
||||||
accessToken: accessToken,
|
|
||||||
filter: filter
|
|
||||||
)
|
|
||||||
allRecords.append(contentsOf: page.items)
|
|
||||||
pageToken = page.hasMore ? page.pageToken : nil
|
|
||||||
} while pageToken != nil
|
|
||||||
|
|
||||||
return allRecords
|
|
||||||
}
|
|
||||||
|
|
||||||
private func fetchPage(
|
private func fetchPage(
|
||||||
appToken: String,
|
appToken: String,
|
||||||
tableId: String,
|
tableId: String,
|
||||||
pageToken: String?,
|
pageToken: String?,
|
||||||
pageSize: Int = 500,
|
pageSize: Int = 500,
|
||||||
accessToken: String,
|
accessToken: String
|
||||||
filter: String? = nil
|
|
||||||
) async throws -> RecordListData {
|
) async throws -> RecordListData {
|
||||||
guard !appToken.isEmpty, !tableId.isEmpty else {
|
var components = URLComponents(
|
||||||
throw FeishuError.invalidConfiguration("App token and table ID must not be empty.")
|
|
||||||
}
|
|
||||||
guard var components = URLComponents(
|
|
||||||
string: "\(baseURL)/bitable/v1/apps/\(appToken)/tables/\(tableId)/records"
|
string: "\(baseURL)/bitable/v1/apps/\(appToken)/tables/\(tableId)/records"
|
||||||
) else {
|
)!
|
||||||
throw FeishuError.invalidConfiguration("App token or table ID contains invalid characters.")
|
|
||||||
}
|
|
||||||
var queryItems = [URLQueryItem(name: "page_size", value: String(pageSize))]
|
var queryItems = [URLQueryItem(name: "page_size", value: String(pageSize))]
|
||||||
if let pageToken {
|
if let pageToken {
|
||||||
queryItems.append(URLQueryItem(name: "page_token", value: pageToken))
|
queryItems.append(URLQueryItem(name: "page_token", value: pageToken))
|
||||||
}
|
}
|
||||||
if let filter, !filter.isEmpty {
|
|
||||||
queryItems.append(URLQueryItem(name: "filter", value: filter))
|
|
||||||
}
|
|
||||||
components.queryItems = queryItems
|
components.queryItems = queryItems
|
||||||
|
|
||||||
guard let url = components.url else {
|
var request = URLRequest(url: components.url!)
|
||||||
throw FeishuError.invalidConfiguration("Could not build records request URL.")
|
|
||||||
}
|
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.httpMethod = "GET"
|
request.httpMethod = "GET"
|
||||||
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||||||
request.timeoutInterval = 30
|
request.timeoutInterval = 30
|
||||||
|
|
@ -172,10 +78,6 @@ final class FeishuService {
|
||||||
guard let httpResponse = response as? HTTPURLResponse else {
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
throw FeishuError.networkError(nil)
|
throw FeishuError.networkError(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
let rawBody = String(data: data, encoding: .utf8) ?? "<binary \(data.count)B>"
|
|
||||||
BuggerLog.debug("fetchPage: HTTP \(httpResponse.statusCode) body=\(String(rawBody.prefix(500)))")
|
|
||||||
|
|
||||||
if httpResponse.statusCode == 401 {
|
if httpResponse.statusCode == 401 {
|
||||||
throw FeishuError.unauthorized
|
throw FeishuError.unauthorized
|
||||||
}
|
}
|
||||||
|
|
@ -184,109 +86,7 @@ final class FeishuService {
|
||||||
do {
|
do {
|
||||||
apiResponse = try decoder.decode(FeishuAPIResponse<RecordListData>.self, from: data)
|
apiResponse = try decoder.decode(FeishuAPIResponse<RecordListData>.self, from: data)
|
||||||
} catch {
|
} catch {
|
||||||
BuggerLog.error("fetchPage: decode FAILED: \(error)")
|
throw FeishuError.decodingError(error)
|
||||||
BuggerLog.error("fetchPage: raw body: \(String(rawBody.prefix(1000)))")
|
|
||||||
throw FeishuError.decodingError(error, rawBody: String(rawBody.prefix(200)))
|
|
||||||
}
|
|
||||||
|
|
||||||
guard apiResponse.code == 0, let pageData = apiResponse.data else {
|
|
||||||
throw FeishuError.apiError(code: apiResponse.code, message: apiResponse.msg)
|
|
||||||
}
|
|
||||||
return pageData
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Search endpoint (POST /records/search)
|
|
||||||
|
|
||||||
private func searchRecords(
|
|
||||||
config: AppConfig,
|
|
||||||
accessToken: String,
|
|
||||||
assigneeField: String,
|
|
||||||
openId: String
|
|
||||||
) async throws -> [RecordItem] {
|
|
||||||
var allRecords: [RecordItem] = []
|
|
||||||
var pageToken: String?
|
|
||||||
var pages = 0
|
|
||||||
let maxPages = 50
|
|
||||||
|
|
||||||
repeat {
|
|
||||||
pages += 1
|
|
||||||
guard pages <= maxPages else {
|
|
||||||
throw FeishuError.paginationLimitExceeded
|
|
||||||
}
|
|
||||||
let page = try await searchPage(
|
|
||||||
appToken: config.appToken,
|
|
||||||
tableId: config.tableId,
|
|
||||||
pageToken: pageToken,
|
|
||||||
accessToken: accessToken,
|
|
||||||
assigneeField: assigneeField,
|
|
||||||
openId: openId
|
|
||||||
)
|
|
||||||
allRecords.append(contentsOf: page.items)
|
|
||||||
pageToken = page.hasMore ? page.pageToken : nil
|
|
||||||
} while pageToken != nil
|
|
||||||
|
|
||||||
return allRecords
|
|
||||||
}
|
|
||||||
|
|
||||||
private func searchPage(
|
|
||||||
appToken: String,
|
|
||||||
tableId: String,
|
|
||||||
pageToken: String?,
|
|
||||||
accessToken: String,
|
|
||||||
assigneeField: String,
|
|
||||||
openId: String
|
|
||||||
) async throws -> RecordListData {
|
|
||||||
guard !appToken.isEmpty, !tableId.isEmpty else {
|
|
||||||
throw FeishuError.invalidConfiguration("App token and table ID must not be empty.")
|
|
||||||
}
|
|
||||||
|
|
||||||
let urlString = "\(baseURL)/bitable/v1/apps/\(appToken)/tables/\(tableId)/records/search"
|
|
||||||
guard let url = URL(string: urlString) else {
|
|
||||||
throw FeishuError.invalidConfiguration("Could not build search request URL.")
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: [String: Any] = [
|
|
||||||
"page_size": 500,
|
|
||||||
"filter": [
|
|
||||||
"conjunction": "and",
|
|
||||||
"conditions": [
|
|
||||||
[
|
|
||||||
"field_name": assigneeField,
|
|
||||||
"operator": "is",
|
|
||||||
"value": [openId],
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
if let pageToken, !pageToken.isEmpty {
|
|
||||||
body["page_token"] = pageToken
|
|
||||||
}
|
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.httpMethod = "POST"
|
|
||||||
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
|
||||||
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
|
|
||||||
request.timeoutInterval = 30
|
|
||||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
|
||||||
|
|
||||||
let (data, response) = try await session.data(for: request)
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse else {
|
|
||||||
throw FeishuError.networkError(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
let rawBody = String(data: data, encoding: .utf8) ?? "<binary \(data.count)B>"
|
|
||||||
BuggerLog.debug("searchPage: HTTP \(httpResponse.statusCode) body=\(String(rawBody.prefix(500)))")
|
|
||||||
|
|
||||||
if httpResponse.statusCode == 401 {
|
|
||||||
throw FeishuError.unauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
let apiResponse: FeishuAPIResponse<RecordListData>
|
|
||||||
do {
|
|
||||||
apiResponse = try decoder.decode(FeishuAPIResponse<RecordListData>.self, from: data)
|
|
||||||
} catch {
|
|
||||||
BuggerLog.error("searchPage: decode FAILED: \(error)")
|
|
||||||
throw FeishuError.decodingError(error, rawBody: String(rawBody.prefix(200)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
guard apiResponse.code == 0, let pageData = apiResponse.data else {
|
guard apiResponse.code == 0, let pageData = apiResponse.data else {
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,7 @@ final class NotificationService: NSObject, UNUserNotificationCenterDelegate {
|
||||||
guard isAuthorized else { return }
|
guard isAuthorized else { return }
|
||||||
|
|
||||||
let significant = changes
|
let significant = changes
|
||||||
.filter { change in
|
.filter { $0.type == .newBug || $0.type == .priorityChanged }
|
||||||
guard change.type == .newBug || change.type == .priorityChanged else { return false }
|
|
||||||
// Skip activity toasts for bugs that are already done.
|
|
||||||
return change.bug.status != .resolved && change.bug.status != .closed
|
|
||||||
}
|
|
||||||
.prefix(3)
|
.prefix(3)
|
||||||
|
|
||||||
for change in significant {
|
for change in significant {
|
||||||
|
|
@ -60,11 +56,10 @@ final class NotificationService: NSObject, UNUserNotificationCenterDelegate {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var userInfo: [String: Any] = ["bugId": change.bug.id]
|
content.userInfo = [
|
||||||
if let url = change.bug.feishuURL {
|
"bugId": change.bug.id,
|
||||||
userInfo["feishuURL"] = url.absoluteString
|
"feishuURL": change.bug.feishuURL.absoluteString
|
||||||
}
|
]
|
||||||
content.userInfo = userInfo
|
|
||||||
|
|
||||||
let request = UNNotificationRequest(
|
let request = UNNotificationRequest(
|
||||||
identifier: "bugger-\(change.bug.id)-\(Date().timeIntervalSince1970)",
|
identifier: "bugger-\(change.bug.id)-\(Date().timeIntervalSince1970)",
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,7 @@ final class PollerService {
|
||||||
private let configService = AppStateService.shared
|
private let configService = AppStateService.shared
|
||||||
|
|
||||||
private var timer: Timer?
|
private var timer: Timer?
|
||||||
private var lastFetchTime: Date?
|
private var isFetching = false
|
||||||
private let fetchGate = FetchGate()
|
|
||||||
|
|
||||||
private(set) var isRunning = false
|
private(set) var isRunning = false
|
||||||
|
|
||||||
|
|
@ -24,29 +23,19 @@ final class PollerService {
|
||||||
tokenManager.isAuthenticated else {
|
tokenManager.isAuthenticated else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
start(interval: TimeInterval(config.pollIntervalSeconds), fetchImmediately: config.refreshOnStart)
|
start(interval: TimeInterval(config.pollIntervalSeconds))
|
||||||
}
|
}
|
||||||
|
|
||||||
func start(interval: TimeInterval, fetchImmediately: Bool = true) {
|
func start(interval: TimeInterval) {
|
||||||
guard !isRunning else { return }
|
guard !isRunning else { return }
|
||||||
isRunning = true
|
isRunning = true
|
||||||
|
|
||||||
if fetchImmediately {
|
Task { await performFetch() }
|
||||||
Task { await performFetch() }
|
|
||||||
}
|
|
||||||
|
|
||||||
if interval < 0 {
|
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||||
// Daily schedule mode: check every 60s, fire at configured times
|
Task { await self?.performFetch() }
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
|
|
||||||
self?.checkDailySchedule()
|
|
||||||
}
|
|
||||||
timer?.tolerance = 5
|
|
||||||
} else {
|
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
|
||||||
Task { await self?.performFetch() }
|
|
||||||
}
|
|
||||||
timer?.tolerance = interval * 0.1
|
|
||||||
}
|
}
|
||||||
|
timer?.tolerance = interval * 0.1
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
|
|
@ -64,75 +53,10 @@ final class PollerService {
|
||||||
await performFetch()
|
await performFetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Daily schedule
|
|
||||||
|
|
||||||
private func checkDailySchedule() {
|
|
||||||
guard let config = configService.config else { return }
|
|
||||||
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "HH:mm"
|
|
||||||
let now = formatter.string(from: Date())
|
|
||||||
|
|
||||||
guard config.dailyRefreshTimes.contains(now) else { return }
|
|
||||||
|
|
||||||
// Avoid firing twice in the same minute
|
|
||||||
if let last = lastFetchTime, Date().timeIntervalSince(last) < 120 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Task { await performFetch() }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Fetch
|
|
||||||
|
|
||||||
/// Serializes fetches and coalesces concurrent triggers.
|
|
||||||
///
|
|
||||||
/// Previously a trigger arriving mid-fetch was silently dropped
|
|
||||||
/// (`guard !isFetching else { return }`). Rapid successive changes —
|
|
||||||
/// e.g. an A→B→A reassignment producing two SSE wake-ups — then
|
|
||||||
/// collapsed into a single fetch, and if that fetch sampled the final
|
|
||||||
/// state the transition was never observed and no notification fired.
|
|
||||||
/// Now a trigger arriving mid-fetch queues exactly one follow-up round,
|
|
||||||
/// so the final state is always observed. The actor also makes the
|
|
||||||
/// check-then-set race-safe (triggers arrive from the timer, the SSE
|
|
||||||
/// handler and the UI on different tasks).
|
|
||||||
private actor FetchGate {
|
|
||||||
private var fetching = false
|
|
||||||
private var pending = false
|
|
||||||
|
|
||||||
/// Returns true if the caller may start fetching; otherwise queues
|
|
||||||
/// a follow-up round and returns false.
|
|
||||||
func tryBegin() -> Bool {
|
|
||||||
if fetching {
|
|
||||||
pending = true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
fetching = true
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ends one round. Returns true when another round was requested
|
|
||||||
/// while fetching; the gate stays held between rounds so no
|
|
||||||
/// parallel fetch can slip in.
|
|
||||||
func endRound() -> Bool {
|
|
||||||
if pending {
|
|
||||||
pending = false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
fetching = false
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func performFetch() async {
|
private func performFetch() async {
|
||||||
guard await fetchGate.tryBegin() else { return }
|
guard !isFetching else { return }
|
||||||
repeat {
|
isFetching = true
|
||||||
await fetchOnce()
|
defer { isFetching = false }
|
||||||
} while await fetchGate.endRound()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func fetchOnce() async {
|
|
||||||
lastFetchTime = Date()
|
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
bugStore.setLoading(true)
|
bugStore.setLoading(true)
|
||||||
|
|
@ -143,12 +67,10 @@ final class PollerService {
|
||||||
let config = try getConfig()
|
let config = try getConfig()
|
||||||
let token = try await tokenManager.getAccessToken()
|
let token = try await tokenManager.getAccessToken()
|
||||||
let assignee = try await tokenManager.resolveAssigneeName(config: config, accessToken: token)
|
let assignee = try await tokenManager.resolveAssigneeName(config: config, accessToken: token)
|
||||||
let openId = tokenManager.cachedOpenId
|
|
||||||
let bugs = try await feishuService.fetchBugs(
|
let bugs = try await feishuService.fetchBugs(
|
||||||
config: config,
|
config: config,
|
||||||
assigneeName: assignee,
|
assigneeName: assignee,
|
||||||
accessToken: token,
|
accessToken: token
|
||||||
userOpenId: openId
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ final class TokenManager {
|
||||||
private let accessTokenKey = "feishu.access_token"
|
private let accessTokenKey = "feishu.access_token"
|
||||||
private let refreshTokenKey = "feishu.refresh_token"
|
private let refreshTokenKey = "feishu.refresh_token"
|
||||||
private let tokenExpiryKey = "feishu.token_expiry"
|
private let tokenExpiryKey = "feishu.token_expiry"
|
||||||
private let openIdKey = "feishu.open_id"
|
|
||||||
|
|
||||||
enum State: Equatable {
|
enum State: Equatable {
|
||||||
case unauthenticated
|
case unauthenticated
|
||||||
|
|
@ -39,18 +38,6 @@ final class TokenManager {
|
||||||
return authService.authorizeURL
|
return authService.authorizeURL
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The user's Feishu open_id, cached after the first OAuth callback or
|
|
||||||
/// user-info fetch. Used for server-side filtering of Person-type fields
|
|
||||||
/// on the Feishu search endpoint. Nil if the user hasn't authenticated.
|
|
||||||
var cachedOpenId: String? {
|
|
||||||
keychain.read(openIdKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
func storeOpenId(_ openId: String) {
|
|
||||||
guard !openId.isEmpty else { return }
|
|
||||||
keychain.write(openId, forKey: openIdKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAccessToken() async throws -> String {
|
func getAccessToken() async throws -> String {
|
||||||
if let token = keychain.read(accessTokenKey),
|
if let token = keychain.read(accessTokenKey),
|
||||||
let expiry = UserDefaults.standard.object(forKey: tokenExpiryKey) as? Date,
|
let expiry = UserDefaults.standard.object(forKey: tokenExpiryKey) as? Date,
|
||||||
|
|
@ -96,7 +83,6 @@ final class TokenManager {
|
||||||
func clearTokens() {
|
func clearTokens() {
|
||||||
keychain.delete(accessTokenKey)
|
keychain.delete(accessTokenKey)
|
||||||
keychain.delete(refreshTokenKey)
|
keychain.delete(refreshTokenKey)
|
||||||
keychain.delete(openIdKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: tokenExpiryKey)
|
UserDefaults.standard.removeObject(forKey: tokenExpiryKey)
|
||||||
state = .unauthenticated
|
state = .unauthenticated
|
||||||
}
|
}
|
||||||
|
|
@ -119,11 +105,6 @@ final class TokenManager {
|
||||||
refresh: response.refreshToken ?? "",
|
refresh: response.refreshToken ?? "",
|
||||||
expiresIn: response.expiresIn
|
expiresIn: response.expiresIn
|
||||||
)
|
)
|
||||||
// Fetch and cache the user's open_id for server-side record filtering.
|
|
||||||
if let userInfo = try? await authService.fetchUserInfo(accessToken: response.accessToken),
|
|
||||||
let openId = userInfo.openId {
|
|
||||||
storeOpenId(openId)
|
|
||||||
}
|
|
||||||
state = .authenticated
|
state = .authenticated
|
||||||
BuggerLog.info("handleCallback: done, authenticated ✓")
|
BuggerLog.info("handleCallback: done, authenticated ✓")
|
||||||
}
|
}
|
||||||
|
|
@ -132,11 +113,7 @@ final class TokenManager {
|
||||||
if !config.assigneeName.isEmpty {
|
if !config.assigneeName.isEmpty {
|
||||||
return config.assigneeName
|
return config.assigneeName
|
||||||
}
|
}
|
||||||
let userInfo = try await authService.fetchUserInfo(accessToken: accessToken)
|
return try await authService.fetchCurrentUserName(accessToken: accessToken)
|
||||||
if let openId = userInfo.openId {
|
|
||||||
storeOpenId(openId)
|
|
||||||
}
|
|
||||||
return userInfo.name ?? userInfo.enName ?? "Unknown"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,26 +6,12 @@ enum FeishuURLBuilder {
|
||||||
appToken: String,
|
appToken: String,
|
||||||
tableId: String,
|
tableId: String,
|
||||||
recordId: String
|
recordId: String
|
||||||
) -> URL? {
|
) -> URL {
|
||||||
// Feishu Bitable record deep link: uses query parameters.
|
// Feishu Bitable record deep link: uses query parameters
|
||||||
// Returns nil for malformed inputs (e.g. config containing spaces or
|
URL(string: "https://\(baseDomain)/base/\(appToken)?table=\(tableId)&record=\(recordId)")!
|
||||||
// slashes) instead of force-unwrapping and crashing.
|
|
||||||
var components = URLComponents()
|
|
||||||
components.scheme = "https"
|
|
||||||
components.host = baseDomain
|
|
||||||
components.path = "/base/\(appToken)"
|
|
||||||
components.queryItems = [
|
|
||||||
URLQueryItem(name: "table", value: tableId),
|
|
||||||
URLQueryItem(name: "record", value: recordId)
|
|
||||||
]
|
|
||||||
return components.url
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func tableURL(baseDomain: String, appToken: String) -> URL? {
|
static func tableURL(baseDomain: String, appToken: String) -> URL {
|
||||||
var components = URLComponents()
|
URL(string: "https://\(baseDomain)/base/\(appToken)")!
|
||||||
components.scheme = "https"
|
|
||||||
components.host = baseDomain
|
|
||||||
components.path = "/base/\(appToken)"
|
|
||||||
return components.url
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,9 @@ import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
final class FloatingWidgetWindow: NSPanel {
|
final class FloatingWidgetWindow: NSPanel {
|
||||||
private let margin: CGFloat = 16
|
|
||||||
private let bottomOffset: CGFloat = 200
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
super.init(
|
super.init(
|
||||||
contentRect: NSRect(x: 0, y: 0, width: 1, height: 1),
|
contentRect: NSRect(x: 0, y: 0, width: 140, height: 70),
|
||||||
styleMask: [.borderless, .nonactivatingPanel],
|
styleMask: [.borderless, .nonactivatingPanel],
|
||||||
backing: .buffered,
|
backing: .buffered,
|
||||||
defer: false
|
defer: false
|
||||||
|
|
@ -23,63 +20,20 @@ final class FloatingWidgetWindow: NSPanel {
|
||||||
hidesOnDeactivate = false
|
hidesOnDeactivate = false
|
||||||
animationBehavior = .none
|
animationBehavior = .none
|
||||||
|
|
||||||
// Place the 1x1 placeholder in the bottom-right corner so the first
|
|
||||||
// content measurement anchors to the screen's right edge.
|
|
||||||
if let screen = NSScreen.main {
|
if let screen = NSScreen.main {
|
||||||
let screenFrame = screen.visibleFrame
|
let screenFrame = screen.visibleFrame
|
||||||
setFrameOrigin(NSPoint(
|
setFrameOrigin(NSPoint(x: screenFrame.maxX - 100, y: screenFrame.minY + 200))
|
||||||
x: screenFrame.maxX - 1 - margin,
|
|
||||||
y: screenFrame.minY + bottomOffset
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let rootView = FloatingWidgetView { [weak self] size in
|
contentView = NSHostingView(rootView: FloatingWidgetView())
|
||||||
self?.applyContentSize(size)
|
|
||||||
}
|
|
||||||
let hostingView = NSHostingView(rootView: rootView)
|
|
||||||
contentView = hostingView
|
|
||||||
|
|
||||||
// Size to the content's intrinsic size up front to avoid a 1x1 flash
|
|
||||||
// before the first SwiftUI layout pass reports a measurement.
|
|
||||||
applyContentSize(hostingView.fittingSize)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override var canBecomeKey: Bool { false }
|
override var canBecomeKey: Bool { false }
|
||||||
override var canBecomeMain: Bool { false }
|
override var canBecomeMain: Bool { false }
|
||||||
|
|
||||||
/// Resizes the window to fit its content while keeping the bottom-right
|
|
||||||
/// corner anchored, so a growing bug count extends leftward instead of
|
|
||||||
/// spilling off-screen. Clamped to the visible screen frame as a safety
|
|
||||||
/// net (e.g. when the user has dragged the widget toward an edge).
|
|
||||||
private func applyContentSize(_ size: CGSize) {
|
|
||||||
guard size.width > 0, size.height > 0 else { return }
|
|
||||||
// Skip no-op resizes to avoid a layout feedback loop with SwiftUI.
|
|
||||||
guard abs(size.width - frame.width) > 0.5
|
|
||||||
|| abs(size.height - frame.height) > 0.5 else { return }
|
|
||||||
|
|
||||||
var newFrame = frame
|
|
||||||
// Keep the right edge: grow leftward as the count widens.
|
|
||||||
newFrame.origin.x = newFrame.maxX - size.width
|
|
||||||
// Keep the bottom edge: grow upward when the priority row appears.
|
|
||||||
newFrame.origin.y = newFrame.minY
|
|
||||||
newFrame.size = size
|
|
||||||
|
|
||||||
if let screen = NSScreen.main {
|
|
||||||
let visible = screen.visibleFrame
|
|
||||||
newFrame.origin.x = max(newFrame.origin.x, visible.minX)
|
|
||||||
newFrame.origin.y = min(
|
|
||||||
max(newFrame.origin.y, visible.minY),
|
|
||||||
visible.maxY - size.height
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
setFrame(newFrame, display: true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FloatingWidgetView: View {
|
struct FloatingWidgetView: View {
|
||||||
@State private var bugStore = BugStore.shared
|
@State private var bugStore = BugStore.shared
|
||||||
let onSizeChange: (CGSize) -> Void
|
|
||||||
|
|
||||||
private var priorityCounts: [(BugPriority, Int)] {
|
private var priorityCounts: [(BugPriority, Int)] {
|
||||||
let active = bugStore.activeBugs
|
let active = bugStore.activeBugs
|
||||||
|
|
@ -102,7 +56,6 @@ struct FloatingWidgetView: View {
|
||||||
Text("\(bugStore.activeBugs.count)")
|
Text("\(bugStore.activeBugs.count)")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.fontWeight(.bold)
|
.fontWeight(.bold)
|
||||||
.monospacedDigit()
|
|
||||||
}
|
}
|
||||||
if !priorityCounts.isEmpty {
|
if !priorityCounts.isEmpty {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
|
|
@ -114,7 +67,6 @@ struct FloatingWidgetView: View {
|
||||||
Text("\(count)")
|
Text("\(count)")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.monospacedDigit()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -129,17 +81,5 @@ struct FloatingWidgetView: View {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
// Let the button take its intrinsic size so the window can fit it
|
|
||||||
// exactly, regardless of the (initially 1x1) hosting container.
|
|
||||||
.fixedSize()
|
|
||||||
.background(
|
|
||||||
GeometryReader { proxy in
|
|
||||||
Color.clear
|
|
||||||
.onAppear { onSizeChange(proxy.size) }
|
|
||||||
.onChange(of: proxy.size) { _, newSize in
|
|
||||||
onSizeChange(newSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import SwiftUI
|
||||||
struct BugListPopover: View {
|
struct BugListPopover: View {
|
||||||
@State private var bugStore = BugStore.shared
|
@State private var bugStore = BugStore.shared
|
||||||
@State private var tokenManager = TokenManager.shared
|
@State private var tokenManager = TokenManager.shared
|
||||||
@Environment(\.openSettings) private var openSettings
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
|
@ -28,21 +27,11 @@ struct BugListPopover: View {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.scaleEffect(0.7)
|
.scaleEffect(0.7)
|
||||||
.frame(width: 16, height: 16)
|
.frame(width: 16, height: 16)
|
||||||
} else {
|
}
|
||||||
Image(systemName: "arrow.triangle.2.circlepath")
|
if let lastUpdated = bugStore.lastUpdated {
|
||||||
.font(.caption2)
|
Text(formatted(lastUpdated))
|
||||||
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
if let lastUpdated = bugStore.lastUpdated {
|
|
||||||
TimelineView(.periodic(from: lastUpdated, by: 30)) { _ in
|
|
||||||
Text(formatted(lastUpdated))
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Text("Never")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Button("Mark all seen") {
|
Button("Mark all seen") {
|
||||||
bugStore.markAllSeen()
|
bugStore.markAllSeen()
|
||||||
|
|
@ -91,21 +80,9 @@ struct BugListPopover: View {
|
||||||
Button("Refresh") {
|
Button("Refresh") {
|
||||||
Task { await PollerService.shared.fetchNow() }
|
Task { await PollerService.shared.fetchNow() }
|
||||||
}
|
}
|
||||||
Button {
|
SettingsLink {
|
||||||
openSettings()
|
|
||||||
// Bugger runs as an accessory (no Dock icon), so the Settings
|
|
||||||
// window would otherwise open behind whatever is frontmost.
|
|
||||||
// Activate on the next runloop pass, once the window exists,
|
|
||||||
// to bring it on top.
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
NSRunningApplication.current.activate(options: [.activateAllWindows])
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Settings", systemImage: "gear")
|
Label("Settings", systemImage: "gear")
|
||||||
}
|
}
|
||||||
Button(action: { NSApp.terminate(nil) }) {
|
|
||||||
Label("Quit", systemImage: "power")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
|
|
@ -114,34 +91,23 @@ struct BugListPopover: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openInFeishu(_ bug: Bug) {
|
private func openInFeishu(_ bug: Bug) {
|
||||||
guard let url = bug.feishuURL else { return }
|
NSWorkspace.shared.open(bug.feishuURL)
|
||||||
NSWorkspace.shared.open(url)
|
|
||||||
AppDelegate.shared?.closePopover()
|
AppDelegate.shared?.closePopover()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openFeishuTable() {
|
private func openFeishuTable() {
|
||||||
guard let config = AppStateService.shared.config,
|
guard let config = AppStateService.shared.config else { return }
|
||||||
let url = FeishuURLBuilder.tableURL(
|
let url = FeishuURLBuilder.tableURL(
|
||||||
baseDomain: config.feishuBaseDomain,
|
baseDomain: config.feishuBaseDomain,
|
||||||
appToken: config.appToken
|
appToken: config.appToken
|
||||||
) else { return }
|
)
|
||||||
NSWorkspace.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatted(_ date: Date) -> String {
|
private func formatted(_ date: Date) -> String {
|
||||||
let seconds = Int(Date().timeIntervalSince(date))
|
let formatter = RelativeDateTimeFormatter()
|
||||||
switch seconds {
|
formatter.unitsStyle = .abbreviated
|
||||||
case ..<60: return "Just now"
|
return formatter.localizedString(for: date, relativeTo: Date())
|
||||||
case ..<3600:
|
|
||||||
let m = seconds / 60
|
|
||||||
return m == 1 ? "1m ago" : "\(m)m ago"
|
|
||||||
case ..<86400:
|
|
||||||
let h = seconds / 3600
|
|
||||||
return h == 1 ? "1h ago" : "\(h)h ago"
|
|
||||||
default:
|
|
||||||
let d = seconds / 86400
|
|
||||||
return d == 1 ? "1d ago" : "\(d)d ago"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,11 +142,6 @@ struct ErrorStateView: View {
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
Button("Retry") {
|
|
||||||
Task { await PollerService.shared.fetchNow() }
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.padding(.top, 4)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.padding()
|
.padding()
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,6 @@ struct BugRow: View {
|
||||||
}
|
}
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
StatusPill(status: bug.status)
|
StatusPill(status: bug.status)
|
||||||
if let customer = bug.customer {
|
|
||||||
Text(customer)
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.blue.opacity(0.8))
|
|
||||||
}
|
|
||||||
Text(ageString(bug.age))
|
Text(ageString(bug.age))
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
import ServiceManagement
|
import ServiceManagement
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - Constants
|
|
||||||
|
|
||||||
private let kLabelWidth: CGFloat = 130
|
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@State private var config: AppConfig
|
@State private var config: AppConfig
|
||||||
@State private var isTestingConnection = false
|
@State private var isTestingConnection = false
|
||||||
|
|
@ -17,84 +13,45 @@ struct SettingsView: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
Form {
|
||||||
// MARK: Your Name card
|
Section("Feishu Bitable") {
|
||||||
yourNameCard
|
TextField("App Token (from Bitable URL)", text: $config.appToken)
|
||||||
|
TextField("Table ID", text: $config.tableId)
|
||||||
// MARK: Feishu Bitable
|
TextField("Your Name (as in Assignee column, optional)", text: $config.assigneeName)
|
||||||
settingsSection("Feishu Bitable") {
|
TextField("Feishu domain", text: $config.feishuBaseDomain)
|
||||||
labeledRow("App Token") {
|
|
||||||
TextField("from Bitable URL", text: $config.appToken)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
}
|
|
||||||
labeledRow("Table ID") {
|
|
||||||
TextField("", text: $config.tableId)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
}
|
|
||||||
labeledRow("Feishu domain") {
|
|
||||||
TextField("e.g. feishu.cn", text: $config.feishuBaseDomain)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
}
|
|
||||||
connectionTestRow
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Polling
|
|
||||||
settingsSection("Polling") {
|
|
||||||
labeledRow("Check every") {
|
|
||||||
Picker("", selection: $config.pollIntervalSeconds) {
|
|
||||||
Text("1 minute").tag(60)
|
|
||||||
Text("5 minutes").tag(300)
|
|
||||||
Text("10 minutes").tag(600)
|
|
||||||
Text("30 minutes").tag(1_800)
|
|
||||||
Text("1 hour").tag(3_600)
|
|
||||||
Text("2 hours").tag(7_200)
|
|
||||||
Text("4 hours").tag(14_400)
|
|
||||||
Text("8 hours").tag(28_800)
|
|
||||||
Text("12 hours").tag(43_200)
|
|
||||||
Text("1 day").tag(86_400)
|
|
||||||
Divider()
|
|
||||||
Text("Daily schedule").tag(-1)
|
|
||||||
}
|
|
||||||
.labelsHidden()
|
|
||||||
.fixedSize()
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.pollIntervalSeconds == -1 {
|
|
||||||
dailyScheduleEditor
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Toggle("Refresh on launch", isOn: $config.refreshOnStart)
|
HStack(spacing: 8) {
|
||||||
Text("Fetch bugs immediately when Bugger starts. Off = wait for the next scheduled poll.")
|
Button("Test Connection") {
|
||||||
.font(.caption)
|
testConnection()
|
||||||
.foregroundStyle(.secondary)
|
}
|
||||||
|
.disabled(isTestingConnection || !config.isConfigured)
|
||||||
|
|
||||||
|
if isTestingConnection {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let connectionResult {
|
||||||
|
Text(connectionResult)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(connectionResult.contains("✓") ? .green : .red)
|
||||||
|
.lineLimit(3)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Real-time updates
|
Section("Polling") {
|
||||||
settingsSection("Real-time updates") {
|
Picker("Check every", selection: $config.pollIntervalSeconds) {
|
||||||
labeledRow("Subscribe URL") {
|
Text("1 minute").tag(60)
|
||||||
TextField("e.g. https://example.com", text: $config.feishuAppBaseURL)
|
Text("5 minutes").tag(300)
|
||||||
.textFieldStyle(.roundedBorder)
|
Text("10 minutes").tag(600)
|
||||||
.autocorrectionDisabled()
|
Text("30 minutes").tag(1800)
|
||||||
}
|
}
|
||||||
Text("Base URL of the change-notification service. 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Calibration
|
Section("Display") {
|
||||||
settingsSection("Calibration") {
|
|
||||||
Toggle("Enable calibration check", isOn: $config.calibrationEnabled)
|
|
||||||
Text("Periodically verifies with the notification server that all your assigned bugs are in sync. Helps recover missed push notifications. Only works when a Subscribe base URL is set.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Display
|
|
||||||
settingsSection("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
|
||||||
if enabled {
|
if enabled {
|
||||||
|
|
@ -106,146 +63,17 @@ struct SettingsView: View {
|
||||||
Toggle("Launch at login", isOn: $config.launchAtLogin)
|
Toggle("Launch at login", isOn: $config.launchAtLogin)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Advanced
|
Section("Field Mappings") {
|
||||||
advancedSection
|
TextField("Title field", text: $config.fieldMappings.titleField)
|
||||||
|
TextField("Priority field", text: $config.fieldMappings.priorityField)
|
||||||
// MARK: Actions
|
TextField("Status field", text: $config.fieldMappings.statusField)
|
||||||
actionButtons
|
TextField("Assignee field", text: $config.fieldMappings.assigneeField)
|
||||||
}
|
TextField("Reporter field", text: $config.fieldMappings.reporterField)
|
||||||
.frame(maxWidth: 700, alignment: .leading)
|
TextField("Created At field", text: $config.fieldMappings.createdAtField)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
TextField("Updated At field", text: $config.fieldMappings.updatedAtField)
|
||||||
.padding(20)
|
|
||||||
}
|
|
||||||
.frame(minWidth: 520, idealWidth: 560, maxWidth: .infinity,
|
|
||||||
minHeight: 400, idealHeight: 600, maxHeight: .infinity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Your Name Card
|
|
||||||
|
|
||||||
private var yourNameCard: some View {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Image(systemName: "person.fill")
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundStyle(.tint)
|
|
||||||
Text("Your Name")
|
|
||||||
.font(.headline)
|
|
||||||
TextField("e.g. Alice", text: $config.assigneeName)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
}
|
|
||||||
.padding(10)
|
|
||||||
.background(RoundedRectangle(cornerRadius: 8).fill(.tint.opacity(0.08)))
|
|
||||||
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(.tint.opacity(0.35), lineWidth: 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Connection Test
|
|
||||||
|
|
||||||
private var connectionTestRow: some View {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Button("Test Connection") {
|
|
||||||
testConnection()
|
|
||||||
}
|
|
||||||
.disabled(isTestingConnection || !config.isConfigured)
|
|
||||||
|
|
||||||
if isTestingConnection {
|
|
||||||
ProgressView()
|
|
||||||
.scaleEffect(0.7)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let connectionResult {
|
|
||||||
Text(connectionResult)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(connectionResult.contains("✓") ? .green : .red)
|
|
||||||
.lineLimit(3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Daily Schedule Editor
|
|
||||||
|
|
||||||
private var dailyScheduleEditor: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text("Refresh at these times:")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
ForEach(dailyTimeBindings.indices, id: \.self) { index in
|
|
||||||
HStack {
|
|
||||||
DatePicker("", selection: dailyTimeBindings[index],
|
|
||||||
displayedComponents: .hourAndMinute)
|
|
||||||
.labelsHidden()
|
|
||||||
|
|
||||||
Button(action: { removeDailyTime(at: index) }) {
|
|
||||||
Image(systemName: "minus.circle.fill")
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.disabled(config.dailyRefreshTimes.count <= 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: addDailyTime) {
|
|
||||||
Label("Add time", systemImage: "plus.circle")
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
.padding(.leading, kLabelWidth + 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Advanced Section
|
|
||||||
|
|
||||||
private var advancedSection: some View {
|
|
||||||
DisclosureGroup(isExpanded: $showAdvanced) {
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
// Field Mappings
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("Field Mappings")
|
|
||||||
.font(.subheadline)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
labeledRow("Title field") {
|
|
||||||
TextField("", text: $config.fieldMappings.titleField)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
}
|
|
||||||
labeledRow("Priority field") {
|
|
||||||
TextField("", text: $config.fieldMappings.priorityField)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
}
|
|
||||||
labeledRow("Status field") {
|
|
||||||
TextField("", text: $config.fieldMappings.statusField)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
}
|
|
||||||
labeledRow("Assignee field") {
|
|
||||||
TextField("", text: $config.fieldMappings.assigneeField)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
}
|
|
||||||
labeledRow("Reporter field") {
|
|
||||||
TextField("", text: $config.fieldMappings.reporterField)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
}
|
|
||||||
labeledRow("Customer field") {
|
|
||||||
TextField("", text: $config.fieldMappings.customerField)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
}
|
|
||||||
labeledRow("Created At field") {
|
|
||||||
TextField("", text: $config.fieldMappings.createdAtField)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
}
|
|
||||||
labeledRow("Updated At field") {
|
|
||||||
TextField("", text: $config.fieldMappings.updatedAtField)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
Section("Status Mappings") {
|
||||||
|
|
||||||
// Status Mappings
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("Status Mappings")
|
|
||||||
.font(.subheadline)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text("Map your Feishu status values to standard Bugger statuses.")
|
Text("Map your Feishu status values to standard Bugger statuses.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
@ -254,59 +82,23 @@ struct SettingsView: View {
|
||||||
statusMappingRow(for: status)
|
statusMappingRow(for: status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button("Save") {
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.return)
|
||||||
|
|
||||||
|
Button("Disconnect Feishu") {
|
||||||
|
TokenManager.shared.clearTokens()
|
||||||
|
PollerService.shared.stop()
|
||||||
|
}
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.top, 8)
|
.padding()
|
||||||
} label: {
|
|
||||||
Label("Advanced: field & status mappings", systemImage: "slider.horizontal.3")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Action Buttons
|
|
||||||
|
|
||||||
private var actionButtons: some View {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Button("Save") {
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
.keyboardShortcut(.return)
|
|
||||||
|
|
||||||
Button("Disconnect Feishu") {
|
|
||||||
TokenManager.shared.clearTokens()
|
|
||||||
PollerService.shared.stop()
|
|
||||||
BitableEventService.shared.disconnect()
|
|
||||||
CalibrationService.shared.stop()
|
|
||||||
}
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Layout Helpers
|
|
||||||
|
|
||||||
/// A consistently-aligned label–control row.
|
|
||||||
private func labeledRow<Content: View>(
|
|
||||||
_ label: String,
|
|
||||||
@ViewBuilder content: () -> Content
|
|
||||||
) -> some View {
|
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
||||||
Text(label)
|
|
||||||
.frame(width: kLabelWidth, alignment: .trailing)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A section with a left-aligned header and divider.
|
|
||||||
private func settingsSection<Content: View>(
|
|
||||||
_ title: String,
|
|
||||||
@ViewBuilder content: () -> Content
|
|
||||||
) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text(title)
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundStyle(.primary)
|
|
||||||
Divider()
|
|
||||||
content()
|
|
||||||
}
|
}
|
||||||
|
.frame(minWidth: 500, idealWidth: 520, minHeight: 400, maxHeight: 800)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Status Mapping Row
|
// MARK: - Status Mapping Row
|
||||||
|
|
@ -336,8 +128,8 @@ struct SettingsView: View {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return VStack(alignment: .leading, spacing: 4) {
|
return VStack(alignment: .leading, spacing: 2) {
|
||||||
HStack(spacing: 6) {
|
HStack {
|
||||||
StatusPill(status: status)
|
StatusPill(status: status)
|
||||||
Text(status.label)
|
Text(status.label)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
|
@ -350,43 +142,6 @@ struct SettingsView: View {
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Daily times
|
|
||||||
|
|
||||||
/// Reference date for time-only DatePickers
|
|
||||||
private var timeRef: Date {
|
|
||||||
Calendar.current.date(from: DateComponents(year: 2000, month: 1, day: 1))!
|
|
||||||
}
|
|
||||||
|
|
||||||
private var dailyTimeBindings: [Binding<Date>] {
|
|
||||||
config.dailyRefreshTimes.indices.map { index in
|
|
||||||
Binding<Date>(
|
|
||||||
get: {
|
|
||||||
let parts = config.dailyRefreshTimes[index].components(separatedBy: ":")
|
|
||||||
let hour = Int(parts.first ?? "") ?? 0
|
|
||||||
let minute = Int(parts.last ?? "") ?? 0
|
|
||||||
return Calendar.current.date(
|
|
||||||
bySettingHour: hour, minute: minute, second: 0, of: timeRef
|
|
||||||
) ?? timeRef
|
|
||||||
},
|
|
||||||
set: { newDate in
|
|
||||||
let comps = Calendar.current.dateComponents([.hour, .minute], from: newDate)
|
|
||||||
let h = String(format: "%02d", comps.hour ?? 0)
|
|
||||||
let m = String(format: "%02d", comps.minute ?? 0)
|
|
||||||
config.dailyRefreshTimes[index] = "\(h):\(m)"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func addDailyTime() {
|
|
||||||
config.dailyRefreshTimes.append("09:00")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func removeDailyTime(at index: Int) {
|
|
||||||
guard config.dailyRefreshTimes.count > 1 else { return }
|
|
||||||
config.dailyRefreshTimes.remove(at: index)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|
||||||
private func save() {
|
private func save() {
|
||||||
|
|
@ -407,11 +162,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()
|
|
||||||
CalibrationService.shared.restart()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,724 +0,0 @@
|
||||||
# Push Reliability — Design & Implementation Plan
|
|
||||||
|
|
||||||
**Date:** 2026-07-10
|
|
||||||
**Status:** Draft — pending review
|
|
||||||
**Scope:** Bugger (macOS SSE client) + bugger-feishu (k8s SSE server)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Problem Statement
|
|
||||||
|
|
||||||
Push notifications (SSE `change` events from bugger-feishu → Bugger) can be
|
|
||||||
**delayed or missed entirely**. The root cause is unknown — it could be:
|
|
||||||
|
|
||||||
- **Feishu side** failing to deliver WebSocket events to bugger-feishu.
|
|
||||||
- **bugger-feishu** failing to push the SSE event to the client (e.g. connection
|
|
||||||
dropped, queue overflow, server crash).
|
|
||||||
- **Ingress** (k8s nginx / cloud LB) silently closing the long-lived SSE
|
|
||||||
connection before the heartbeat resets the idle timer (see
|
|
||||||
`SSE_TIMEOUT_ANALYSIS.md`).
|
|
||||||
|
|
||||||
The current reconnection strategy (fixed 30s delay, no jitter) compounds the
|
|
||||||
problem: when the ingress drops many clients simultaneously, they all retry at
|
|
||||||
the same instant — a thundering herd that can overwhelm the ingress and prevent
|
|
||||||
any client from reconnecting.
|
|
||||||
|
|
||||||
### Goals
|
|
||||||
|
|
||||||
| # | Goal | Owner |
|
|
||||||
|---|------|-------|
|
|
||||||
| 1 | Reconnect with **exponential backoff + jitter** to avoid retry storms | Bugger |
|
|
||||||
| 2 | bugger-feishu exposes an **owned-records endpoint** so Bugger can calibrate | bugger-feishu |
|
|
||||||
| 3 | Bugger has an **advanced settings toggle** (disabled by default) to enable periodic calibration | Bugger |
|
|
||||||
| 4 | Bugger **optimizes the Feishu query** — fetch only the owner's bugs, not all records | Bugger |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Architecture Overview
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Bugger (macOS) │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────┐ SSE push ┌──────────────────────────────┐ │
|
|
||||||
│ │ BitableEventSvc │ ◀─────────────│ Ingress → bugger-feishu │ │
|
|
||||||
│ │ (exponential │ change │ (SSE server, 30s heartbeat) │ │
|
|
||||||
│ │ backoff+jitter)│ └──────────────────────────────┘ │
|
|
||||||
│ └────────┬─────────┘ │ │
|
|
||||||
│ │ fetchNow() │ GET /owned_records │
|
|
||||||
│ ▼ ▼ │
|
|
||||||
│ ┌──────────────────┐ ┌──────────────────────┐ │
|
|
||||||
│ │ PollerService │ │ CalibrationService │ │
|
|
||||||
│ │ (periodic poll │ │ (optional, every │ │
|
|
||||||
│ │ + fetchNow) │ │ 5 min, compares │ │
|
|
||||||
│ └────────┬─────────┘ │ record IDs) │ │
|
|
||||||
│ │ └──────────┬───────────┘ │
|
|
||||||
│ ▼ │ mismatch? │
|
|
||||||
│ ┌──────────────────┐ │ │
|
|
||||||
│ │ FeishuService │ ◀─────────────────────┘ fetchNow() │
|
|
||||||
│ │ (search records │ │
|
|
||||||
│ │ with filter) │ │
|
|
||||||
│ └──────────────────┘ │
|
|
||||||
└──────────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Data flow for calibration:**
|
|
||||||
1. `CalibrationService` calls `GET {feishuAppBaseURL}/api/v1/bitable/owned_records`
|
|
||||||
2. bugger-feishu returns the record IDs assigned to this user (from its in-memory cache)
|
|
||||||
3. Bugger compares the returned IDs with `BugStore.shared.bugs.map(\.id)`
|
|
||||||
4. On mismatch → triggers `PollerService.shared.fetchNow()` → `FeishuService` fetches
|
|
||||||
only the owner's bugs (server-side filtered) → `BugStore` updates
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Design Details
|
|
||||||
|
|
||||||
### 3.1 Item 1 — Exponential Backoff with Jitter
|
|
||||||
|
|
||||||
**Problem:** `BitableEventService.swift:25` uses a fixed `reconnectDelay = 30`.
|
|
||||||
All clients retry on the same cadence, creating synchronized load spikes.
|
|
||||||
|
|
||||||
**Design:**
|
|
||||||
|
|
||||||
Replace the fixed delay with exponential backoff + decorrelated jitter:
|
|
||||||
|
|
||||||
```
|
|
||||||
attempt 1: delay ≈ 1s (±25% jitter)
|
|
||||||
attempt 2: delay ≈ 2s (±25% jitter)
|
|
||||||
attempt 3: delay ≈ 4s (±25% jitter)
|
|
||||||
attempt 4: delay ≈ 8s (±25% jitter)
|
|
||||||
attempt 5: delay ≈ 16s (±25% jitter)
|
|
||||||
attempt 6+: delay ≈ 30s (±25% jitter) ← cap
|
|
||||||
```
|
|
||||||
|
|
||||||
**Formula:**
|
|
||||||
```
|
|
||||||
base = 1.0 (seconds)
|
|
||||||
factor = 2.0
|
|
||||||
cap = 30.0 (seconds) — preserves current steady-state behavior
|
|
||||||
jitter = ±25% (random uniform in [0.75, 1.25])
|
|
||||||
|
|
||||||
raw_delay = min(cap, base * factor^(attempt - 1))
|
|
||||||
delay = raw_delay * Double.random(in: 0.75...1.25)
|
|
||||||
```
|
|
||||||
|
|
||||||
**State management:**
|
|
||||||
- `reconnectAttempt: Int = 0` — incremented on each `handleStreamEnd`, reset to 0
|
|
||||||
when a `connected` event is received from the server.
|
|
||||||
- The attempt counter is an instance variable on `BitableEventService`, mutated
|
|
||||||
only on the main queue (all callbacks are already routed to main).
|
|
||||||
|
|
||||||
**Reset logic:**
|
|
||||||
In `handleEvent`, when event == `"connected"`:
|
|
||||||
```swift
|
|
||||||
reconnectAttempt = 0
|
|
||||||
```
|
|
||||||
This ensures the backoff de-escalates as soon as the connection is healthy.
|
|
||||||
|
|
||||||
**No catch-up fetch on reconnect (unchanged):**
|
|
||||||
The existing `handleEvent` already calls `PollerService.shared.fetchNow()` on
|
|
||||||
`change` events. The calibration service (item 3) provides the safety net for
|
|
||||||
missed events, so we do not add an explicit catch-up fetch on reconnect.
|
|
||||||
|
|
||||||
**Changes:**
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `bugger/Sources/Services/BitableEventService.swift` | Replace `reconnectDelay` constant with backoff logic; add `reconnectAttempt` counter; reset on `connected` event |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.2 Item 2 — bugger-feishu Owned-Records Endpoint
|
|
||||||
|
|
||||||
**Problem:** Bugger has no way to verify it has received all change pushes.
|
|
||||||
If a push is missed (SSE connection down, Feishu WS event lost), the bug list
|
|
||||||
is stale until the next periodic poll.
|
|
||||||
|
|
||||||
**Design:**
|
|
||||||
|
|
||||||
New endpoint on bugger-feishu:
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/v1/bitable/owned_records
|
|
||||||
?file_token={app_token}
|
|
||||||
&assignee_field={field_name}
|
|
||||||
&assignee_name={user_name}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response (200):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"file_token": "bascnXXX",
|
|
||||||
"assignee_name": "张三",
|
|
||||||
"assignee_field": "负责人",
|
|
||||||
"record_ids": ["recAAA", "recBBB", "recCCC"],
|
|
||||||
"count": 3,
|
|
||||||
"cache_status": "warm",
|
|
||||||
"server_time": 1720000000
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**`cache_status` values:**
|
|
||||||
| Value | Meaning | Bugger action |
|
|
||||||
|-------|---------|---------------|
|
|
||||||
| `"warm"` | Cache has been populated (WS events received or full pull done) | Compare IDs, calibrate on mismatch |
|
|
||||||
| `"cold"` | Cache is empty — no WS events received yet, no full pull done | **Skip calibration** — would cause false positives |
|
|
||||||
| `"stale"` | Cache exists but hasn't been refreshed recently (> 1h since last update) | Compare IDs, but log a warning |
|
|
||||||
|
|
||||||
**Implementation on bugger-feishu:**
|
|
||||||
|
|
||||||
The server already maintains `self._cache: Dict[str, Dict[str, Dict[str, str]]]`
|
|
||||||
(file_token → assignee_field → record_id → assignee_value) in
|
|
||||||
`BitableEventService`. The endpoint reads from this cache — **no Feishu API call**
|
|
||||||
is needed, making it fast and cheap.
|
|
||||||
|
|
||||||
```python
|
|
||||||
# bitable_event_service.py — new method
|
|
||||||
|
|
||||||
def get_owned_record_ids(
|
|
||||||
self, file_token: str, assignee_field: str, assignee_name: str
|
|
||||||
) -> dict:
|
|
||||||
"""Return the record IDs assigned to a user, from the in-memory cache."""
|
|
||||||
file_cache = self._cache.get(file_token, {})
|
|
||||||
field_cache = file_cache.get(assignee_field, {})
|
|
||||||
|
|
||||||
if not field_cache:
|
|
||||||
# Check if we've ever seen this file_token at all
|
|
||||||
if file_token not in self._cache:
|
|
||||||
return {"record_ids": [], "cache_status": "cold", "count": 0}
|
|
||||||
return {"record_ids": [], "cache_status": "cold", "count": 0}
|
|
||||||
|
|
||||||
# Check staleness: if no table_id learned yet, cache may be incomplete
|
|
||||||
has_table_id = file_token in self._table_ids
|
|
||||||
|
|
||||||
owned = [
|
|
||||||
rid for rid, assignee in field_cache.items()
|
|
||||||
if assignee == assignee_name
|
|
||||||
]
|
|
||||||
status = "warm" if has_table_id else "stale"
|
|
||||||
return {
|
|
||||||
"record_ids": owned,
|
|
||||||
"cache_status": status,
|
|
||||||
"count": len(owned),
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```python
|
|
||||||
# bitable_subscription.py — new route
|
|
||||||
|
|
||||||
@router.get("/owned_records")
|
|
||||||
async def owned_records(
|
|
||||||
file_token: str = Query(...),
|
|
||||||
assignee_field: str = Query(...),
|
|
||||||
assignee_name: str = Query(...),
|
|
||||||
):
|
|
||||||
service = getattr(request.app.state, "bitable_event_service", None)
|
|
||||||
if service is None:
|
|
||||||
return {"record_ids": [], "cache_status": "cold", "count": 0}
|
|
||||||
|
|
||||||
result = service.get_owned_record_ids(file_token, assignee_field, assignee_name)
|
|
||||||
result["file_token"] = file_token
|
|
||||||
result["assignee_name"] = assignee_name
|
|
||||||
result["assignee_field"] = assignee_field
|
|
||||||
result["server_time"] = int(time.time())
|
|
||||||
return result
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why read from cache, not live Feishu API?**
|
|
||||||
- The cache is already maintained and reconciled every 24h by the server.
|
|
||||||
- A live API call per calibration request (every 5 min × N clients) would be
|
|
||||||
expensive and rate-limited.
|
|
||||||
- The cache is "good enough" for calibration — it catches missed pushes, which
|
|
||||||
is the goal. The periodic reconciliation task (every 24h) ensures the cache
|
|
||||||
stays eventually consistent.
|
|
||||||
|
|
||||||
**Changes:**
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `bugger-feishu/app/services/bitable_event_service.py` | Add `get_owned_record_ids()` method |
|
|
||||||
| `bugger-feishu/app/api/bitable_subscription.py` | Add `GET /owned_records` route |
|
|
||||||
| `bugger-feishu/tests/test_bitable_change_notification.py` | Add unit tests for the endpoint |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.3 Item 3 — Bugger Calibration Service + Advanced Settings Toggle
|
|
||||||
|
|
||||||
**Problem:** Even with backoff, the SSE connection may be unreliable (ingress
|
|
||||||
issues, server restarts). A periodic calibration check provides a safety net.
|
|
||||||
|
|
||||||
**Design:**
|
|
||||||
|
|
||||||
#### 3.3.1 AppConfig — new field
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// AppConfig.swift
|
|
||||||
/// When true, Bugger periodically queries bugger-feishu for the list of
|
|
||||||
/// record IDs assigned to the current user and triggers a fetchNow() if
|
|
||||||
/// the list doesn't match the bugs in BugStore. Disabled by default —
|
|
||||||
/// only useful when feishuAppBaseURL is set and push reliability is a
|
|
||||||
/// concern.
|
|
||||||
var calibrationEnabled: Bool = false
|
|
||||||
```
|
|
||||||
|
|
||||||
- Default: `false` (disabled)
|
|
||||||
- Only meaningful when `feishuAppBaseURL` is set (the calibration endpoint
|
|
||||||
lives on the same server)
|
|
||||||
- Tolerant decoding (like all other AppConfig fields) so existing persisted
|
|
||||||
configs don't break
|
|
||||||
|
|
||||||
#### 3.3.2 CalibrationService — new service
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// Sources/Services/CalibrationService.swift
|
|
||||||
|
|
||||||
final class CalibrationService {
|
|
||||||
static let shared = CalibrationService()
|
|
||||||
|
|
||||||
private var timer: Timer?
|
|
||||||
private let interval: TimeInterval = 300 // 5 minutes
|
|
||||||
private var isRunning = false
|
|
||||||
private let session = URLSession.shared
|
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
func start() {
|
|
||||||
guard !isRunning else { return }
|
|
||||||
guard let config = AppStateService.shared.config,
|
|
||||||
config.calibrationEnabled,
|
|
||||||
!config.feishuAppBaseURL.isEmpty else { return }
|
|
||||||
isRunning = true
|
|
||||||
|
|
||||||
// First check after 60s (let the initial fetch + SSE connect settle)
|
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
|
||||||
Task { await self?.calibrate() }
|
|
||||||
}
|
|
||||||
timer?.tolerance = 30
|
|
||||||
// Delay the first run
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 60) { [weak self] in
|
|
||||||
Task { await self?.calibrate() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
timer?.invalidate()
|
|
||||||
timer = nil
|
|
||||||
isRunning = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func restart() {
|
|
||||||
stop()
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func calibrate() async {
|
|
||||||
guard let config = AppStateService.shared.config,
|
|
||||||
!config.feishuAppBaseURL.isEmpty,
|
|
||||||
!config.assigneeName.isEmpty,
|
|
||||||
!config.fieldMappings.assigneeField.isEmpty else { return }
|
|
||||||
|
|
||||||
// Build URL: {feishuAppBaseURL}/api/v1/bitable/owned_records?...
|
|
||||||
guard let url = buildURL(config: config) else { return }
|
|
||||||
|
|
||||||
do {
|
|
||||||
let (data, response) = try await session.data(from: url)
|
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return }
|
|
||||||
|
|
||||||
let result = try JSONDecoder().decode(OwnedRecordsResponse.self, from: data)
|
|
||||||
|
|
||||||
// Skip if cache is cold — would cause false positives
|
|
||||||
guard result.cacheStatus == "warm" || result.cacheStatus == "stale" else { return }
|
|
||||||
|
|
||||||
let serverIDs = Set(result.recordIds)
|
|
||||||
let localIDs = Set(BugStore.shared.bugs.map(\.id))
|
|
||||||
|
|
||||||
// Only calibrate if we have local data (skip on fresh launch
|
|
||||||
// before the first fetch completes)
|
|
||||||
guard !localIDs.isEmpty else { return }
|
|
||||||
|
|
||||||
let mismatch = serverIDs != localIDs
|
|
||||||
if mismatch {
|
|
||||||
BuggerLog.info("CalibrationService: mismatch detected (server=\(serverIDs.count), local=\(localIDs.count)), triggering fetchNow")
|
|
||||||
await PollerService.shared.fetchNow()
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
BuggerLog.error("CalibrationService: error — \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func buildURL(config: AppConfig) -> URL? {
|
|
||||||
let base = config.feishuAppBaseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
||||||
guard var comps = URLComponents(string: "\(base)/api/v1/bitable/owned_records") else { return nil }
|
|
||||||
comps.queryItems = [
|
|
||||||
URLQueryItem(name: "file_token", value: config.appToken),
|
|
||||||
URLQueryItem(name: "assignee_field", value: config.fieldMappings.assigneeField),
|
|
||||||
URLQueryItem(name: "assignee_name", value: config.assigneeName),
|
|
||||||
]
|
|
||||||
return comps.url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Response model
|
|
||||||
struct OwnedRecordsResponse: Decodable {
|
|
||||||
let recordIds: [String]
|
|
||||||
let cacheStatus: String
|
|
||||||
let count: Int
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case recordIds = "record_ids"
|
|
||||||
case cacheStatus = "cache_status"
|
|
||||||
case count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.3.3 Settings UI — advanced section
|
|
||||||
|
|
||||||
In `SettingsView.swift`, inside the existing `DisclosureGroup` for Advanced
|
|
||||||
options, add a new section before Field Mappings:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// Advanced section — calibration toggle
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Toggle("Enable calibration check", isOn: $config.calibrationEnabled)
|
|
||||||
Text("Periodically verifies with the notification server that all your assigned bugs are in sync. Helps recover missed push notifications. Only works when a Subscribe base URL is set.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.3.4 Lifecycle wiring
|
|
||||||
|
|
||||||
| Location | Action |
|
|
||||||
|----------|--------|
|
|
||||||
| `AppDelegate.applicationDidFinishLaunching` | After `BitableEventService.shared.connect()`, add `CalibrationService.shared.start()` |
|
|
||||||
| `SettingsView.save()` | After `BitableEventService.shared.reconnect()`, add `CalibrationService.shared.restart()` |
|
|
||||||
| `SettingsView` Disconnect button | Add `CalibrationService.shared.stop()` |
|
|
||||||
|
|
||||||
**Changes:**
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `bugger/Sources/Models/AppConfig.swift` | Add `calibrationEnabled` field + CodingKey + tolerant decode |
|
|
||||||
| `bugger/Sources/Services/CalibrationService.swift` | **New file** — calibration service |
|
|
||||||
| `bugger/Sources/Views/Settings/SettingsView.swift` | Add toggle in Advanced section; wire lifecycle in `save()` and Disconnect |
|
|
||||||
| `bugger/Sources/AppDelegate.swift` | Start calibration on launch |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.4 Item 4 — Optimize Feishu Query (Filter by Assignee)
|
|
||||||
|
|
||||||
**Problem:** `FeishuService.fetchBugs()` fetches **ALL** records from the Bitable
|
|
||||||
table (up to 25,000, paginated 500/page = 50 API calls), then filters
|
|
||||||
client-side by assignee name. For large tables, this is slow and wasteful.
|
|
||||||
|
|
||||||
**Feishu API capabilities (verified from official docs):**
|
|
||||||
|
|
||||||
Two approaches are available:
|
|
||||||
|
|
||||||
| Approach | Endpoint | Filter format | Person field support |
|
|
||||||
|----------|----------|---------------|---------------------|
|
|
||||||
| **A. List records** | `GET .../records` | `filter` query param: `CurrentValue.[FieldName]="value"` | Unreliable for Person fields — test code already notes it may not work |
|
|
||||||
| **B. Search records** | `POST .../records/search` | JSON body with structured `filter` object | **Supported** — Person fields require `open_id` (user ID), not display name |
|
|
||||||
|
|
||||||
**Key constraint (from Feishu filter guide):**
|
|
||||||
> Person field value: `["ou_9a971ded01b4ca66f4798549878abcef"]` — Fill in the
|
|
||||||
> corresponding user **ID**. The user ID type must match the `user_id_type`
|
|
||||||
> parameter (default: `open_id`).
|
|
||||||
|
|
||||||
This means filtering by Person fields requires the user's `open_id`, which is
|
|
||||||
only available if the user authenticated via OAuth. If the user entered their
|
|
||||||
name manually in Settings (no OAuth), we cannot filter Person fields server-side.
|
|
||||||
|
|
||||||
**Chosen strategy — multi-tier with fallback:**
|
|
||||||
|
|
||||||
```
|
|
||||||
1. If user's open_id is available (from OAuth):
|
|
||||||
→ Use search endpoint with filter: {field_name, operator: "is", value: [open_id]}
|
|
||||||
→ Works for Person-type assignee fields
|
|
||||||
|
|
||||||
2. If open_id not available OR search returns error/0 results:
|
|
||||||
→ Use list endpoint with filter: CurrentValue.[assigneeField]="assigneeName"
|
|
||||||
→ Works for text-type assignee fields
|
|
||||||
|
|
||||||
3. If both fail:
|
|
||||||
→ Fall back to current unfiltered list endpoint (fetch all, filter client-side)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Additionally — `field_names` optimization (applies to all tiers):**
|
|
||||||
|
|
||||||
Both list and search endpoints support requesting only specific fields. By
|
|
||||||
passing only the fields Bugger needs (title, priority, status, assignee, reporter,
|
|
||||||
customer, created_at, updated_at), we reduce the per-record payload significantly
|
|
||||||
— especially in tables with many columns or large text/attachment fields.
|
|
||||||
|
|
||||||
#### 3.4.1 Capture user's open_id during OAuth
|
|
||||||
|
|
||||||
Extend `UserInfoData` to include `openId`:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// FeishuModels.swift
|
|
||||||
struct UserInfoData: Decodable {
|
|
||||||
let name: String?
|
|
||||||
let enName: String?
|
|
||||||
let openId: String? // NEW — needed for server-side Person field filtering
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Extend `TokenManager` to cache the open_id:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// TokenManager.swift
|
|
||||||
private let openIdKey = "feishu.open_id"
|
|
||||||
|
|
||||||
var cachedOpenId: String? {
|
|
||||||
keychain.read(openIdKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// In handleCallback() and refreshAccessToken() — after getting user info:
|
|
||||||
// Store open_id alongside tokens
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.4.2 New FeishuService method — search records with filter
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// FeishuService.swift
|
|
||||||
|
|
||||||
func fetchBugs(
|
|
||||||
config: AppConfig,
|
|
||||||
assigneeName: String,
|
|
||||||
accessToken: String,
|
|
||||||
userOpenId: String? = nil // NEW parameter
|
|
||||||
) async throws -> [Bug] {
|
|
||||||
// Try filtered search first, fall back to unfiltered list
|
|
||||||
let records = try await fetchRecordsFiltered(
|
|
||||||
config: config, assigneeName: assigneeName,
|
|
||||||
accessToken: accessToken, userOpenId: userOpenId
|
|
||||||
)
|
|
||||||
return records.map { BugMapper.map($0, config: config) }
|
|
||||||
.filter { bug in bug.assignee.localizedCaseInsensitiveContains(assigneeName) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func fetchRecordsFiltered(
|
|
||||||
config: AppConfig, assigneeName: String,
|
|
||||||
accessToken: String, userOpenId: String?
|
|
||||||
) async throws -> [RecordItem] {
|
|
||||||
// Tier 1: search endpoint with open_id filter (Person fields)
|
|
||||||
if let openId = userOpenId {
|
|
||||||
if let records = try? await searchRecords(
|
|
||||||
config: config, accessToken: accessToken,
|
|
||||||
filterConditions: [
|
|
||||||
["field_name": config.fieldMappings.assigneeField,
|
|
||||||
"operator": "is",
|
|
||||||
"value": [openId]]
|
|
||||||
]
|
|
||||||
), !records.isEmpty {
|
|
||||||
return records
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tier 2: list endpoint with CurrentValue filter (text fields)
|
|
||||||
let filterStr = "CurrentValue.[\(config.fieldMappings.assigneeField)]=\"\(assigneeName)\""
|
|
||||||
if let records = try? await listRecords(
|
|
||||||
config: config, accessToken: accessToken, filter: filterStr
|
|
||||||
), !records.isEmpty {
|
|
||||||
return records
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tier 3: fallback — unfiltered list (current behavior)
|
|
||||||
return try await listRecords(config: config, accessToken: accessToken, filter: nil)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** The client-side `.filter` on `assigneeName` is retained as a safety
|
|
||||||
net even after server-side filtering. It's a no-op if the filter worked
|
|
||||||
correctly, but prevents incorrect records from appearing if the filter is
|
|
||||||
imperfect.
|
|
||||||
|
|
||||||
#### 3.4.3 PollerService — pass open_id to FeishuService
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// PollerService.swift — in performFetch()
|
|
||||||
let openId = TokenManager.shared.cachedOpenId
|
|
||||||
let bugs = try await feishuService.fetchBugs(
|
|
||||||
config: config,
|
|
||||||
assigneeName: assignee,
|
|
||||||
accessToken: token,
|
|
||||||
userOpenId: openId // NEW
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Changes:**
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `bugger/Sources/Services/Feishu/FeishuModels.swift` | Add `openId` to `UserInfoData` |
|
|
||||||
| `bugger/Sources/Services/TokenManager.swift` | Cache `open_id` from OAuth; expose `cachedOpenId` |
|
|
||||||
| `bugger/Sources/Services/Feishu/FeishuService.swift` | Add `searchRecords` method; add `fetchRecordsFiltered` with 3-tier fallback; add `field_names` to list calls; accept `userOpenId` parameter |
|
|
||||||
| `bugger/Sources/Services/PollerService.swift` | Pass `userOpenId` to `fetchBugs` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Ingress Considerations
|
|
||||||
|
|
||||||
The user suspects the ingress plays a role in missed pushes. The code changes
|
|
||||||
above make Bugger **resilient** to ingress issues, but the ingress itself should
|
|
||||||
also be configured correctly (per `SSE_TIMEOUT_ANALYSIS.md` R1):
|
|
||||||
|
|
||||||
| Setting | Required value | Why |
|
|
||||||
|---------|---------------|-----|
|
|
||||||
| `proxy_read_timeout` | ≥ 60s | Must exceed the 30s heartbeat with margin |
|
|
||||||
| `proxy_buffering` | `off` | Prevents heartbeats from being buffered and never sent |
|
|
||||||
| `proxy_send_timeout` | ≥ 60s | Symmetric to read timeout |
|
|
||||||
| Cloud LB idle-timeout | ≥ 60s | Some LBs override ingress settings |
|
|
||||||
|
|
||||||
**How the code changes mitigate ingress issues:**
|
|
||||||
- **Backoff + jitter** → When the ingress drops many SSE connections (e.g. on
|
|
||||||
pod restart), clients reconnect at randomized intervals instead of
|
|
||||||
simultaneously, reducing the load spike that could cause the ingress to reject
|
|
||||||
new connections.
|
|
||||||
- **Calibration service** → Even if the SSE connection is chronically
|
|
||||||
unreliable (ingress kills it every cycle), the 5-minute calibration check
|
|
||||||
ensures the bug list stays eventually consistent.
|
|
||||||
- **Optimized Feishu query** → When calibration triggers a `fetchNow()`, the
|
|
||||||
filtered query is much faster and lighter, reducing the load on both the
|
|
||||||
Feishu API and the client.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Implementation Plan
|
|
||||||
|
|
||||||
### Phase 1 — Bugger: Exponential Backoff (Item 1)
|
|
||||||
|
|
||||||
**Scope:** `BitableEventService.swift` only. No server changes. No new files.
|
|
||||||
|
|
||||||
| Step | Task | File |
|
|
||||||
|------|------|------|
|
|
||||||
| 1.1 | Remove `reconnectDelay` constant; add `reconnectAttempt`, `reconnectBaseDelay`, `reconnectMaxDelay`, `reconnectJitterRange` properties | `BitableEventService.swift` |
|
|
||||||
| 1.2 | Add `computeReconnectDelay() -> TimeInterval` method implementing the backoff + jitter formula | `BitableEventService.swift` |
|
|
||||||
| 1.3 | Update `handleStreamEnd` to call `computeReconnectDelay()` and increment `reconnectAttempt` | `BitableEventService.swift` |
|
|
||||||
| 1.4 | Update `handleEvent` to reset `reconnectAttempt = 0` on `"connected"` event | `BitableEventService.swift` |
|
|
||||||
| 1.5 | Reset `reconnectAttempt = 0` in `closeStream()` (clean disconnect resets the counter) | `BitableEventService.swift` |
|
|
||||||
|
|
||||||
**Testing:** Manual — disconnect Wi-Fi, observe reconnect delays in logs.
|
|
||||||
Verify: 1st retry ~1s, 2nd ~2s, 3rd ~4s... capping at ~30s. Reconnect after
|
|
||||||
recovery resets to 1s.
|
|
||||||
|
|
||||||
### Phase 2 — bugger-feishu: Owned-Records Endpoint (Item 2)
|
|
||||||
|
|
||||||
**Scope:** bugger-feishu server only.
|
|
||||||
|
|
||||||
| Step | Task | File |
|
|
||||||
|------|------|------|
|
|
||||||
| 2.1 | Add `get_owned_record_ids()` method to `BitableEventService` | `bitable_event_service.py` |
|
|
||||||
| 2.2 | Add `GET /owned_records` route to `bitable_subscription.py` | `bitable_subscription.py` |
|
|
||||||
| 2.3 | Add unit tests (mock cache warm/cold/stale states) | `test_bitable_change_notification.py` |
|
|
||||||
|
|
||||||
**Testing:** `pytest bugger-feishu/tests/test_bitable_change_notification.py`
|
|
||||||
|
|
||||||
### Phase 3 — Bugger: Calibration Service + Settings (Item 3)
|
|
||||||
|
|
||||||
**Scope:** Bugger client only. Depends on Phase 2 (endpoint must exist).
|
|
||||||
|
|
||||||
| Step | Task | File |
|
|
||||||
|------|------|------|
|
|
||||||
| 3.1 | Add `calibrationEnabled: Bool = false` to `AppConfig` (field + CodingKey + tolerant decode) | `AppConfig.swift` |
|
|
||||||
| 3.2 | Create `CalibrationService.swift` — timer, `calibrate()`, `OwnedRecordsResponse` model | **New file** |
|
|
||||||
| 3.3 | Add calibration toggle in Advanced section of `SettingsView` | `SettingsView.swift` |
|
|
||||||
| 3.4 | Wire `CalibrationService.shared.start()` in `AppDelegate` | `AppDelegate.swift` |
|
|
||||||
| 3.5 | Wire `CalibrationService.shared.restart()` in `SettingsView.save()` | `SettingsView.swift` |
|
|
||||||
| 3.6 | Wire `CalibrationService.shared.stop()` in Disconnect button | `SettingsView.swift` |
|
|
||||||
|
|
||||||
**Testing:** Manual — enable calibration in Advanced settings, verify the timer
|
|
||||||
fires every 5 min (check logs), verify `fetchNow()` triggers on ID mismatch.
|
|
||||||
|
|
||||||
### Phase 4 — Bugger: Optimized Feishu Query (Item 4)
|
|
||||||
|
|
||||||
**Scope:** Bugger client only. Independent of Phases 2-3.
|
|
||||||
|
|
||||||
| Step | Task | File |
|
|
||||||
|------|------|------|
|
|
||||||
| 4.1 | Add `openId` to `UserInfoData` | `FeishuModels.swift` |
|
|
||||||
| 4.2 | Cache `open_id` in `TokenManager` (store on OAuth callback + token refresh) | `TokenManager.swift` |
|
|
||||||
| 4.3 | Add `searchRecords()` method using `POST .../records/search` with structured filter | `FeishuService.swift` |
|
|
||||||
| 4.4 | Add `fetchRecordsFiltered()` with 3-tier fallback (open_id → name → unfiltered) | `FeishuService.swift` |
|
|
||||||
| 4.5 | Add `field_names` parameter to `listRecords`/`searchRecords` to reduce payload | `FeishuService.swift` |
|
|
||||||
| 4.6 | Update `PollerService.performFetch()` to pass `userOpenId` | `PollerService.swift` |
|
|
||||||
|
|
||||||
**Testing:** Manual — verify filtered query returns correct results (check
|
|
||||||
`BuggerLog.debug` output for which tier was used). Test with both Person-type
|
|
||||||
and text-type assignee fields. Verify fallback works when open_id is unavailable.
|
|
||||||
|
|
||||||
### Phase 5 — Verification & Documentation
|
|
||||||
|
|
||||||
| Step | Task |
|
|
||||||
|------|------|
|
|
||||||
| 5.1 | Build Bugger in Xcode — verify no compiler errors |
|
|
||||||
| 5.2 | Run bugger-feishu tests — `pytest` |
|
|
||||||
| 5.3 | End-to-end test: start bugger-feishu → connect Bugger → kill SSE → verify backoff → verify calibration catches missed push |
|
|
||||||
| 5.4 | Update `SSE_TIMEOUT_ANALYSIS.md` — mark R3 (backoff) as implemented, link to this plan |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Key Code Locations (current state)
|
|
||||||
|
|
||||||
All paths relative to `/Users/tigeren/Dev/aptsell/bugger-boundle/`:
|
|
||||||
|
|
||||||
### Bugger (macOS client)
|
|
||||||
|
|
||||||
| Location | What |
|
|
||||||
|----------|------|
|
|
||||||
| `bugger/Sources/Services/BitableEventService.swift:25` | `reconnectDelay = 30` (fixed — to be replaced with backoff) |
|
|
||||||
| `bugger/Sources/Services/BitableEventService.swift:102-124` | `handleStreamEnd` — reconnect scheduling |
|
|
||||||
| `bugger/Sources/Services/BitableEventService.swift:126-138` | `handleEvent` — `connected` event (no-op — to reset attempt counter) |
|
|
||||||
| `bugger/Sources/Services/BitableEventService.swift:140-165` | `buildURL` — SSE endpoint URL construction |
|
|
||||||
| `bugger/Sources/Services/PollerService.swift:88-126` | `performFetch` — calls `FeishuService.fetchBugs` |
|
|
||||||
| `bugger/Sources/Services/Feishu/FeishuService.swift:15-47` | `fetchBugs` — fetches ALL records, filters client-side |
|
|
||||||
| `bugger/Sources/Services/Feishu/FeishuService.swift:64-119` | `fetchPage` — list records API call |
|
|
||||||
| `bugger/Sources/Services/Feishu/FeishuModels.swift:54-58` | `UserInfoData` — has `name`, `enName` (needs `openId`) |
|
|
||||||
| `bugger/Sources/Services/TokenManager.swift:112-117` | `resolveAssigneeName` — returns name, not open_id |
|
|
||||||
| `bugger/Sources/Models/AppConfig.swift:3-87` | `AppConfig` — needs `calibrationEnabled` field |
|
|
||||||
| `bugger/Sources/Views/Settings/SettingsView.swift:140-170` | Advanced section — needs calibration toggle |
|
|
||||||
| `bugger/Sources/AppDelegate.swift:29-34` | App launch — needs `CalibrationService.start()` |
|
|
||||||
|
|
||||||
### bugger-feishu (SSE server)
|
|
||||||
|
|
||||||
| Location | What |
|
|
||||||
|----------|------|
|
|
||||||
| `bugger-feishu/app/api/bitable_subscription.py:24-94` | SSE `/events` endpoint — model for new `/owned_records` route |
|
|
||||||
| `bugger-feishu/app/services/bitable_event_service.py:49-66` | `BitableEventService` — maintains `_cache` (record_id → assignee) |
|
|
||||||
| `bugger-feishu/app/services/bitable_event_service.py:124-182` | `warm_cache` — full pull that populates the cache |
|
|
||||||
| `bugger-feishu/app/services/bitable_event_service.py:215-263` | `add_connection` / `remove_connection` — SSE lifecycle |
|
|
||||||
| `bugger-feishu/app/services/bitable_event_service.py:460-525` | Reconciliation task — periodic full pull (24h) |
|
|
||||||
| `bugger-feishu/app/main.py:88-91` | Router registration — add `/owned_records` route here |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Risks & Mitigations
|
|
||||||
|
|
||||||
| Risk | Impact | Mitigation |
|
|
||||||
|------|--------|------------|
|
|
||||||
| Feishu search endpoint filter doesn't work for Person fields with open_id | Filtered query returns 0 results → fallback to unfiltered (current behavior) | 3-tier fallback strategy ensures no regression |
|
|
||||||
| Calibration false positives (server cache stale, triggers unnecessary fetchNow) | Extra Feishu API calls every 5 min | `cache_status` check — skip when cold; `localIDs.isEmpty` check — skip on fresh launch; 5-min interval limits impact |
|
|
||||||
| Server cache doesn't include newly assigned records (reconciliation runs every 24h) | Calibration misses recent assignments | The SSE push should catch these; calibration is a safety net, not the primary mechanism. Server reconciliation interval can be reduced if needed. |
|
|
||||||
| Backoff delays push recovery (30s cap vs. current 30s fixed) | Slightly slower first reconnect (1s vs 30s — actually faster!) | Backoff starts at 1s (faster than current 30s), caps at 30s. Net improvement. |
|
|
||||||
| `open_id` not available (user entered name manually, no OAuth) | Can't filter Person fields server-side | Fall back to text filter or unfiltered list. No regression. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Open Questions
|
|
||||||
|
|
||||||
1. **Calibration interval** — 5 min is proposed. Should it be configurable in
|
|
||||||
settings, or fixed? (Recommend: fixed for simplicity; can add a setting
|
|
||||||
later if needed.)
|
|
||||||
|
|
||||||
2. **Server cache freshness** — The server's reconciliation runs every 24h.
|
|
||||||
Should the calibration endpoint trigger a cache refresh if the cache is
|
|
||||||
stale? (Recommend: no — keep the endpoint read-only and cheap. The
|
|
||||||
reconciliation task handles freshness.)
|
|
||||||
|
|
||||||
3. **Person field filter with `contains` operator** — Could we use
|
|
||||||
`operator: "contains"` with the user's name instead of `is` with open_id?
|
|
||||||
The Feishu docs say Person field values must be user IDs, but `contains`
|
|
||||||
might behave differently. (Recommend: test during implementation; if
|
|
||||||
`contains` with name works, it simplifies the flow by not requiring open_id.)
|
|
||||||
|
|
||||||
4. **`field_names` URL encoding** — The list endpoint expects `field_names` as
|
|
||||||
a JSON array string (e.g. `["Title","Status"]`). Need to verify that
|
|
||||||
`URLQueryItem` encodes this correctly for the Feishu API.
|
|
||||||
|
|
@ -1,302 +0,0 @@
|
||||||
# SSE Long-Lived Connection — Timeout & Reconnection Analysis
|
|
||||||
|
|
||||||
**Date:** 2026-07-10
|
|
||||||
**Status:** Analysis complete — recommendations pending implementation
|
|
||||||
**Scope:** Bugger (macOS SSE client) → Ingress → bugger-feishu (k8s pod, SSE server)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TL;DR
|
|
||||||
|
|
||||||
Bugger subscribes to bugger-feishu via a long-lived SSE connection that traverses a
|
|
||||||
Kubernetes ingress. The connection's survival depends on **server-side heartbeats** and
|
|
||||||
the **ingress idle-timeout configuration** — neither of which Bugger can control from the
|
|
||||||
client side.
|
|
||||||
|
|
||||||
| Concern | Finding | Risk |
|
|
||||||
|---|---|---|
|
|
||||||
| Heartbeat | Server emits `: heartbeat\n\n` every ~30s; client relies on it | ✅ if ingress idle-timeout ≥ 60s; ❌ if tighter |
|
|
||||||
| Reconnection | Present, fixed 30s delay, well-guarded | Works, but no backoff/jitter → retry storms |
|
|
||||||
| Exponential backoff | **Not implemented** | Retry storms under sustained outage |
|
|
||||||
|
|
||||||
**Top recommendations:**
|
|
||||||
1. Set ingress `proxy_read_timeout` / LB idle-timeout to **≥ 60s** with `proxy_buffering off`.
|
|
||||||
2. Verify the 30s heartbeat is actually deployed in bugger-feishu (it is only *specified* in
|
|
||||||
this repo's docs, not in shipped code).
|
|
||||||
3. Add exponential backoff with jitter and a cap to `BitableEventService` reconnect logic.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Architecture Under Test
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────┐ SSE (outbound, long-lived) ┌─────────────────────────────┐
|
|
||||||
│ Bugger │ ──────────────────────────────▶ │ Kubernetes Ingress │
|
|
||||||
│ (macOS) │ GET /api/v1/bitable/events │ (idle-timeout = T_ingress) │
|
|
||||||
│ SSE client │ Accept: text/event-stream │ │ │
|
|
||||||
└──────────────┘ │ ▼ │
|
|
||||||
│ ┌────────────────────────┐ │
|
|
||||||
│ │ bugger-feishu pod │ │
|
|
||||||
│ │ (FastAPI SSE server) │ │
|
|
||||||
│ │ heartbeat every 30s │ │
|
|
||||||
│ └────────────────────────┘ │
|
|
||||||
└─────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Bugger** is the SSE *client* (`Sources/Services/BitableEventService.swift`, 227 LOC).
|
|
||||||
- **bugger-feishu** is the SSE *server* — a separate FastAPI service deployed as a k8s pod.
|
|
||||||
- The ingress sits between them and **will close any connection that appears idle** for
|
|
||||||
longer than its configured idle-timeout (`proxy_read_timeout` for nginx ingress, default
|
|
||||||
60s; cloud LBs vary).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Heartbeat Analysis
|
|
||||||
|
|
||||||
### 2.1 Client side — relies on server, sends nothing
|
|
||||||
|
|
||||||
SSE is a one-way protocol (server → client). Bugger's client **cannot keep the connection
|
|
||||||
alive from its end** — it can only receive. It depends entirely on the server periodically
|
|
||||||
sending bytes to reset the ingress's idle timer.
|
|
||||||
|
|
||||||
Client-side idle-timeout configuration (`BitableEventService.swift:61-66`):
|
|
||||||
```swift
|
|
||||||
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
|
|
||||||
```
|
|
||||||
- `timeoutIntervalForRequest = 300` (5 min): the client's own dead-connection detector.
|
|
||||||
Set generously above the 30s server heartbeat so it never fires under normal operation.
|
|
||||||
- `timeoutIntervalForResource = .infinity`: unbounded resource lifetime (correct for a stream).
|
|
||||||
- `waitsForConnectivity = true`: OS waits through transient network gaps rather than failing.
|
|
||||||
|
|
||||||
The client's 300s timeout is **never the bottleneck** — it is the ingress that will drop
|
|
||||||
the connection first.
|
|
||||||
|
|
||||||
### 2.2 Server side — specified, must be verified deployed
|
|
||||||
|
|
||||||
The heartbeat is specified in `docs/BITABLE_CHANGE_NOTIFICATION_IMPL.md:686-693`:
|
|
||||||
```python
|
|
||||||
# Wait for events with heartbeat timeout
|
|
||||||
event = await asyncio.wait_for(conn.queue.get(), timeout=30.0)
|
|
||||||
yield f"event: {event['event']}\ndata: {event['data']}\n\n"
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
# Heartbeat
|
|
||||||
yield ": heartbeat\n\n"
|
|
||||||
```
|
|
||||||
- **Interval: 30 seconds.** Emits an SSE comment frame (`: heartbeat\n\n`) when no real
|
|
||||||
event is queued within 30s.
|
|
||||||
- Response headers include `Connection: keep-alive` and `X-Accel-Buffering: no`
|
|
||||||
(lines 707-711) to prevent buffering proxies from holding the heartbeat.
|
|
||||||
|
|
||||||
⚠️ **This code lives in the separate bugger-feishu repo and is only *specified* in this
|
|
||||||
repo's docs.** Whether it is actually deployed must be verified in the bugger-feishu repo
|
|
||||||
before relying on it.
|
|
||||||
|
|
||||||
### 2.3 Will the heartbeat prevent ingress disconnect?
|
|
||||||
|
|
||||||
The ingress closes an upstream connection when **no bytes flow** for its idle-timeout
|
|
||||||
period. The heartbeat resets that timer each time it is sent.
|
|
||||||
|
|
||||||
| Ingress idle-timeout | Heartbeat (30s) prevents disconnect? |
|
|
||||||
|---|---|
|
|
||||||
| ≥ 60s (nginx ingress default) | ✅ Yes — 30s < 60s, timer resets each cycle |
|
|
||||||
| 30s | ⚠️ Marginal — race conditions; not safe |
|
|
||||||
| < 30s (some cloud LBs) | ❌ No — connection dropped every cycle |
|
|
||||||
|
|
||||||
**Requirements for the heartbeat to work:**
|
|
||||||
1. Ingress `proxy_read_timeout` / LB idle-timeout **≥ 60s** (30s heartbeat + safety margin).
|
|
||||||
2. `proxy_buffering off` on the ingress location, **or** the server's `X-Accel-Buffering: no`
|
|
||||||
header must be honored — otherwise heartbeats are buffered and never reach the client
|
|
||||||
until the response completes (which never happens for a stream).
|
|
||||||
3. The 30s heartbeat must actually be running in the deployed bugger-feishu pod.
|
|
||||||
|
|
||||||
### 2.4 What happens on a too-tight ingress timeout?
|
|
||||||
|
|
||||||
If the ingress idle-timeout is, say, 15s:
|
|
||||||
1. Bugger connects.
|
|
||||||
2. After 15s of silence (before the 30s heartbeat fires), the ingress closes the upstream.
|
|
||||||
3. The client sees the stream end → schedules a reconnect in 30s.
|
|
||||||
4. Repeat every ~45s indefinitely — a steady-state reconnect loop.
|
|
||||||
|
|
||||||
This wastes bandwidth, defeats the push model, and — with many Bugger clients — creates a
|
|
||||||
synchronized reconnect storm (no jitter, see §3.3).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Reconnection Analysis
|
|
||||||
|
|
||||||
### 3.1 Mechanism — present and well-guarded
|
|
||||||
|
|
||||||
Reconnection is implemented in `BitableEventService.swift`. On any stream end (error or
|
|
||||||
clean), if the user still intends to be connected, a reconnect is scheduled.
|
|
||||||
|
|
||||||
State guard (`BitableEventService.swift:21-23`):
|
|
||||||
```swift
|
|
||||||
/// 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
|
|
||||||
```
|
|
||||||
|
|
||||||
Reconnect scheduling (`BitableEventService.swift:102-124`):
|
|
||||||
```swift
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Fixed delay constant (`BitableEventService.swift:25`):
|
|
||||||
```swift
|
|
||||||
private let reconnectDelay: TimeInterval = 30
|
|
||||||
```
|
|
||||||
|
|
||||||
**Guard quality — good:**
|
|
||||||
- Double-guarded against intentional disconnect: `isEnabled` check at line 114, plus
|
|
||||||
re-checked inside the `DispatchWorkItem` at line 117.
|
|
||||||
- Pending reconnect is cancellable: `closeStream()` calls `reconnectWorkItem?.cancel()`
|
|
||||||
(line 93), so `disconnect()` cannot race with a scheduled reconnect.
|
|
||||||
- All state mutation happens on the main queue (callbacks routed via `DispatchQueue.main.async`
|
|
||||||
in `SSESessionDelegate` at lines 202, 223).
|
|
||||||
|
|
||||||
**Gap — no catch-up fetch:** reconnection does **not** trigger an immediate `fetchNow()`
|
|
||||||
to recover events missed during the downtime window. It relies on the next server push or
|
|
||||||
the periodic `PollerService` to eventually resync. This is acceptable given polling is the
|
|
||||||
fallback, but means event freshness is bounded by the poll interval after any reconnect.
|
|
||||||
|
|
||||||
### 3.2 Exponential backoff — NOT implemented
|
|
||||||
|
|
||||||
The reconnect delay is a **constant 30s, every time, forever**, regardless of how many
|
|
||||||
consecutive failures occur.
|
|
||||||
|
|
||||||
Searching the repo, "exponential backoff" appears only in design docs for the *rejected*
|
|
||||||
direct-WebSocket design — never in shipped SSE code:
|
|
||||||
- `docs/TECH_INVESTIGATION.md:56-58` — Feishu raw WebSocket option (not chosen)
|
|
||||||
- `docs/TECH_INVESTIGATION.md:424` — planned task for the abandoned WSClient approach
|
|
||||||
- `docs/TECH_INVESTIGATION.md:544` — same
|
|
||||||
|
|
||||||
### 3.3 Risks of the fixed-delay strategy
|
|
||||||
|
|
||||||
| Scenario | Behavior | Problem |
|
|
||||||
|---|---|---|
|
|
||||||
| bugger-feishu pod down 1 hour | 120 reconnect attempts at fixed 30s | No de-escalation; wastes resources |
|
|
||||||
| bugger-feishu pod restarts | All clients reconnect simultaneously after 30s | **Thundering herd** — no jitter |
|
|
||||||
| Ingress drops every cycle (timeout < 30s) | Reconnect every ~45s per client | Steady-state storm across all clients |
|
|
||||||
| Many clients, transient outage | All retry on identical 30s cadence | Synchronized load spikes on ingress |
|
|
||||||
|
|
||||||
The lack of jitter is the most operationally significant gap: with N Bugger clients all
|
|
||||||
using the same fixed 30s delay, a pod restart causes all N to hit the ingress at the same
|
|
||||||
instant, every 30s, until one succeeds.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Findings Summary
|
|
||||||
|
|
||||||
### Q1 — Heartbeat & ingress timeout
|
|
||||||
|
|
||||||
- **Yes**, a heartbeat exists — but it is **server-side only** (bugger-feishu emits
|
|
||||||
`: heartbeat\n\n` every ~30s). Bugger's client sends nothing.
|
|
||||||
- **Will it prevent ingress disconnect?** Only if the ingress idle-timeout
|
|
||||||
(`proxy_read_timeout` / LB idle-timeout) is **≥ the heartbeat interval (30s)**, with
|
|
||||||
margin — recommend **≥ 60s**. It also requires `proxy_buffering off` (or honored
|
|
||||||
`X-Accel-Buffering: no`) so heartbeats are not buffered.
|
|
||||||
- **Caveat:** the heartbeat code lives in the separate bugger-feishu repo. It is only
|
|
||||||
*specified* in this repo's docs — verify it is actually deployed.
|
|
||||||
|
|
||||||
### Q2 — Reconnection & exponential backoff
|
|
||||||
|
|
||||||
- **Yes**, reconnection exists. It is triggered on stream end when `isEnabled == true`,
|
|
||||||
uses a fixed **30s delay**, and is properly guarded against intentional disconnects
|
|
||||||
(double guard + cancellable work item).
|
|
||||||
- **No**, it does **not** use exponential backoff. The delay is constant 30s, indefinitely,
|
|
||||||
with no cap, no jitter, and no escalation/de-escalation. This creates retry-storm and
|
|
||||||
thundering-herd risks under sustained or synchronized outages.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Recommendations
|
|
||||||
|
|
||||||
### R1 — Ingress / LB configuration (ops, no code change)
|
|
||||||
|
|
||||||
Configure the ingress in front of bugger-feishu:
|
|
||||||
1. `proxy_read_timeout 60s;` (or higher) — must exceed the 30s heartbeat with margin.
|
|
||||||
2. `proxy_buffering off;` on the SSE location — or ensure `X-Accel-Buffering: no` is honored.
|
|
||||||
3. `proxy_send_timeout 60s;` — symmetric.
|
|
||||||
4. Verify the cloud LB idle-timeout (if any, in front of the ingress) is also ≥ 60s — some
|
|
||||||
LBs default to 30s or less and override the ingress setting.
|
|
||||||
|
|
||||||
### R2 — Verify heartbeat is deployed (ops, no code change)
|
|
||||||
|
|
||||||
Confirm in the bugger-feishu repo that the 30s `: heartbeat\n\n` emission is actually
|
|
||||||
running in the deployed image, not just specified in this repo's docs. If absent, the
|
|
||||||
ingress will drop the connection on every idle cycle regardless of R1.
|
|
||||||
|
|
||||||
### R3 — Add exponential backoff with jitter and cap (code change)
|
|
||||||
|
|
||||||
Modify `BitableEventService.swift` to replace the fixed `reconnectDelay` with a backoff
|
|
||||||
strategy. Suggested parameters:
|
|
||||||
|
|
||||||
```
|
|
||||||
attempt 1: delay = 1s (±20% jitter)
|
|
||||||
attempt 2: delay = 2s (±20% jitter)
|
|
||||||
attempt 3: delay = 4s (±20% jitter)
|
|
||||||
attempt 4: delay = 8s (±20% jitter)
|
|
||||||
attempt 5: delay = 16s (±20% jitter)
|
|
||||||
attempt 6+: delay = 30s (±20% jitter) ← cap
|
|
||||||
```
|
|
||||||
|
|
||||||
- Base: `delay = min(cap, base * 2^(attempt-1))`
|
|
||||||
- Cap: 30s (preserves current steady-state behavior)
|
|
||||||
- Jitter: ±20% of computed delay (prevents thundering herd)
|
|
||||||
- Reset attempt counter on a successful `connected` event from the server.
|
|
||||||
|
|
||||||
### R4 — Catch-up fetch on reconnect (optional code change)
|
|
||||||
|
|
||||||
On a successful reconnect, trigger `PollerService.shared.fetchNow()` once to recover any
|
|
||||||
events missed during the downtime window, rather than waiting for the next poll cycle.
|
|
||||||
This bounds event freshness loss to one fetch rather than the full poll interval.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Key Code Locations
|
|
||||||
|
|
||||||
All paths in `/Users/tigeren/Dev/xorbitlab/bugger/`:
|
|
||||||
|
|
||||||
| Location | What |
|
|
||||||
|---|---|
|
|
||||||
| `Sources/Services/BitableEventService.swift:25` | `reconnectDelay = 30` (fixed, no backoff) |
|
|
||||||
| `Sources/Services/BitableEventService.swift:21-23` | `isEnabled` guard |
|
|
||||||
| `Sources/Services/BitableEventService.swift:61-66` | Client idle-timeout config (300s request, infinite resource) |
|
|
||||||
| `Sources/Services/BitableEventService.swift:83` | Request timeout (300s) |
|
|
||||||
| `Sources/Services/BitableEventService.swift:92-100` | `closeStream` — cancels pending reconnect |
|
|
||||||
| `Sources/Services/BitableEventService.swift:102-124` | `handleStreamEnd` — auto-reconnect scheduling |
|
|
||||||
| `Sources/Services/BitableEventService.swift:126-138` | `handleEvent` — `change`→`fetchNow`, heartbeat no-op |
|
|
||||||
| `Sources/Services/BitableEventService.swift:170-226` | `SSESessionDelegate` — frame parser, comment/heartbeat ignored |
|
|
||||||
| `Sources/AppDelegate.swift:33` | `connect()` on app launch |
|
|
||||||
| `Sources/Views/Settings/SettingsView.swift:293` | `reconnect()` on settings save |
|
|
||||||
| `Sources/Views/Settings/SettingsView.swift:181` | `disconnect()` on field clear |
|
|
||||||
| `docs/BITABLE_CHANGE_NOTIFICATION_IMPL.md:686-693` | Server heartbeat spec (30s, must verify deployed) |
|
|
||||||
| `docs/BITABLE_CHANGE_NOTIFICATION_IMPL.md:707-711` | Server SSE response headers (keep-alive, no buffering) |
|
|
||||||
| `docs/TECH_INVESTIGATION.md:56-58,424,544` | Backoff references (rejected WSClient design only) |
|
|
||||||
|
|
@ -1,710 +0,0 @@
|
||||||
# Feishu Connector — Technical Investigation Report
|
|
||||||
|
|
||||||
**Date:** 2026-06-30
|
|
||||||
**Goal:** Replace polling-based Bitable sync with event-driven (push) notifications from Feishu to Bugger.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TL;DR
|
|
||||||
|
|
||||||
⚠️ **Conditionally feasible.** The best approach is a **WebSocket Long Connection** to Feishu's event platform, which does NOT require a public-facing server. A `feishu-connector` service can maintain this connection, listen for `drive.file.bitable_record_changed_v1` events, and notify the Bugger macOS app to refresh.
|
|
||||||
|
|
||||||
**Critical caveat:** Feishu only allows the Bitable **owner or manager** to subscribe to events (§7.1). For editor/viewer users, the connector cannot subscribe — Bugger falls back to polling for those Bitables. This is a **progressive enhancement** on top of polling, not a replacement.
|
|
||||||
|
|
||||||
**Phase 0 (before any code):** Verify the subscribe permission boundary with Bugger's target user roles. If all target users are editors only, pivot to improved polling (§8.2).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Current State (Bugger)
|
|
||||||
|
|
||||||
Bugger currently polls Feishu's Bitable REST API:
|
|
||||||
|
|
||||||
```
|
|
||||||
Bugger (macOS) ──[Timer: every N min]──▶ GET /bitable/v1/apps/{appToken}/tables/{tableId}/records
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Feishu Cloud
|
|
||||||
```
|
|
||||||
|
|
||||||
**Pain points:**
|
|
||||||
- Latency up to the poll interval (default 5 min)
|
|
||||||
- Wasted API calls when nothing changed
|
|
||||||
- Timer-based, no real-time awareness
|
|
||||||
|
|
||||||
**Existing Bugger Feishu integration:**
|
|
||||||
- OAuth 2.0 with `user_access_token` + `refresh_token`
|
|
||||||
- `tenant_access_token` obtained via `app_id` + `app_secret`
|
|
||||||
- `FeishuAuthService` already handles both token types
|
|
||||||
- `FeishuService` already handles Bitable record fetching
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Feishu Event Subscription Options
|
|
||||||
|
|
||||||
Feishu provides **three** mechanisms to receive Bitable change events:
|
|
||||||
|
|
||||||
### Option A: WebSocket Long Connection (⭐ Recommended)
|
|
||||||
|
|
||||||
| Aspect | Detail |
|
|
||||||
|--------|--------|
|
|
||||||
| **URL** | `wss://open.feishu.cn/open-apis/ws/v1/events` |
|
|
||||||
| **Public server required?** | ❌ No — client initiates outbound WebSocket |
|
|
||||||
| **Auth** | One-time `tenant_access_token` at connection time |
|
|
||||||
| **Protocol** | Custom Protobuf frame (`pbbp2.proto`) over binary WebSocket |
|
|
||||||
| **SDKs available** | Go, Python, Java, Node.js (no official Swift SDK) |
|
|
||||||
| **Connection limit** | 50 concurrent connections per app |
|
|
||||||
| **Heartbeat** | Ping/Pong every ~120 seconds |
|
|
||||||
| **Timeout** | 300s without any frame → reconnect |
|
|
||||||
| **Reconnection** | Must implement exponential backoff manually |
|
|
||||||
| **Deduplication** | Use `event_id` field; platform delivers at-least-once |
|
|
||||||
|
|
||||||
**How it works:**
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────┐ ┌──────────────────┐
|
|
||||||
│ feishu-connector │ │ Feishu Cloud │
|
|
||||||
│ (macOS service) │ │ │
|
|
||||||
│ │ 1. GET tenant_access_token │
|
|
||||||
│ │──── HTTP POST ─────────▶│ /auth/v3/... │
|
|
||||||
│ │◀─── {token} ───────────│ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ 2. WebSocket connect │ │
|
|
||||||
│ │──── wss://open.feishu.cn/open-apis/ws/v1/events ──▶│
|
|
||||||
│ │ │ │
|
|
||||||
│ │ 3. Auth frame (PbFrame) │ │
|
|
||||||
│ │──── {type:"auth", token}──▶│ │
|
|
||||||
│ │◀─── ACK ───────────────│ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ 4. Subscribe to Bitable │ │
|
|
||||||
│ │──── POST /drive/v1/files/{token}/subscribe ─▶│
|
|
||||||
│ │ │ │
|
|
||||||
│ │ 5. Events stream in │ │
|
|
||||||
│ │◀── PbFrame {bitable_record_changed} ───────│
|
|
||||||
│ │◀── PbFrame {bitable_record_changed} ───────│
|
|
||||||
│ │ │ │
|
|
||||||
│ │ 6. Ping/Pong keepalive │ │
|
|
||||||
│ │◀──────▶─────────────────│ │
|
|
||||||
└──────────────────┘ └──────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option B: Webhook (HTTP Callback)
|
|
||||||
|
|
||||||
| Aspect | Detail |
|
|
||||||
|--------|--------|
|
|
||||||
| **Public server required?** | ✅ Yes — must have a public HTTPS URL |
|
|
||||||
| **Auth** | Manual signature verification + decryption on every request |
|
|
||||||
| **Response** | HTTP 200 within 3 seconds |
|
|
||||||
| **Retry** | 15s, 5min, 1hr, 6hr (max 4 retries) |
|
|
||||||
|
|
||||||
❌ **Not suitable** — Bugger is a personal macOS tool with no public server.
|
|
||||||
|
|
||||||
### Option C: Bitable Built-in Automation (No-Code)
|
|
||||||
|
|
||||||
| Aspect | Detail |
|
|
||||||
|--------|--------|
|
|
||||||
| **Setup** | Configure in Bitable UI: Automation → "When record changes" → "Send HTTP Request" |
|
|
||||||
| **Public URL required?** | ✅ Yes — HTTP request target must be publicly reachable |
|
|
||||||
| **Payload** | Customizable JSON with field value interpolation |
|
|
||||||
| **Granularity** | Per-table, per-field conditions supported |
|
|
||||||
|
|
||||||
❌ **Not suitable** — still needs a public URL, and has limited payload control.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. The Bitable Record Changed Event
|
|
||||||
|
|
||||||
**Event type:** `drive.file.bitable_record_changed_v1`
|
|
||||||
|
|
||||||
### Event Payload (key fields)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"schema": "2.0",
|
|
||||||
"header": {
|
|
||||||
"event_id": "f7984f25108f8137722bb63cee927e66",
|
|
||||||
"event_type": "drive.file.bitable_record_changed_v1",
|
|
||||||
"create_time": "1603977298000000",
|
|
||||||
"token": "066zT6pS4QCbgj5Do145GfDbbagCHGgF",
|
|
||||||
"app_id": "cli_xxxxxxxx",
|
|
||||||
"tenant_key": "xxxxxxx"
|
|
||||||
},
|
|
||||||
"event": {
|
|
||||||
"file_type": "bitable",
|
|
||||||
"file_token": "bTkAbFdN...",
|
|
||||||
"table_id": "tblXXXXXXXX",
|
|
||||||
"revision": 42,
|
|
||||||
"operator_id": { "union_id": "...", "open_id": "..." },
|
|
||||||
"action_list": [
|
|
||||||
{
|
|
||||||
"record_id": "recXXXXXXXX",
|
|
||||||
"action": "record_edited",
|
|
||||||
"before_value": { "field_id": "...", "field_value": "..." },
|
|
||||||
"after_value": { "field_id": "...", "field_value": "..." }
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"subscriber_id_list": ["..."],
|
|
||||||
"update_time": 1603977298
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Important Notes
|
|
||||||
|
|
||||||
- **Formula field changes do NOT trigger events**
|
|
||||||
- **Must call subscribe API first:** `POST /open-apis/drive/v1/files/{file_token}/subscribe?file_type=bitable`
|
|
||||||
- **Only document owner/manager can subscribe** — ⚠️ **SHOWSTOPPER** (see §7.1)
|
|
||||||
- **App needs both app identity AND user identity permissions** for `bitable:app` or `drive:drive`
|
|
||||||
- Events include `before_value` and `after_value` — useful for detecting what changed
|
|
||||||
|
|
||||||
### 3.1 Client-Side Event Filtering (Payload Reduction)
|
|
||||||
|
|
||||||
The raw `bitable_record_changed_v1` event includes full `before_value` / `after_value` for every changed field. For a large Bitable record, this can be several KB — enough to exceed `DistributedNotificationCenter`'s ~2 KB payload limit.
|
|
||||||
|
|
||||||
**Feishu does NOT support server-side field filtering** on the event subscription — the entire changed record is always delivered. However, the connector can filter client-side before notifying Bugger:
|
|
||||||
|
|
||||||
```
|
|
||||||
Feishu ──[full event]──▶ Connector ──[filtered: only changed fields]──▶ Bugger
|
|
||||||
```
|
|
||||||
|
|
||||||
**Filtering strategies (client-side, in `BitableEventHandler`):**
|
|
||||||
|
|
||||||
| Strategy | What Bugger Receives | Payload Reduction |
|
|
||||||
|----------|---------------------|-------------------|
|
|
||||||
| **Assignee-only** | Only `action_list` entries where the changed field is the assignee/person column | ~90%+ for typical records |
|
|
||||||
| **Field whitelist** | Only `action_list` entries matching a configured set of field names/IDs | Configurable |
|
|
||||||
| **Change summary** | `{table_id, record_id, revision, changed_fields: ["Assignee"]}` — no before/after values | ~95%+, fits easily in DNC |
|
|
||||||
| **Wake-up signal only** | `{table_id, revision}` — Bugger does a full re-fetch | Minimal (~100 bytes), simplest |
|
|
||||||
|
|
||||||
**Recommendation:** Start with the **wake-up signal only** approach. It's the simplest, safest for DNC payload limits, and Bugger already knows how to fetch records. The connector just needs to say "table X changed, go fetch." This avoids the entire payload-size concern and keeps the notification channel trivial.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. WebSocket Long Connection Protocol Details
|
|
||||||
|
|
||||||
### 4.1 Protobuf Frame Format (`pbbp2.proto`)
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message PbFrame {
|
|
||||||
int32 method = 1; // 0 = CONTROL, 1 = DATA
|
|
||||||
bytes payload = 2; // JSON string for DATA frames
|
|
||||||
map<string, string> headers = 3; // message_id, seq, sum, etc.
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 Connection Lifecycle
|
|
||||||
|
|
||||||
```
|
|
||||||
1. Obtain tenant_access_token
|
|
||||||
POST /open-apis/auth/v3/tenant_access_token/internal
|
|
||||||
Body: { "app_id": "...", "app_secret": "..." }
|
|
||||||
|
|
||||||
2. Open WebSocket
|
|
||||||
wss://open.feishu.cn/open-apis/ws/v1/events
|
|
||||||
|
|
||||||
3. Send Authentication Frame
|
|
||||||
PbFrame { method=1, payload='{"type":"authentication","data":{"tenant_access_token":"..."}}' }
|
|
||||||
|
|
||||||
4. Send Subscribe API (over HTTP, NOT websocket)
|
|
||||||
POST /open-apis/drive/v1/files/{file_token}/subscribe?file_type=bitable
|
|
||||||
|
|
||||||
5. Receive Events
|
|
||||||
Binary frames arrive. Parse PbFrame → extract JSON payload → process event.
|
|
||||||
|
|
||||||
6. Heartbeat
|
|
||||||
Server sends ping_interval. Client must send CONTROL frames at that interval.
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 Fragment Reassembly
|
|
||||||
|
|
||||||
Large events may be split across multiple PbFrames. Headers provide:
|
|
||||||
- `message_id` — groups fragments of the same logical message
|
|
||||||
- `sum` — total fragment count
|
|
||||||
- `seq` — 0-indexed fragment number
|
|
||||||
|
|
||||||
Client must buffer and reassemble by `message_id`.
|
|
||||||
|
|
||||||
### 4.4 Deduplication
|
|
||||||
|
|
||||||
Platform uses at-least-once delivery. Use `header.event_id` for idempotency.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Architecture Proposal: feishu-connector
|
|
||||||
|
|
||||||
### 5.1 High-Level Design
|
|
||||||
|
|
||||||
**Key architectural principle: Polling is the primary path; push is a progressive enhancement.** The connector provides real-time notifications only for Bitables where the user has owner/manager permissions (§7.1). For all others, the existing `PollerService` remains the sole refresh mechanism. Bugger operates in a per-Bitable mixed mode.
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────────────┐
|
|
||||||
│ User's Mac │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────────────────────────────┐ │
|
|
||||||
│ │ feishu-connector (new) │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ ┌──────────────┐ ┌──────────────────┐ │ │
|
|
||||||
│ │ │ WSClient │ │ EventDispatcher │ │ │
|
|
||||||
│ │ │ (URLSession │──▶│ - dedup │ │ │
|
|
||||||
│ │ │ WebSocket) │ │ - fragment merge │ │ │
|
|
||||||
│ │ │ │ │ - filter: only │ │ │
|
|
||||||
│ │ │ │ │ eligible tables │ │ │
|
|
||||||
│ │ └──────────────┘ └────────┬─────────┘ │ │
|
|
||||||
│ │ │ │ │
|
|
||||||
│ │ ┌────────▼─────────┐ │ │
|
|
||||||
│ │ │ LocalNotifier │ │ │
|
|
||||||
│ │ │ (DNC wake-up │ │ │
|
|
||||||
│ │ │ signal: ~100 B) │ │ │
|
|
||||||
│ │ └────────┬─────────┘ │ │
|
|
||||||
│ └──────────────────────────────┼──────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌──────────────────────────────┼──────────────┐ │
|
|
||||||
│ │ Bugger (existing) │ │ │
|
|
||||||
│ │ │ │ │
|
|
||||||
│ │ ┌────────────────┐ ┌──────▼──────┐ │ │
|
|
||||||
│ │ │ PollerService │ │ Connector │ │ │
|
|
||||||
│ │ │ (PRIMARY path │ │ Listener │ │ │
|
|
||||||
│ │ │ for ALL tables)│ │ (new) │ │ │
|
|
||||||
│ │ │ │ │ │ │ │
|
|
||||||
│ │ │ Interval: │ │ On signal: │ │ │
|
|
||||||
│ │ │ 5 min (no WS) │ │ fetchNow() │ │ │
|
|
||||||
│ │ │ 15-30 min (WS │ │ │ │ │
|
|
||||||
│ │ │ active) │ │ │ │ │
|
|
||||||
│ │ └────────────────┘ └─────────────┘ │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ Per-Bitable mode: │ │
|
|
||||||
│ │ ✅ owner/manager → WS push + polling │ │
|
|
||||||
│ │ ❌ editor/viewer → polling only │ │
|
|
||||||
│ └──────────────────────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
└──────────────────────────┼────────────────────────────────┘
|
|
||||||
│
|
|
||||||
│ wss://open.feishu.cn
|
|
||||||
▼
|
|
||||||
┌───────────────┐
|
|
||||||
│ Feishu Cloud │
|
|
||||||
└───────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 Component Breakdown
|
|
||||||
|
|
||||||
| Component | Language | Responsibility |
|
|
||||||
|-----------|----------|----------------|
|
|
||||||
| `feishu-connector` | Swift (macOS CLI/daemon) | WebSocket connection, event processing, local notification |
|
|
||||||
| `WSClient` | Swift | Raw WebSocket + Protobuf frame encode/decode |
|
|
||||||
| `EventDispatcher` | Swift | Dedup, merge fragments, route events to handlers |
|
|
||||||
| `BitableEventHandler` | Swift | Parse `bitable_record_changed_v1`, filter to eligible tables only, emit wake-up signal |
|
|
||||||
| `SubscriptionManager` | Swift | Call subscribe API, detect permission errors, maintain eligible-table whitelist |
|
|
||||||
| `LocalNotifier` | Swift | Notify Bugger via DistributedNotificationCenter (wake-up signal: ~100 B) |
|
|
||||||
| `Bugger Connector Listener` | Swift (in Bugger) | Receive wake-up signals, trigger `PollerService.fetchNow()` for the signaled table |
|
|
||||||
| `Bugger Eligibility Check` | Swift (in Bugger) | On Bitable setup, test if subscribe succeeds; set per-table mode (push+poll vs. poll-only) |
|
|
||||||
|
|
||||||
### 5.3 Notification Channel Options (connector → Bugger)
|
|
||||||
|
|
||||||
| Method | Pros | Cons |
|
|
||||||
|--------|------|------|
|
|
||||||
| **DistributedNotificationCenter** | Native macOS, no socket, both Swift | No guaranteed delivery, ~2 KB payload limit |
|
|
||||||
| **XPC Service** | Native, secure, lifecycle management | More complex setup, tightly coupled |
|
|
||||||
| **localhost HTTP (e.g., :18924)** | Simple, debuggable, any language | Port management, local firewall issues |
|
|
||||||
| **Unix Domain Socket** | Fast, secure, no port conflicts | Slightly more code |
|
|
||||||
| **File watch / shared memory** | Simplest | Polling defeats the purpose |
|
|
||||||
|
|
||||||
**Recommendation: DistributedNotificationCenter with wake-up-signal pattern.**
|
|
||||||
|
|
||||||
The connector sends a minimal payload — just `{table_id, revision}` (~100 bytes) — and Bugger does a full re-fetch. This eliminates the payload-size concern entirely (§3.1). The signal is idempotent and loss-tolerant since Bugger's `PollerService` runs as a periodic fallback.
|
|
||||||
|
|
||||||
**Fallback:** If DNC proves unreliable in practice, switch to **localhost HTTP** on a fixed port. The wake-up-signal approach keeps the payload tiny regardless of transport.
|
|
||||||
|
|
||||||
### 5.4 Integration with Bugger's Existing Auth
|
|
||||||
|
|
||||||
Bugger already has:
|
|
||||||
- `FeishuAuthService` — obtains `tenant_access_token`, handles OAuth
|
|
||||||
- `TokenManager` — Keychain-backed token storage
|
|
||||||
- `FeishuService` — Bitable API client
|
|
||||||
|
|
||||||
The connector can **reuse the same credentials** (app_id, app_secret from Info.plist) and obtain its own `tenant_access_token`. Alternatively, Bugger can share its token via Keychain (same access group).
|
|
||||||
|
|
||||||
### 5.5 Fallback Strategy
|
|
||||||
|
|
||||||
The connector handles **event loss gracefully**:
|
|
||||||
|
|
||||||
```
|
|
||||||
Event received ──▶ Notify Bugger ──▶ Bugger.fetchNow()
|
|
||||||
│
|
|
||||||
If connector disconnects: │
|
|
||||||
→ Bugger's PollerService continues │
|
|
||||||
as periodic fallback │
|
|
||||||
│
|
|
||||||
When connector reconnects: │
|
|
||||||
→ Full re-fetch to catch missed │
|
|
||||||
events │
|
|
||||||
```
|
|
||||||
|
|
||||||
Bugger's existing `PollerService` should remain as a fallback (e.g., every 15–30 min) to catch any events missed during connector downtime.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Implementation Complexity Assessment
|
|
||||||
|
|
||||||
### 6.1 Swift Protobuf Implementation
|
|
||||||
|
|
||||||
Feishu's WebSocket uses a custom binary protocol (`pbbp2.proto`). In Swift:
|
|
||||||
|
|
||||||
| Option | Effort | Risk |
|
|
||||||
|--------|--------|------|
|
|
||||||
| **SwiftProtobuf (Apple)** | Medium | Official, well-maintained. Need `.proto` → Swift codegen |
|
|
||||||
| **Manual binary encode/decode** | High | Error-prone, but avoids dependency |
|
|
||||||
| **Use `Codable` + manual frame parsing** | Medium | Protobuf wire format is straightforward for this simple schema |
|
|
||||||
|
|
||||||
The `PbFrame` schema is simple enough (3 fields: int32, bytes, map) that a manual binary encoder/decoder (~200 lines) may be viable and avoids a Protobuf dependency.
|
|
||||||
|
|
||||||
### 6.2 WebSocket in Swift
|
|
||||||
|
|
||||||
`URLSessionWebSocketTask` (iOS 13+ / macOS 10.15+) provides native WebSocket support:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let session = URLSession(configuration: .default)
|
|
||||||
let wsTask = session.webSocketTask(with: URL(string: "wss://open.feishu.cn/open-apis/ws/v1/events")!)
|
|
||||||
wsTask.resume()
|
|
||||||
|
|
||||||
// Send binary
|
|
||||||
let frame = try PbFrameEncoder.encode(authFrame)
|
|
||||||
wsTask.send(.data(frame)) { error in ... }
|
|
||||||
|
|
||||||
// Receive
|
|
||||||
func receiveNext() {
|
|
||||||
wsTask.receive { result in
|
|
||||||
switch result {
|
|
||||||
case .success(let message):
|
|
||||||
switch message {
|
|
||||||
case .data(let data): handleBinaryFrame(data)
|
|
||||||
case .string(let text): handleTextFrame(text)
|
|
||||||
@unknown default: break
|
|
||||||
}
|
|
||||||
receiveNext() // Loop
|
|
||||||
case .failure(let error): handleDisconnect(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.3 Line Count Estimates
|
|
||||||
|
|
||||||
| Component | Estimated LOC | Complexity |
|
|
||||||
|-----------|---------------|------------|
|
|
||||||
| `PbFrame` encoder/decoder | ~150 | Medium |
|
|
||||||
| `WSClient` (connect, auth, heartbeat, reconnect) | ~300 | Medium |
|
|
||||||
| `EventDispatcher` (dedup, merge, route) | ~150 | Low |
|
|
||||||
| `BitableEventHandler` (filter eligible tables, emit wake-up signal) | ~120 | Low |
|
|
||||||
| `SubscriptionManager` (subscribe API, permission check, whitelist) | ~100 | Low |
|
|
||||||
| `LocalNotifier` (DistributedNotificationCenter) | ~50 | Low |
|
|
||||||
| `ConnectorDaemon` (main loop, signal handling) | ~100 | Low |
|
|
||||||
| **feishu-connector total** | **~970** | |
|
|
||||||
| Bugger: `ConnectorListener` | ~80 | Low |
|
|
||||||
| Bugger: `EligibilityCheck` (test subscribe, set per-table mode) | ~80 | Low |
|
|
||||||
| Bugger: Integration changes | ~100 | Low |
|
|
||||||
| **Bugger changes total** | **~260** | |
|
|
||||||
|
|
||||||
### 6.4 Dependencies
|
|
||||||
|
|
||||||
| Dependency | Need |
|
|
||||||
|------------|------|
|
|
||||||
| `SwiftProtobuf` | Optional (manual encoder possible) |
|
|
||||||
| `Foundation` (URLSession, DistributedNotificationCenter) | Built-in |
|
|
||||||
| Third-party | None required |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Risks & Mitigations
|
|
||||||
|
|
||||||
| Risk | Severity | Mitigation |
|
|
||||||
|------|----------|------------|
|
|
||||||
| **Subscribe requires doc owner/manager** | 🔴 Critical | **Showstopper.** The subscribing user must be the Bitable owner or have manager permissions. If Bugger's user is a collaborator/editor only, the WebSocket approach is non-viable for that Bitable. See §7.1 for deep-dive and alternatives. |
|
|
||||||
| **No official Swift SDK** | Medium | Protocol is documented; implement manually. PbFrame schema is simple. |
|
|
||||||
| **Protobuf complexity** | Low | Use Apple's SwiftProtobuf, or hand-roll ~150 lines for the simple schema. |
|
|
||||||
| **WebSocket disconnects** | Medium | Auto-reconnect with exponential backoff. Bugger's PollerService is fallback. |
|
|
||||||
| **Event loss during disconnect** | Medium | On reconnect, trigger full re-fetch. PollerService runs as periodic safety net. |
|
|
||||||
| **Feishu API changes** | Low | Protocol is stable. Event schema versioned (`schema: "2.0"`). |
|
|
||||||
| **Connection limit (50/app)** | Low | Single-user tool — 1 connection. |
|
|
||||||
| **Token expiry (2h)** | Low | Bugger already handles token refresh. Connector re-auths on WS reconnect. |
|
|
||||||
| **App must be "published"** | Medium | Feishu requires app version publication before it can subscribe to events. Acceptable for internal tool, but adds setup friction. |
|
|
||||||
| **macOS sandboxing** | Medium | If the connector ships inside Bugger's app bundle, the sandbox may block outbound WebSocket connections. Requires `com.apple.security.network.client` entitlement. If distributed outside the App Store (direct download), sandboxing may not apply. See §7.2. |
|
|
||||||
|
|
||||||
### 7.1 Deep-Dive: Subscribe Permission (Showstopper)
|
|
||||||
|
|
||||||
Feishu's event subscription API (`POST /drive/v1/files/{file_token}/subscribe`) enforces a hard permission check:
|
|
||||||
|
|
||||||
> **Only the document owner or users with manager-level permissions can subscribe to events on a Bitable.**
|
|
||||||
|
|
||||||
This is a **platform-level restriction** — there is no workaround, no admin override, and no scope escalation that bypasses it.
|
|
||||||
|
|
||||||
**Impact on Bugger users:**
|
|
||||||
|
|
||||||
| User's Bitable Role | Can Subscribe? | WebSocket Viable? |
|
|
||||||
|---------------------|---------------|-------------------|
|
|
||||||
| Owner | ✅ Yes | ✅ Full push notifications |
|
|
||||||
| Manager | ✅ Yes | ✅ Full push notifications |
|
|
||||||
| Editor | ❌ No | ❌ Must fall back to polling |
|
|
||||||
| Viewer / Commenter | ❌ No | ❌ Must fall back to polling |
|
|
||||||
|
|
||||||
**What Bugger can do about it:**
|
|
||||||
|
|
||||||
1. **Detect at setup time.** When the user configures a Bitable in Bugger, call the subscribe API. If it returns a permission error, log it clearly and fall back to polling for that Bitable. Don't silently fail.
|
|
||||||
|
|
||||||
2. **Per-Bitable mode.** Bugger can support a mixed mode — WebSocket push for Bitables where the user is owner/manager, polling for others. The connector subscribes only to eligible Bitables.
|
|
||||||
|
|
||||||
3. **User-facing guidance.** If subscription fails, Bugger should tell the user exactly what's needed: *"To enable real-time sync, ask the Bitable owner to grant you manager permissions, or ask them to install Bugger and set up the connector."*
|
|
||||||
|
|
||||||
4. **Polling remains the universal fallback.** Even with the connector, Bugger's `PollerService` is the baseline that works for all permission levels. The connector is a progressive enhancement, not a replacement.
|
|
||||||
|
|
||||||
**Verdict:** This doesn't kill the project — it scopes it. The connector is a **best-effort push layer** on top of polling, not a replacement for it. The architecture must treat polling as the primary path and push as an optimization available to owner/manager users.
|
|
||||||
|
|
||||||
### 7.2 macOS Sandboxing Considerations
|
|
||||||
|
|
||||||
If `feishu-connector` is distributed as part of Bugger's app bundle (vs. a standalone CLI):
|
|
||||||
|
|
||||||
| Scenario | Sandbox Applied? | WebSocket Blocked? |
|
|
||||||
|----------|------------------|---------------------|
|
|
||||||
| **App Store distribution** | ✅ Mandatory | Needs `com.apple.security.network.client` entitlement (outbound only — granted by default in most templates) |
|
|
||||||
| **Direct download + notarized** | ❌ Optional | No issue |
|
|
||||||
| **Standalone CLI binary** | ❌ N/A | No issue |
|
|
||||||
| **LaunchAgent daemon** | ❌ N/A | No issue |
|
|
||||||
|
|
||||||
**Required entitlements** (if sandboxed):
|
|
||||||
```xml
|
|
||||||
<key>com.apple.security.network.client</key>
|
|
||||||
<true/>
|
|
||||||
```
|
|
||||||
|
|
||||||
Outbound WebSocket (client-initiated) is the least restricted network operation — this entitlement is included in Xcode's default sandbox template. It should not be a blocker.
|
|
||||||
|
|
||||||
**If the connector also listens on localhost** (for the localhost HTTP fallback), it additionally needs:
|
|
||||||
```xml
|
|
||||||
<key>com.apple.security.network.server</key>
|
|
||||||
<true/>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Recommendation:** Distribute the connector as a LaunchAgent daemon (via `.plist` in `~/Library/LaunchAgents/`), not embedded in the app bundle. This avoids sandboxing entirely and is the standard pattern for macOS background services. If it must ship inside the bundle, add the network client entitlement explicitly.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Alternatives Considered
|
|
||||||
|
|
||||||
### 8.1 Go/Python Sidecar
|
|
||||||
|
|
||||||
Run the Feishu WebSocket client in Go or Python (using official SDK), notifying Bugger via localhost HTTP.
|
|
||||||
|
|
||||||
| Pros | Cons |
|
|
||||||
|------|------|
|
|
||||||
| Official SDK support | Two runtimes to manage |
|
|
||||||
| Less protocol risk | Distribution complexity |
|
|
||||||
| Faster to prototype | User must install Go/Python runtime |
|
|
||||||
|
|
||||||
Verdict: Viable fallback if pure Swift proves too difficult.
|
|
||||||
|
|
||||||
### 8.2 Improve Polling Instead
|
|
||||||
|
|
||||||
Shorten poll interval + use conditional requests (ETags / If-Modified-Since).
|
|
||||||
|
|
||||||
| Pros | Cons |
|
|
||||||
|------|------|
|
|
||||||
| Zero new infrastructure | Still polling |
|
|
||||||
| Immediate improvement | Feishu Bitable API may not support conditional requests |
|
|
||||||
| No Feishu platform config needed | Higher API call volume |
|
|
||||||
|
|
||||||
Verdict: Quick win, but doesn't solve the fundamental problem. Feishu Bitable List Records API does not support ETags.
|
|
||||||
|
|
||||||
### 8.3 Bitable Automation + ngrok
|
|
||||||
|
|
||||||
Use Bitable's built-in Automation to send HTTP requests to an ngrok tunnel → local server.
|
|
||||||
|
|
||||||
| Pros | Cons |
|
|
||||||
|------|------|
|
|
||||||
| Zero code for Feishu integration | ngrok dependency |
|
|
||||||
| Simple to configure | ngrok URL changes on restart |
|
|
||||||
| | Fragile for a long-running tool |
|
|
||||||
|
|
||||||
Verdict: Good for prototyping, not for production.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Recommendation
|
|
||||||
|
|
||||||
### Build `feishu-connector` as a Swift CLI/daemon with per-Bitable eligibility
|
|
||||||
|
|
||||||
**Guiding principle:** The connector is a **best-effort push layer** on top of polling — not a replacement. Polling remains the universal fallback for all Bitables. The connector accelerates refresh for Bitables where the user has owner/manager permissions.
|
|
||||||
|
|
||||||
**Phase 0 — Permission Feasibility Check (1 hour)** ⚠️ **Do this first.**
|
|
||||||
1. Using Bugger's existing auth, call `POST /drive/v1/files/{file_token}/subscribe` for a test Bitable
|
|
||||||
2. Verify the user role required (owner vs. manager vs. editor)
|
|
||||||
3. If the test user (editor role) gets 403, confirm the permission boundary
|
|
||||||
4. **Go/no-go:** If none of Bugger's target users are Bitable owners/managers, the WebSocket approach is non-viable — pivot to improved polling (§8.2) or Bitable Automation + ngrok (§8.3)
|
|
||||||
|
|
||||||
**Phase 1 — Prototype (1-2 days)**
|
|
||||||
1. Implement `PbFrame` encoder/decoder in Swift
|
|
||||||
2. Implement `WSClient` with auth + heartbeat + exponential backoff reconnect
|
|
||||||
3. Implement `SubscriptionManager` — call subscribe API, detect permission errors, build eligible-table whitelist
|
|
||||||
4. Implement `BitableEventHandler` — filter events to eligible tables, emit wake-up signal only
|
|
||||||
5. Print received events to stdout for validation
|
|
||||||
|
|
||||||
**Phase 2 — Integration (1 day)**
|
|
||||||
6. Implement `LocalNotifier` → Bugger via `DistributedNotificationCenter` (wake-up signal: `{table_id, revision}`)
|
|
||||||
7. Add `ConnectorListener` to Bugger that triggers `fetchNow()` for the signaled table
|
|
||||||
8. Add per-Bitable eligibility check in Bugger — test subscribe on setup, set mixed mode
|
|
||||||
9. Keep `PollerService` as primary/fallback (5 min when no WS, 15–30 min when WS active)
|
|
||||||
|
|
||||||
**Phase 3 — Hardening (1-2 days)**
|
|
||||||
10. Fragment reassembly
|
|
||||||
11. Deduplication via `event_id`
|
|
||||||
12. Graceful shutdown
|
|
||||||
13. LaunchAgent `.plist` for auto-start (avoids sandboxing — see §7.2)
|
|
||||||
14. Re-fetch on reconnect to catch missed events
|
|
||||||
|
|
||||||
**Total estimated effort: 3–5 days** (after Phase 0 passes)
|
|
||||||
|
|
||||||
### Quick Alternative
|
|
||||||
|
|
||||||
If the full Swift implementation is too heavy upfront, prototype with Python (`lark-oapi` SDK, ~50 lines) + localhost HTTP notification → Bugger. **Note:** The Python SDK handles Protobuf frames internally; use it for validation, then port to Swift once the approach is confirmed viable. See Appendix A for caveats about the spike code.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. References
|
|
||||||
|
|
||||||
- [Feishu Event Subscription Overview](https://open.feishu.cn/document/server-docs/event-subscription-guide/overview)
|
|
||||||
- [Bitable Record Changed Event](https://open.feishu.cn/document/docs/bitable-v1/events/bitable_record_changed)
|
|
||||||
- [Subscribe Cloud Document Events API](https://open.feishu.cn/document/server-docs/docs/drive-v1/event/subscribe)
|
|
||||||
- [Feishu WebSocket Long Connection Guide](https://open.feishu.cn/document/server-docs/event-subscription-guide/overview)
|
|
||||||
- [Bitable Automation: Send HTTP Request](https://feishu.feishu.cn/wiki/FmlgwagDKiVcTWkCjgxckMj9nTe)
|
|
||||||
- [Apple SwiftProtobuf](https://github.com/apple/swift-protobuf)
|
|
||||||
- [URLSessionWebSocketTask](https://developer.apple.com/documentation/foundation/urlsessionwebsockettask)
|
|
||||||
- [Feishu Python SDK (lark-oapi)](https://pypi.org/project/lark-oapi/)
|
|
||||||
- [Feishu Node.js SDK](https://www.npmjs.com/package/@larksuiteoapi/node-sdk)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Appendix A: Spike Code — Python Prototype (~50 lines)
|
|
||||||
|
|
||||||
> ⚠️ **Caveat:** This is a quick validation spike only. It uses text-mode WebSocket (sends/receives JSON strings) for rapid prototyping. The **production Feishu WebSocket uses binary Protobuf frames** (`pbbp2.proto`), not JSON text frames. The real Swift implementation must handle binary frame encoding/decoding, fragment reassembly, and CONTROL vs DATA frame routing. Do not use this prototype as the basis for production code.
|
|
||||||
|
|
||||||
For quick validation before full Swift implementation:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# DISCLAIMER: Spike only — uses text WebSocket for fast prototyping.
|
|
||||||
# Production must use binary Protobuf frames per §4.1.
|
|
||||||
# Do not ship this.
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import websockets
|
|
||||||
import requests
|
|
||||||
import sys
|
|
||||||
|
|
||||||
APP_ID = "cli_xxx"
|
|
||||||
APP_SECRET = "xxx"
|
|
||||||
FILE_TOKEN = "bTkAbFdN..." # Bitable token
|
|
||||||
|
|
||||||
def get_tenant_token():
|
|
||||||
r = requests.post(
|
|
||||||
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
|
|
||||||
json={"app_id": APP_ID, "app_secret": APP_SECRET}
|
|
||||||
)
|
|
||||||
return r.json()["tenant_access_token"]
|
|
||||||
|
|
||||||
def subscribe_bitable(token, file_token):
|
|
||||||
r = requests.post(
|
|
||||||
f"https://open.feishu.cn/open-apis/drive/v1/files/{file_token}/subscribe",
|
|
||||||
params={"file_type": "bitable"},
|
|
||||||
headers={"Authorization": f"Bearer {token}"}
|
|
||||||
)
|
|
||||||
print(f"Subscribe: {r.status_code} {r.json()}")
|
|
||||||
# Check for permission error — if 403, user is not owner/manager (§7.1)
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
token = get_tenant_token()
|
|
||||||
subscribe_bitable(token, FILE_TOKEN)
|
|
||||||
|
|
||||||
async with websockets.connect(
|
|
||||||
"wss://open.feishu.cn/open-apis/ws/v1/events"
|
|
||||||
) as ws:
|
|
||||||
# NOTE: Production uses binary PbFrame auth, not JSON text.
|
|
||||||
auth = json.dumps({
|
|
||||||
"type": "authentication",
|
|
||||||
"data": {"tenant_access_token": token}
|
|
||||||
})
|
|
||||||
await ws.send(auth)
|
|
||||||
print("Connected & authenticated")
|
|
||||||
|
|
||||||
async for raw in ws:
|
|
||||||
# NOTE: Production receives binary PbFrame, not JSON text.
|
|
||||||
event = json.loads(raw)
|
|
||||||
event_type = event.get("header", {}).get("event_type", "")
|
|
||||||
if "bitable_record_changed" in event_type:
|
|
||||||
print(f"🔔 Bitable changed: {json.dumps(event, indent=2)}")
|
|
||||||
# TODO: notify Bugger via localhost HTTP (wake-up signal only)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
```
|
|
||||||
|
|
||||||
## Appendix B: PbFrame Swift Encoder Sketch
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// Minimal Protobuf wire-format encoder for PbFrame
|
|
||||||
// See: https://protobuf.dev/programming-guides/encoding/
|
|
||||||
|
|
||||||
struct PbFrame {
|
|
||||||
enum Method: Int { case control = 0, data = 1 }
|
|
||||||
let method: Method
|
|
||||||
let payload: Data
|
|
||||||
let headers: [String: String]
|
|
||||||
}
|
|
||||||
|
|
||||||
enum PbFrameEncoder {
|
|
||||||
static func encode(_ frame: PbFrame) -> Data {
|
|
||||||
var data = Data()
|
|
||||||
// Field 1: method (varint, wire type 0)
|
|
||||||
data.append(encodeVarint(field: 1, wireType: 0))
|
|
||||||
data.append(encodeVarint(UInt64(frame.method.rawValue)))
|
|
||||||
// Field 2: payload (bytes, wire type 2)
|
|
||||||
data.append(encodeVarint(field: 2, wireType: 2))
|
|
||||||
data.append(encodeVarint(UInt64(frame.payload.count)))
|
|
||||||
data.append(frame.payload)
|
|
||||||
// Field 3: headers (map entries as repeated messages)
|
|
||||||
for (key, value) in frame.headers {
|
|
||||||
let entry = encodeMapEntry(key: key, value: value)
|
|
||||||
data.append(encodeVarint(field: 3, wireType: 2))
|
|
||||||
data.append(encodeVarint(UInt64(entry.count)))
|
|
||||||
data.append(entry)
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
static func decode(_ data: Data) throws -> PbFrame {
|
|
||||||
var method = Method.control, payload = Data(), headers = [String: String]()
|
|
||||||
var pos = 0
|
|
||||||
while pos < data.count {
|
|
||||||
let (fieldNum, wireType, adv) = try decodeVarint(data, pos: pos)
|
|
||||||
pos += adv
|
|
||||||
switch (fieldNum, wireType) {
|
|
||||||
case (1, 0): // method
|
|
||||||
let (v, adv) = try decodeVarint(data, pos: pos); pos += adv
|
|
||||||
method = Method(rawValue: Int(v)) ?? .control
|
|
||||||
case (2, 2): // payload
|
|
||||||
let (len, adv) = try decodeVarint(data, pos: pos); pos += adv
|
|
||||||
payload = data.subdata(in: pos..<pos+Int(len)); pos += Int(len)
|
|
||||||
case (3, 2): // headers entry
|
|
||||||
let (len, adv) = try decodeVarint(data, pos: pos); pos += adv
|
|
||||||
let (k, v) = try decodeMapEntry(data.subdata(in: pos..<pos+Int(len)))
|
|
||||||
headers[k] = v; pos += Int(len)
|
|
||||||
default: throw DecodingError.dataCorrupted(...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return PbFrame(method: method, payload: payload, headers: headers)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ... encodeVarint, decodeVarint, encodeMapEntry, decodeMapEntry helpers
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Conclusion:** Building a Swift-native `feishu-connector` daemon using WebSocket long connection is feasible with ~970 lines of Swift code for the connector and ~260 lines of changes in Bugger, with no third-party dependencies. The approach provides real-time push notifications for Bitables where the user is owner/manager, while polling remains the universal fallback. **Prerequisite:** Verify the subscribe permission boundary (Phase 0) before committing to implementation.
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# Build Bugger.app for distribution (personal use, ad-hoc signed).
|
|
||||||
# Prerequisites: Config.xcconfig with your FEISHU_APP_ID / FEISHU_APP_SECRET.
|
|
||||||
set -e
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
|
|
||||||
PROJECT="Bugger.xcodeproj"
|
|
||||||
SCHEME="Bugger"
|
|
||||||
BUILD_DIR="build"
|
|
||||||
APP_NAME="Bugger.app"
|
|
||||||
|
|
||||||
echo "=== Cleaning ==="
|
|
||||||
xcodebuild -project "$PROJECT" -scheme "$SCHEME" clean 1>/dev/null
|
|
||||||
|
|
||||||
# Use Config.xcconfig if present
|
|
||||||
XCCONFIG_FLAG=""
|
|
||||||
if [ -f "Config.xcconfig" ]; then
|
|
||||||
XCCONFIG_FLAG="-xcconfig Config.xcconfig"
|
|
||||||
echo "=== Using Config.xcconfig ==="
|
|
||||||
else
|
|
||||||
echo "=== WARNING: Config.xcconfig not found, credentials will be empty ==="
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=== Building Release ==="
|
|
||||||
xcodebuild -project "$PROJECT" -scheme "$SCHEME" \
|
|
||||||
-configuration Release \
|
|
||||||
-derivedDataPath "$BUILD_DIR/derived" \
|
|
||||||
$XCCONFIG_FLAG \
|
|
||||||
build 2>&1 | grep -E "^.../|error:|BUILD|warning:.*Swift"
|
|
||||||
|
|
||||||
# Find the built .app
|
|
||||||
APP_PATH=$(find "$BUILD_DIR/derived/Build/Products/Release" -name "*.app" -type d | head -1)
|
|
||||||
if [ -z "$APP_PATH" ]; then
|
|
||||||
echo "ERROR: Could not find built .app"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Read the marketing version from the built app so the DMG name stays in
|
|
||||||
# sync with MARKETING_VERSION / CFBundleShortVersionString automatically.
|
|
||||||
VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$APP_PATH/Contents/Info.plist")
|
|
||||||
if [ -z "$VERSION" ]; then
|
|
||||||
echo "ERROR: Could not read version from $APP_PATH/Contents/Info.plist"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Built: $APP_PATH (v$VERSION) ==="
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Copy to build root
|
|
||||||
rm -rf "$BUILD_DIR/$APP_NAME"
|
|
||||||
cp -R "$APP_PATH" "$BUILD_DIR/$APP_NAME"
|
|
||||||
|
|
||||||
# Create DMG
|
|
||||||
echo "=== Creating DMG ==="
|
|
||||||
mkdir -p "$BUILD_DIR/dmg"
|
|
||||||
rm -rf "$BUILD_DIR/dmg/"*
|
|
||||||
cp -R "$BUILD_DIR/$APP_NAME" "$BUILD_DIR/dmg/"
|
|
||||||
ln -sf /Applications "$BUILD_DIR/dmg/Applications" 2>/dev/null || true
|
|
||||||
|
|
||||||
DMG="$BUILD_DIR/Bugger-${VERSION}.dmg"
|
|
||||||
rm -f "$DMG"
|
|
||||||
hdiutil create -volname Bugger -srcfolder "$BUILD_DIR/dmg" \
|
|
||||||
-ov -format UDZO "$DMG" 1>/dev/null
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Done ==="
|
|
||||||
echo " App: $PWD/$BUILD_DIR/$APP_NAME"
|
|
||||||
echo " DMG: $PWD/$DMG"
|
|
||||||
echo ""
|
|
||||||
echo "To install:"
|
|
||||||
echo " cp -R $BUILD_DIR/$APP_NAME /Applications/"
|
|
||||||
echo ""
|
|
||||||
echo "On first launch, right-click the app → Open (Gatekeeper bypass)."
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Generate Bugger app icon (bug beetle) in all required sizes for macOS."""
|
|
||||||
import math, os, shutil
|
|
||||||
from pathlib import Path
|
|
||||||
from PIL import Image, ImageDraw
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
|
||||||
ICONSET = ROOT / "Resources" / "Assets.xcassets" / "AppIcon.appiconset"
|
|
||||||
|
|
||||||
# Required macOS icon sizes: (logical_size, scale_factor, actual_pixels)
|
|
||||||
SIZES = [
|
|
||||||
(16, 1, 16), (16, 2, 32),
|
|
||||||
(32, 1, 32), (32, 2, 64),
|
|
||||||
(128, 1, 128), (128, 2, 256),
|
|
||||||
(256, 1, 256), (256, 2, 512),
|
|
||||||
(512, 1, 512), (512, 2, 1024),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def draw_bug(canvas_size: int) -> Image.Image:
|
|
||||||
"""Draw a bug beetle icon at given canvas size. Returns RGBA image."""
|
|
||||||
img = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 0))
|
|
||||||
d = ImageDraw.Draw(img)
|
|
||||||
s = canvas_size # shorthand
|
|
||||||
|
|
||||||
# Colors
|
|
||||||
body_fill = (45, 50, 55, 255) # dark charcoal
|
|
||||||
body_highlight = (65, 72, 78, 255) # lighter shell
|
|
||||||
accent_green = (76, 188, 128, 255) # green accent
|
|
||||||
antenna = (45, 50, 55, 255)
|
|
||||||
leg = (45, 50, 55, 255)
|
|
||||||
eye = (255, 255, 255, 255)
|
|
||||||
|
|
||||||
# Margins (proportional)
|
|
||||||
margin = s * 0.12
|
|
||||||
|
|
||||||
# ---- Body (rounded oval) ----
|
|
||||||
body_rect = (
|
|
||||||
margin + s * 0.08, # left
|
|
||||||
margin + s * 0.22, # top
|
|
||||||
s - margin - s * 0.08, # right
|
|
||||||
s - margin + s * 0.05, # bottom
|
|
||||||
)
|
|
||||||
d.rounded_rectangle(body_rect, radius=s * 0.28, fill=body_fill)
|
|
||||||
|
|
||||||
# ---- Shell highlight (lighter arc on top half) ----
|
|
||||||
shell_rect = (
|
|
||||||
margin + s * 0.06,
|
|
||||||
margin + s * 0.22,
|
|
||||||
s - margin - s * 0.06,
|
|
||||||
s * 0.62,
|
|
||||||
)
|
|
||||||
d.pieslice(shell_rect, start=180, end=360, fill=body_highlight)
|
|
||||||
|
|
||||||
# ---- Green accent stripe ----
|
|
||||||
stripe_rect = (
|
|
||||||
s * 0.32,
|
|
||||||
s * 0.38,
|
|
||||||
s * 0.68,
|
|
||||||
s * 0.62,
|
|
||||||
)
|
|
||||||
d.rounded_rectangle(stripe_rect, radius=s * 0.06, fill=accent_green)
|
|
||||||
|
|
||||||
# ---- Head (small semi-circle at top) ----
|
|
||||||
head_center = (s * 0.50, s * 0.30)
|
|
||||||
head_r = s * 0.095
|
|
||||||
d.ellipse(
|
|
||||||
(head_center[0] - head_r, head_center[1] - head_r,
|
|
||||||
head_center[0] + head_r, head_center[1] + head_r),
|
|
||||||
fill=body_fill
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---- Eyes ----
|
|
||||||
eye_r = s * 0.025
|
|
||||||
eye_offset = s * 0.04
|
|
||||||
for ex in (head_center[0] - eye_offset, head_center[0] + eye_offset):
|
|
||||||
d.ellipse(
|
|
||||||
(ex - eye_r, head_center[1] - eye_r - s * 0.01,
|
|
||||||
ex + eye_r, head_center[1] + eye_r - s * 0.01),
|
|
||||||
fill=eye
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---- Antennae (two curved lines) ----
|
|
||||||
ant_line_w = max(2, int(s * 0.015))
|
|
||||||
ant_start_y = head_center[1] - head_r + s * 0.01
|
|
||||||
for side, curve_dir in [(-1, -0.08), (1, 0.08)]:
|
|
||||||
ant_start_x = head_center[0] + side * s * 0.04
|
|
||||||
points = [
|
|
||||||
(ant_start_x, ant_start_y),
|
|
||||||
(ant_start_x + side * s * 0.06, ant_start_y - s * 0.08),
|
|
||||||
(ant_start_x + side * s * 0.14, ant_start_y - s * 0.18 + curve_dir * s),
|
|
||||||
(ant_start_x + side * s * 0.18 + curve_dir * s * 0.5, ant_start_y - s * 0.17),
|
|
||||||
]
|
|
||||||
for i in range(len(points) - 1):
|
|
||||||
d.line([points[i], points[i + 1]], fill=antenna, width=ant_line_w)
|
|
||||||
# dot at tip
|
|
||||||
tip_r = max(1, int(s * 0.012))
|
|
||||||
tip = points[-1]
|
|
||||||
d.ellipse((tip[0] - tip_r, tip[1] - tip_r, tip[0] + tip_r, tip[1] + tip_r),
|
|
||||||
fill=antenna)
|
|
||||||
|
|
||||||
# ---- Legs (3 per side) ----
|
|
||||||
leg_w = max(2, int(s * 0.018))
|
|
||||||
leg_positions = [0.45, 0.56, 0.67] # y-positions as fraction of body height
|
|
||||||
body_left = margin + s * 0.02
|
|
||||||
body_right = s - margin - s * 0.02
|
|
||||||
|
|
||||||
for frac in leg_positions:
|
|
||||||
leg_y = s * (0.22 + frac * 0.57)
|
|
||||||
# Left legs
|
|
||||||
d.line(
|
|
||||||
[(body_left, leg_y),
|
|
||||||
(body_left - s * 0.16, leg_y - s * 0.08),
|
|
||||||
(body_left - s * 0.22, leg_y + s * 0.04)],
|
|
||||||
fill=leg, width=leg_w
|
|
||||||
)
|
|
||||||
# Right legs
|
|
||||||
d.line(
|
|
||||||
[(body_right, leg_y),
|
|
||||||
(body_right + s * 0.16, leg_y - s * 0.08),
|
|
||||||
(body_right + s * 0.22, leg_y + s * 0.04)],
|
|
||||||
fill=leg, width=leg_w
|
|
||||||
)
|
|
||||||
|
|
||||||
return img
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ICONSET.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Generate the largest size and downscale
|
|
||||||
master = draw_bug(1024)
|
|
||||||
|
|
||||||
images = []
|
|
||||||
for logical, scale, pixels in SIZES:
|
|
||||||
if pixels == 1024:
|
|
||||||
img = master
|
|
||||||
else:
|
|
||||||
img = master.resize((pixels, pixels), Image.LANCZOS)
|
|
||||||
|
|
||||||
filename = f"icon_{logical}x{logical}@x{scale}.png"
|
|
||||||
filepath = ICONSET / filename
|
|
||||||
img.save(filepath, "PNG")
|
|
||||||
images.append({
|
|
||||||
"size": f"{logical}x{logical}",
|
|
||||||
"scale": f"{scale}x",
|
|
||||||
"filename": filename,
|
|
||||||
})
|
|
||||||
print(f" {filename} ({pixels}×{pixels})")
|
|
||||||
|
|
||||||
# Update Contents.json
|
|
||||||
contents = {
|
|
||||||
"images": [],
|
|
||||||
"info": {"author": "xcode", "version": 1},
|
|
||||||
}
|
|
||||||
for img in images:
|
|
||||||
contents["images"].append({
|
|
||||||
"size": img["size"],
|
|
||||||
"idiom": "mac",
|
|
||||||
"filename": img["filename"],
|
|
||||||
"scale": img["scale"],
|
|
||||||
})
|
|
||||||
|
|
||||||
import json
|
|
||||||
contents_path = ICONSET / "Contents.json"
|
|
||||||
with open(contents_path, "w") as f:
|
|
||||||
json.dump(contents, f, indent=2)
|
|
||||||
print(f"\nWrote {contents_path}")
|
|
||||||
print(f"Generated {len(images)} icon sizes in {ICONSET}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -337,7 +337,7 @@ def build_pbxproj() -> str:
|
||||||
target_settings = [
|
target_settings = [
|
||||||
("ASSETCATALOG_COMPILER_APPICON_NAME", "AppIcon"),
|
("ASSETCATALOG_COMPILER_APPICON_NAME", "AppIcon"),
|
||||||
("COMBINE_HIDPI_IMAGES", "YES"),
|
("COMBINE_HIDPI_IMAGES", "YES"),
|
||||||
("CURRENT_PROJECT_VERSION", "5"),
|
("CURRENT_PROJECT_VERSION", "1"),
|
||||||
("FEISHU_APP_ID", '"$(FEISHU_APP_ID)"'),
|
("FEISHU_APP_ID", '"$(FEISHU_APP_ID)"'),
|
||||||
("FEISHU_APP_SECRET", '"$(FEISHU_APP_SECRET)"'),
|
("FEISHU_APP_SECRET", '"$(FEISHU_APP_SECRET)"'),
|
||||||
("FEISHU_BASE_DOMAIN", '"xorbitlab.feishu.cn"'),
|
("FEISHU_BASE_DOMAIN", '"xorbitlab.feishu.cn"'),
|
||||||
|
|
@ -346,7 +346,7 @@ def build_pbxproj() -> str:
|
||||||
("LD_RUNPATH_SEARCH_PATHS",
|
("LD_RUNPATH_SEARCH_PATHS",
|
||||||
'"$(inherited) @executable_path/../Frameworks"'),
|
'"$(inherited) @executable_path/../Frameworks"'),
|
||||||
("MACOSX_DEPLOYMENT_TARGET", "14.0"),
|
("MACOSX_DEPLOYMENT_TARGET", "14.0"),
|
||||||
("MARKETING_VERSION", "1.5"),
|
("MARKETING_VERSION", "1.0"),
|
||||||
("PRODUCT_BUNDLE_IDENTIFIER", BUNDLE_ID),
|
("PRODUCT_BUNDLE_IDENTIFIER", BUNDLE_ID),
|
||||||
("PRODUCT_NAME", '"$(TARGET_NAME)"'),
|
("PRODUCT_NAME", '"$(TARGET_NAME)"'),
|
||||||
("SWIFT_EMIT_LOC_STRINGS", "YES"),
|
("SWIFT_EMIT_LOC_STRINGS", "YES"),
|
||||||
|
|
|
||||||