Compare commits

...

36 Commits

Author SHA1 Message Date
tigerenwork 44e53e373d chore: 版本号更新至 1.5 (build 5) 2026-07-26 23:01:01 +08:00
tigerenwork 5aef88c6bf feat: default to Chinese Feishu field mappings 2026-07-26 22:53:24 +08:00
tigerenwork 88f89cc918 feat: 新增 textValue 支持结构化字段内容展示 2026-07-26 22:33:54 +08:00
tigerenwork 55d0f86392 fix: 用 FetchGate actor 修复并发触发丢失问题 2026-07-19 15:10:46 +08:00
tigerenwork b5ad9c1ec3 refactor: 重构设置页面布局,用自定义分区和标签行替代 Form 2026-07-11 01:32:47 +08:00
tigerenwork 7a2508a449 refactor: 移除飞书 API 的 field_names 参数及 configFieldNames 方法 2026-07-11 00:31:50 +08:00
tigerenwork a0b1fb8aa3 chore: 升级版本至 1.4 并将校准设置移至独立 Section 2026-07-10 02:07:43 +08:00
tigerenwork be384c9238 feat: 增强推送可靠性:指数退避重连、校准服务与飞书按人过滤查询 2026-07-10 01:59:05 +08:00
tigerenwork 01db8e5347 chore: 版本升级至 1.3 并更新构建号
- MARKETING_VERSION 更新为 1.3
- CURRENT_PROJECT_VERSION 更新为 4
- Info.plist 同步更新版本号与构建号
2026-07-08 12:06:16 +08:00
tigerenwork c275c71b55 feat(bitable): 支持通过 SSE 订阅 Bitable 变更并实时刷新
- 新增 BitableEventService,通过 SSE 监听飞书变更推送,收到 change 事件即触发 fetchNow
- AppConfig 新增 feishuAppBaseURL 字段,置空时禁用实时推送、回退轮询
- 设置页新增 "Real-time updates" 区块,支持配置订阅地址并随保存重连
- AppDelegate 启动时自动连接 SSE;断开飞书时同步断开
- 流断开后 30s 自动重连,显式 disconnect 不重连
- Info.plist 放开 ATS 以支持 HTTP 订阅服务(localhost/局域网)
2026-07-08 11:26:47 +08:00
tigerenwork 4d67d8bffd feat: 支持构建时注入飞书配置并优化设置页面体验 2026-07-05 22:57:36 +08:00
tigerenwork 5e4587909d chmod: 为 build_release.sh 添加可执行权限 2026-07-05 21:13:45 +08:00
tigerenwork 244da37318 fix: 修复设置窗口无法置前的问题 2026-07-05 21:10:51 +08:00
tigerenwork 71e56f8d2b chore: 版本升级至 1.2 并优化构建脚本版本读取 2026-07-05 20:42:19 +08:00
tigerenwork 239403e75c fix: 过滤已关闭/已解决 bug 的变更通知 2026-07-05 20:31:25 +08:00
tigerenwork 55d8b50f1d fix: 增强配置容错与启动稳定性,优化错误状态交互 2026-07-05 20:25:06 +08:00
tigerenwork 7047c51a07 feat: 浮动窗口支持自适应尺寸并锚定右下角 2026-07-05 16:14:58 +08:00
tigerenwork 9b99d9c1ff Merge branch 'develop' of http://192.168.2.212:3000/xorbitlab/bugger into develop 2026-07-02 10:03:02 +08:00
tigerenwork 8286c81afb WIP:feat: capture and log raw response body in Feishu decoding error for better debugging 2026-07-02 10:02:02 +08:00
tigerenwork d34984d480 docs: 更新 Bitable 变更通知实现方案为 SSE 架构 2026-07-01 01:37:57 +08:00
tigerenwork 0adfaf1d7e docs: add Bitable change notification implementation guide and technical investigation
- TECH_INVESTIGATION.md: survey of Feishu event subscription options,
  WebSocket protocol details, architecture proposal with permission
  showstopper analysis for user-level tokens (~711 lines)
- BITABLE_CHANGE_NOTIFICATION_IMPL.md: concrete implementation plan
  reusing existing feishu-app's app-level tenant_access_token to
  bypass the owner/manager subscribe restriction. Covers Feishu
  Open Platform config, feishu-app Python changes (~265 LOC),
  Bugger Swift changes (~115 LOC), and testing procedures.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-30 23:43:59 +08:00
tigerenwork 3f1367790e fix: timestamp auto-updates without triggering fetch on tray open
- Reverted auto-refresh on tray click (fetch only happens on schedule
  or manual Refresh button)
- Timestamp now uses TimelineView(.periodic) to auto-update every 30s:
  'Just now' → '1m ago' → '5m ago' → etc. without any user action

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-29 23:54:28 +08:00
tigerenwork 5b1811db10 fix: auto-refresh on tray click, widen 'Just now' to 60s
- Opening the popover now triggers a fetch (via PollerService.fetchNow
  which deduplicates if already in progress)
- 'Just now' now covers 0-60s, then switches to 'Xm ago'.
  Avoids the frozen-timer look since the text only updates on re-render.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-29 23:51:48 +08:00
tigerenwork 2f2a65f1f7 fix: show 'Just now' instead of '1s ago' after refresh
Replaced RelativeDateTimeFormatter with manual formatting:
<5s → 'Just now', <60s → 'Xs ago', <1h → 'Xm ago',
<1d → 'Xh ago', else 'Xd ago'.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-29 23:49:23 +08:00
tigerenwork 07ac29faa6 feat: show refresh icon + last-updated time in popover header
Header now always shows: ↻ 2m ago  (or 'Never' before first fetch).
Refresh spinner replaces the text while loading. More discoverable
than the bare relative timestamp that was there before.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-29 23:45:24 +08:00
tigerenwork f4668b38d6 fix: build_release.sh now passes Config.xcconfig to xcodebuild
The Release build wasn't picking up FEISHU_APP_ID / FEISHU_APP_SECRET
because xcodebuild doesn't automatically use Config.xcconfig unless
it's passed with -xcconfig flag. Added auto-detection: if Config.xcconfig
exists, it's used; otherwise a warning is printed.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-29 23:38:21 +08:00
tigerenwork 20824443cc feat: generate app icon (bug beetle design)
Python PIL script generates a bug beetle icon with:
- Dark charcoal body with lighter shell highlight
- Green accent stripe across the wing casing
- Head with white eyes, two curved antennae with dot tips
- Six legs (3 per side)
- All 10 required macOS sizes (16..512 at @1x/@2x)

Run: python3 scripts/generate_app_icon.py

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-29 23:26:23 +08:00
tigerenwork f7682ae7cf chore: bump version to 1.1 (build 2) 2026-06-29 21:55:01 +08:00
tigerenwork 9154264ea7 fix: replace daily time text field with native DatePickers
- Daily schedule mode now shows macOS-native hour:minute DatePickers
  instead of a comma-separated text field
- Each time entry has its own picker with a remove button
- '+ Add time' button to add more entries
- Minimum 1 entry enforced (remove disabled when only 1 left)
- No hardcoded defaults — starts empty, user picks their times

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-29 21:52:37 +08:00
tigerenwork b81d3edf42 feat: add 8h/12h intervals + daily schedule refresh mode
- Added 8-hour and 12-hour polling interval options
- Added 'Daily schedule' mode (pollIntervalSeconds = -1): fires at
  specific wall-clock times configured in dailyRefreshTimes (e.g. 08:00, 14:00)
- PollerService: in daily mode, checks every 60s and triggers fetch when
  current time matches a scheduled time (with 2-min dedup window)
- Settings UI: shows HH:mm text field when daily schedule is selected
- AppConfig: added dailyRefreshTimes: [String] field

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-29 21:08:51 +08:00
tigerenwork 64a35752c5 fix: use rawValue for customer field to handle all Feishu field types 2026-06-29 21:03:27 +08:00
tigerenwork fe6dddecf9 fix: move customer between status and age in bug row 2026-06-29 20:57:11 +08:00
tigerenwork 883d7cb608 chore: change customerField default to CustomerName for consistency 2026-06-29 20:51:21 +08:00
tigerenwork 0ce557ac8e feat: add customer field to Bug model with configurable mapping
- Bug model: added optional 'customer' property
- FieldMappings: added 'customerField' (default: 客户名称)
- BugMapper: reads customer value from configured column
- BugRow: displays customer in blue when present
- Settings: Customer field text field in Field Mappings section

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-29 20:50:46 +08:00
tigerenwork 7590389a04 feat: add Quit button, extended polling intervals
- Popover footer now has a Quit button (power icon) to terminate the app
- Polling interval picker now includes 1h, 2h, 4h, and 1 day options

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-29 01:17:07 +08:00
tigerenwork b207918fc6 feat: add release build script (app + DMG)
build_release.sh: Clean → Build Release → Create DMG.
Outputs build/Bugger.app and build/Bugger-1.0.dmg.
Uses ad-hoc signing (no developer cert needed).
Supports both arm64 and x86_64 architectures.

Usage: ./scripts/build_release.sh

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-29 01:08:10 +08:00
40 changed files with 4623 additions and 187 deletions

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ DerivedData/
xcuserdata/
Config.xcconfig
.DS_Store
.idea/

View File

@ -31,6 +31,8 @@
CAE624453A3F0375D872170A /* FeishuModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9FBC39502FA635EF1A6A5C /* FeishuModels.swift */; };
CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A83E76FBB83849E4B6704D3 /* FeishuError.swift */; };
D2855F82E68FADA8BEA43086 /* AppStateService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */; };
7E1F2A3B4C5D6E7F8091A2B3 /* BitableEventService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F2E3D4C5B6A7988061A2B3C /* BitableEventService.swift */; };
A1B2C3D4E5F60718293A4B5C /* CalibrationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F70819293A4B5 /* CalibrationService.swift */; };
DDB9EB345FEBB95DD9976364 /* BugChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B24E1E1D32F7B774D12DD85 /* BugChange.swift */; };
E4CBAE27491AF1C52944FA91 /* BugStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646C676356A4125B8E8D5FA5 /* BugStatus.swift */; };
EEC5853D00ED565270DC867F /* LocalOAuthServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D11523ACB81DBB9D412995 /* LocalOAuthServer.swift */; };
@ -67,6 +69,8 @@
EF0182DF85A7CD0319333F24 /* Bugger.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Bugger.app; sourceTree = BUILT_PRODUCTS_DIR; };
EF06E1030AA21DA8190169A8 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; };
F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateService.swift; sourceTree = "<group>"; };
8F2E3D4C5B6A7988061A2B3C /* BitableEventService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitableEventService.swift; sourceTree = "<group>"; };
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>"; };
FE9FBC39502FA635EF1A6A5C /* FeishuModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuModels.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@ -171,6 +175,8 @@
isa = PBXGroup;
children = (
F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */,
8F2E3D4C5B6A7988061A2B3C /* BitableEventService.swift */,
1A2B3C4D5E6F70819293A4B5 /* CalibrationService.swift */,
59CC175DE8E9FE3250757D33 /* NotificationService.swift */,
4F4628E7B599B69B499E09E9 /* PollerService.swift */,
0E7E3775F77DD36C27160A8C /* TokenManager.swift */,
@ -300,6 +306,8 @@
A33561284CA44A0045902BCC /* BugPriority.swift in Sources */,
E4CBAE27491AF1C52944FA91 /* BugStatus.swift in Sources */,
D2855F82E68FADA8BEA43086 /* AppStateService.swift in Sources */,
7E1F2A3B4C5D6E7F8091A2B3 /* BitableEventService.swift in Sources */,
A1B2C3D4E5F60718293A4B5C /* CalibrationService.swift in Sources */,
4309E8C70AE99466545A3E37 /* FeishuAuthService.swift in Sources */,
CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */,
CAE624453A3F0375D872170A /* FeishuModels.swift in Sources */,
@ -353,10 +361,10 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 5;
FEISHU_APP_ID = cli_aacb82a1c0b8dcc0;
FEISHU_APP_SECRET = jUkU9ygRuQ0KaZFV2BSPfe33iXCahzNM;
FEISHU_BASE_DOMAIN = mi5p6bgsnf8.feishu.cn;
FEISHU_BASE_DOMAIN = xmi5p6bgsnf8.feishu.cn;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Resources/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@ -364,7 +372,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.5;
PRODUCT_BUNDLE_IDENTIFIER = com.xorbitlab.bugger;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@ -377,10 +385,10 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 5;
FEISHU_APP_ID = cli_aacb82a1c0b8dcc0;
FEISHU_APP_SECRET = jUkU9ygRuQ0KaZFV2BSPfe33iXCahzNM;
FEISHU_BASE_DOMAIN = mi5p6bgsnf8.feishu.cn;
FEISHU_BASE_DOMAIN = xmi5p6bgsnf8.feishu.cn;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Resources/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@ -388,7 +396,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.5;
PRODUCT_BUNDLE_IDENTIFIER = com.xorbitlab.bugger;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;

View File

@ -4,3 +4,8 @@
FEISHU_APP_ID = cli_your_app_id
FEISHU_APP_SECRET = your_app_secret
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

View File

@ -1,58 +1,68 @@
{
"images" : [
"images": [
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
"size": "16x16",
"idiom": "mac",
"filename": "icon_16x16@x1.png",
"scale": "1x"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
"size": "16x16",
"idiom": "mac",
"filename": "icon_16x16@x2.png",
"scale": "2x"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
"size": "32x32",
"idiom": "mac",
"filename": "icon_32x32@x1.png",
"scale": "1x"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
"size": "32x32",
"idiom": "mac",
"filename": "icon_32x32@x2.png",
"scale": "2x"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
"size": "128x128",
"idiom": "mac",
"filename": "icon_128x128@x1.png",
"scale": "1x"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
"size": "128x128",
"idiom": "mac",
"filename": "icon_128x128@x2.png",
"scale": "2x"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
"size": "256x256",
"idiom": "mac",
"filename": "icon_256x256@x1.png",
"scale": "1x"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
"size": "256x256",
"idiom": "mac",
"filename": "icon_256x256@x2.png",
"scale": "2x"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
"size": "512x512",
"idiom": "mac",
"filename": "icon_512x512@x1.png",
"scale": "1x"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
"size": "512x512",
"idiom": "mac",
"filename": "icon_512x512@x2.png",
"scale": "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
"info": {
"author": "xcode",
"version": 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -15,18 +15,27 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<string>1.5</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>5</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>FEISHU_APP_ID</key>
<string>$(FEISHU_APP_ID)</string>
<key>FEISHU_APP_SECRET</key>
<string>$(FEISHU_APP_SECRET)</string>
<key>FEISHU_BASE_DOMAIN</key>
<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>
</plist>

View File

@ -28,6 +28,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
Task {
await PollerService.shared.startIfConfigured()
// Real-time change push is non-blocking; no-op if the subscribe
// base URL isn't configured (falls back to polling).
BitableEventService.shared.connect()
CalibrationService.shared.start()
}
}

View File

@ -8,6 +8,6 @@ struct BuggerApp: App {
Settings {
SettingsView()
}
.windowResizability(.contentSize)
.windowResizability(.contentMinSize)
}
}

View File

@ -1,23 +1,42 @@
import Foundation
struct AppConfig: Codable, Equatable {
var appToken: String = ""
var tableId: String = ""
var appToken: String = BundledDefault.appToken
var tableId: String = BundledDefault.tableId
var assigneeName: String = ""
var fieldMappings: FieldMappings = FieldMappings()
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 launchAtLogin: Bool = true
var feishuBaseDomain: String = "xorbitlab.feishu.cn"
var feishuBaseDomain: String = BundledDefault.feishuBaseDomain
/// 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 {
var titleField: String = "Title"
var priorityField: String = "Priority"
var statusField: String = "Status"
var assigneeField: String = "Assignee"
var reporterField: String = "Reporter"
var createdAtField: String = "Created At"
var updatedAtField: String = "Updated At"
var titleField: String = "问题描述"
var priorityField: String = "优先级"
var statusField: String = "状态"
var assigneeField: String = "负责人"
var reporterField: String = "创建人"
var customerField: String = "客户名称"
var createdAtField: String = "提出时间"
var updatedAtField: String = "最后更新时间"
/// Map Feishu display values to standard BugStatus cases.
/// Keys are the Feishu select-option text; values are the standard status.
@ -45,4 +64,47 @@ struct AppConfig: Codable, Equatable {
var isConfigured: Bool {
!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)
}
}

View File

@ -7,9 +7,10 @@ struct Bug: Identifiable, Equatable, Hashable {
let status: BugStatus
let assignee: String
let reporter: String?
let customer: String?
let createdAt: Date
let updatedAt: Date
let feishuURL: URL
let feishuURL: URL?
var age: TimeInterval {
Date().timeIntervalSince(createdAt)

View File

@ -0,0 +1,259 @@
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)
}
}
}

View File

@ -0,0 +1,124 @@
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
}
}

View File

@ -80,6 +80,11 @@ final class FeishuAuthService {
}
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")!
var request = URLRequest(url: url)
request.httpMethod = "GET"
@ -88,7 +93,7 @@ final class FeishuAuthService {
guard let data = response.data else {
throw FeishuError.apiError(code: response.code, message: response.msg)
}
return data.name ?? data.enName ?? "Unknown"
return data
}
private func tenantAccessToken() async throws -> String {

View File

@ -4,9 +4,11 @@ enum FeishuError: Error, LocalizedError {
case unauthorized
case networkError(Error?)
case apiError(code: Int, message: String)
case decodingError(Error)
case decodingError(Error, rawBody: String? = nil)
case notConfigured
case missingCredentials
case invalidConfiguration(String)
case paginationLimitExceeded
var errorDescription: String? {
switch self {
@ -16,12 +18,19 @@ enum FeishuError: Error, LocalizedError {
return "Network error: \(error?.localizedDescription ?? "Unknown")"
case .apiError(let code, let message):
return "Feishu API error (\(code)): \(message)"
case .decodingError:
return "Failed to parse Feishu response. Field mappings may be incorrect."
case .decodingError(let error, let rawBody):
if let rawBody {
return "Failed to parse Feishu response: \(rawBody)"
}
return "Failed to parse Feishu response. Field mappings may be incorrect. (\(error.localizedDescription))"
case .notConfigured:
return "Feishu table not configured. Open Settings."
case .missingCredentials:
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."
}
}
}

View File

@ -55,6 +55,9 @@ struct UserInfoData: Decodable {
let name: String?
// en_name enName via .convertFromSnakeCase
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 {
@ -93,6 +96,26 @@ enum JSONValue: Decodable {
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? {
guard case let .array(items) = self else { return nil }
for item in items {
@ -120,6 +143,7 @@ enum BugMapper {
status: mapStatus(stringValue(in: fields, key: mappings.statusField), mappings: mappings),
assignee: userName(in: fields, key: mappings.assigneeField) ?? "Unknown",
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,
updatedAt: FeishuDateParser.parse(rawValue(in: fields, key: mappings.updatedAtField)) ?? .distantPast,
feishuURL: FeishuURLBuilder.recordURL(
@ -138,21 +162,16 @@ enum BugMapper {
case let .number(number): return number
case let .bool(bool): return bool
case let .array(array):
return array.compactMap { item -> String? in
if case let .object(fields) = item {
return fields["name"]?.stringValue
}
return item.stringValue
}
return array.compactMap(\.textValue)
case let .object(object):
return object["name"]?.stringValue ?? object["text"]?.stringValue
return object["name"]?.textValue ?? object["text"]?.textValue
case .null:
return nil
}
}
private static func stringValue(in fields: [String: JSONValue], key: String) -> String? {
fields[key]?.stringValue
fields[key]?.textValue
}
private static func userName(in fields: [String: JSONValue], key: String) -> String? {

View File

@ -15,23 +15,16 @@ final class FeishuService {
func fetchBugs(
config: AppConfig,
assigneeName: String,
accessToken: String
accessToken: String,
userOpenId: String? = nil
) async throws -> [Bug] {
var allRecords: [RecordItem] = []
var pageToken: String?
repeat {
let page = try await fetchPage(
appToken: config.appToken,
tableId: config.tableId,
pageToken: pageToken,
accessToken: accessToken
let records = try await fetchRecordsFiltered(
config: config,
assigneeName: assigneeName,
accessToken: accessToken,
userOpenId: userOpenId
)
allRecords.append(contentsOf: page.items)
pageToken = page.hasMore ? page.pageToken : nil
} while pageToken != nil
return allRecords
return records
.map { BugMapper.map($0, config: config) }
.filter { bug in
bug.assignee.localizedCaseInsensitiveContains(assigneeName)
@ -53,23 +46,124 @@ final class FeishuService {
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(
appToken: String,
tableId: String,
pageToken: String?,
pageSize: Int = 500,
accessToken: String
accessToken: String,
filter: String? = nil
) async throws -> RecordListData {
var components = URLComponents(
guard !appToken.isEmpty, !tableId.isEmpty else {
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"
)!
) else {
throw FeishuError.invalidConfiguration("App token or table ID contains invalid characters.")
}
var queryItems = [URLQueryItem(name: "page_size", value: String(pageSize))]
if let pageToken {
queryItems.append(URLQueryItem(name: "page_token", value: pageToken))
}
if let filter, !filter.isEmpty {
queryItems.append(URLQueryItem(name: "filter", value: filter))
}
components.queryItems = queryItems
var request = URLRequest(url: components.url!)
guard let url = components.url else {
throw FeishuError.invalidConfiguration("Could not build records request URL.")
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.timeoutInterval = 30
@ -78,6 +172,10 @@ final class FeishuService {
guard let httpResponse = response as? HTTPURLResponse else {
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 {
throw FeishuError.unauthorized
}
@ -86,7 +184,109 @@ final class FeishuService {
do {
apiResponse = try decoder.decode(FeishuAPIResponse<RecordListData>.self, from: data)
} catch {
throw FeishuError.decodingError(error)
BuggerLog.error("fetchPage: decode FAILED: \(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 {

View File

@ -24,7 +24,11 @@ final class NotificationService: NSObject, UNUserNotificationCenterDelegate {
guard isAuthorized else { return }
let significant = changes
.filter { $0.type == .newBug || $0.type == .priorityChanged }
.filter { change in
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)
for change in significant {
@ -56,10 +60,11 @@ final class NotificationService: NSObject, UNUserNotificationCenterDelegate {
return
}
content.userInfo = [
"bugId": change.bug.id,
"feishuURL": change.bug.feishuURL.absoluteString
]
var userInfo: [String: Any] = ["bugId": change.bug.id]
if let url = change.bug.feishuURL {
userInfo["feishuURL"] = url.absoluteString
}
content.userInfo = userInfo
let request = UNNotificationRequest(
identifier: "bugger-\(change.bug.id)-\(Date().timeIntervalSince1970)",

View File

@ -11,7 +11,8 @@ final class PollerService {
private let configService = AppStateService.shared
private var timer: Timer?
private var isFetching = false
private var lastFetchTime: Date?
private let fetchGate = FetchGate()
private(set) var isRunning = false
@ -23,20 +24,30 @@ final class PollerService {
tokenManager.isAuthenticated else {
return
}
start(interval: TimeInterval(config.pollIntervalSeconds))
start(interval: TimeInterval(config.pollIntervalSeconds), fetchImmediately: config.refreshOnStart)
}
func start(interval: TimeInterval) {
func start(interval: TimeInterval, fetchImmediately: Bool = true) {
guard !isRunning else { return }
isRunning = true
if fetchImmediately {
Task { await performFetch() }
}
if interval < 0 {
// Daily schedule mode: check every 60s, fire at configured times
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
}
}
func stop() {
timer?.invalidate()
@ -53,10 +64,75 @@ final class PollerService {
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 ABA 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 {
guard !isFetching else { return }
isFetching = true
defer { isFetching = false }
guard await fetchGate.tryBegin() else { return }
repeat {
await fetchOnce()
} while await fetchGate.endRound()
}
private func fetchOnce() async {
lastFetchTime = Date()
await MainActor.run {
bugStore.setLoading(true)
@ -67,10 +143,12 @@ final class PollerService {
let config = try getConfig()
let token = try await tokenManager.getAccessToken()
let assignee = try await tokenManager.resolveAssigneeName(config: config, accessToken: token)
let openId = tokenManager.cachedOpenId
let bugs = try await feishuService.fetchBugs(
config: config,
assigneeName: assignee,
accessToken: token
accessToken: token,
userOpenId: openId
)
await MainActor.run {

View File

@ -10,6 +10,7 @@ final class TokenManager {
private let accessTokenKey = "feishu.access_token"
private let refreshTokenKey = "feishu.refresh_token"
private let tokenExpiryKey = "feishu.token_expiry"
private let openIdKey = "feishu.open_id"
enum State: Equatable {
case unauthenticated
@ -38,6 +39,18 @@ final class TokenManager {
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 {
if let token = keychain.read(accessTokenKey),
let expiry = UserDefaults.standard.object(forKey: tokenExpiryKey) as? Date,
@ -83,6 +96,7 @@ final class TokenManager {
func clearTokens() {
keychain.delete(accessTokenKey)
keychain.delete(refreshTokenKey)
keychain.delete(openIdKey)
UserDefaults.standard.removeObject(forKey: tokenExpiryKey)
state = .unauthenticated
}
@ -105,6 +119,11 @@ final class TokenManager {
refresh: response.refreshToken ?? "",
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
BuggerLog.info("handleCallback: done, authenticated ✓")
}
@ -113,7 +132,11 @@ final class TokenManager {
if !config.assigneeName.isEmpty {
return config.assigneeName
}
return try await authService.fetchCurrentUserName(accessToken: accessToken)
let userInfo = try await authService.fetchUserInfo(accessToken: accessToken)
if let openId = userInfo.openId {
storeOpenId(openId)
}
return userInfo.name ?? userInfo.enName ?? "Unknown"
}
}

View File

@ -6,12 +6,26 @@ enum FeishuURLBuilder {
appToken: String,
tableId: String,
recordId: String
) -> URL {
// Feishu Bitable record deep link: uses query parameters
URL(string: "https://\(baseDomain)/base/\(appToken)?table=\(tableId)&record=\(recordId)")!
) -> URL? {
// Feishu Bitable record deep link: uses query parameters.
// Returns nil for malformed inputs (e.g. config containing spaces or
// 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 {
URL(string: "https://\(baseDomain)/base/\(appToken)")!
static func tableURL(baseDomain: String, appToken: String) -> URL? {
var components = URLComponents()
components.scheme = "https"
components.host = baseDomain
components.path = "/base/\(appToken)"
return components.url
}
}

View File

@ -2,9 +2,12 @@ import AppKit
import SwiftUI
final class FloatingWidgetWindow: NSPanel {
private let margin: CGFloat = 16
private let bottomOffset: CGFloat = 200
init() {
super.init(
contentRect: NSRect(x: 0, y: 0, width: 140, height: 70),
contentRect: NSRect(x: 0, y: 0, width: 1, height: 1),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
@ -20,20 +23,63 @@ final class FloatingWidgetWindow: NSPanel {
hidesOnDeactivate = false
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 {
let screenFrame = screen.visibleFrame
setFrameOrigin(NSPoint(x: screenFrame.maxX - 100, y: screenFrame.minY + 200))
setFrameOrigin(NSPoint(
x: screenFrame.maxX - 1 - margin,
y: screenFrame.minY + bottomOffset
))
}
contentView = NSHostingView(rootView: FloatingWidgetView())
let rootView = FloatingWidgetView { [weak self] size in
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 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 {
@State private var bugStore = BugStore.shared
let onSizeChange: (CGSize) -> Void
private var priorityCounts: [(BugPriority, Int)] {
let active = bugStore.activeBugs
@ -56,6 +102,7 @@ struct FloatingWidgetView: View {
Text("\(bugStore.activeBugs.count)")
.font(.title3)
.fontWeight(.bold)
.monospacedDigit()
}
if !priorityCounts.isEmpty {
HStack(spacing: 6) {
@ -67,6 +114,7 @@ struct FloatingWidgetView: View {
Text("\(count)")
.font(.caption2)
.fontWeight(.medium)
.monospacedDigit()
}
}
}
@ -81,5 +129,17 @@ struct FloatingWidgetView: View {
)
}
.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)
}
}
)
}
}

View File

@ -4,6 +4,7 @@ import SwiftUI
struct BugListPopover: View {
@State private var bugStore = BugStore.shared
@State private var tokenManager = TokenManager.shared
@Environment(\.openSettings) private var openSettings
var body: some View {
VStack(spacing: 0) {
@ -27,12 +28,22 @@ struct BugListPopover: View {
ProgressView()
.scaleEffect(0.7)
.frame(width: 16, height: 16)
}
} else {
Image(systemName: "arrow.triangle.2.circlepath")
.font(.caption2)
.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") {
bugStore.markAllSeen()
AppDelegate.shared?.updateBadge(count: bugStore.activeBugs.count)
@ -80,9 +91,21 @@ struct BugListPopover: View {
Button("Refresh") {
Task { await PollerService.shared.fetchNow() }
}
SettingsLink {
Button {
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")
}
Button(action: { NSApp.terminate(nil) }) {
Label("Quit", systemImage: "power")
}
}
.padding(.horizontal)
.padding(.vertical, 6)
@ -91,23 +114,34 @@ struct BugListPopover: View {
}
private func openInFeishu(_ bug: Bug) {
NSWorkspace.shared.open(bug.feishuURL)
guard let url = bug.feishuURL else { return }
NSWorkspace.shared.open(url)
AppDelegate.shared?.closePopover()
}
private func openFeishuTable() {
guard let config = AppStateService.shared.config else { return }
guard let config = AppStateService.shared.config,
let url = FeishuURLBuilder.tableURL(
baseDomain: config.feishuBaseDomain,
appToken: config.appToken
)
) else { return }
NSWorkspace.shared.open(url)
}
private func formatted(_ date: Date) -> String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return formatter.localizedString(for: date, relativeTo: Date())
let seconds = Int(Date().timeIntervalSince(date))
switch seconds {
case ..<60: return "Just now"
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"
}
}
}
@ -142,6 +176,11 @@ struct ErrorStateView: View {
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button("Retry") {
Task { await PollerService.shared.fetchNow() }
}
.buttonStyle(.bordered)
.padding(.top, 4)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()

View File

@ -21,6 +21,11 @@ struct BugRow: View {
}
HStack(spacing: 8) {
StatusPill(status: bug.status)
if let customer = bug.customer {
Text(customer)
.font(.caption2)
.foregroundStyle(.blue.opacity(0.8))
}
Text(ageString(bug.age))
.font(.caption2)
.foregroundStyle(.secondary)

View File

@ -1,6 +1,10 @@
import ServiceManagement
import SwiftUI
// MARK: - Constants
private let kLabelWidth: CGFloat = 130
struct SettingsView: View {
@State private var config: AppConfig
@State private var isTestingConnection = false
@ -13,45 +17,84 @@ struct SettingsView: View {
var body: some View {
ScrollView {
Form {
Section("Feishu Bitable") {
TextField("App Token (from Bitable URL)", text: $config.appToken)
TextField("Table ID", text: $config.tableId)
TextField("Your Name (as in Assignee column, optional)", text: $config.assigneeName)
TextField("Feishu domain", text: $config.feishuBaseDomain)
VStack(alignment: .leading, spacing: 20) {
// MARK: Your Name card
yourNameCard
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Button("Test Connection") {
testConnection()
// MARK: Feishu Bitable
settingsSection("Feishu Bitable") {
labeledRow("App Token") {
TextField("from Bitable URL", text: $config.appToken)
.textFieldStyle(.roundedBorder)
}
.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)
labeledRow("Table ID") {
TextField("", text: $config.tableId)
.textFieldStyle(.roundedBorder)
}
labeledRow("Feishu domain") {
TextField("e.g. feishu.cn", text: $config.feishuBaseDomain)
.textFieldStyle(.roundedBorder)
}
connectionTestRow
}
Section("Polling") {
Picker("Check every", selection: $config.pollIntervalSeconds) {
// 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(1800)
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) {
Toggle("Refresh on launch", isOn: $config.refreshOnStart)
Text("Fetch bugs immediately when Bugger starts. Off = wait for the next scheduled poll.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Section("Display") {
// MARK: Real-time updates
settingsSection("Real-time updates") {
labeledRow("Subscribe URL") {
TextField("e.g. https://example.com", text: $config.feishuAppBaseURL)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
}
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
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)
.onChange(of: config.showFloatingWidget) { _, enabled in
if enabled {
@ -63,17 +106,146 @@ struct SettingsView: View {
Toggle("Launch at login", isOn: $config.launchAtLogin)
}
Section("Field Mappings") {
TextField("Title field", text: $config.fieldMappings.titleField)
TextField("Priority field", text: $config.fieldMappings.priorityField)
TextField("Status field", text: $config.fieldMappings.statusField)
TextField("Assignee field", text: $config.fieldMappings.assigneeField)
TextField("Reporter field", text: $config.fieldMappings.reporterField)
TextField("Created At field", text: $config.fieldMappings.createdAtField)
TextField("Updated At field", text: $config.fieldMappings.updatedAtField)
// MARK: Advanced
advancedSection
// MARK: Actions
actionButtons
}
.frame(maxWidth: 700, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .center)
.padding(20)
}
.frame(minWidth: 520, idealWidth: 560, maxWidth: .infinity,
minHeight: 400, idealHeight: 600, maxHeight: .infinity)
}
Section("Status Mappings") {
// 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()
// 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.")
.font(.caption)
.foregroundStyle(.secondary)
@ -82,8 +254,17 @@ struct SettingsView: View {
statusMappingRow(for: status)
}
}
}
.padding(.top, 8)
} label: {
Label("Advanced: field & status mappings", systemImage: "slider.horizontal.3")
}
}
HStack {
// MARK: - Action Buttons
private var actionButtons: some View {
HStack(spacing: 12) {
Button("Save") {
save()
}
@ -92,13 +273,40 @@ struct SettingsView: View {
Button("Disconnect Feishu") {
TokenManager.shared.clearTokens()
PollerService.shared.stop()
BitableEventService.shared.disconnect()
CalibrationService.shared.stop()
}
.foregroundStyle(.red)
}
}
.padding()
// MARK: - Layout Helpers
/// A consistently-aligned labelcontrol 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
@ -128,8 +336,8 @@ struct SettingsView: View {
}
)
return VStack(alignment: .leading, spacing: 2) {
HStack {
return VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
StatusPill(status: status)
Text(status.label)
.font(.caption)
@ -142,6 +350,43 @@ struct SettingsView: View {
.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
private func save() {
@ -162,6 +407,11 @@ struct SettingsView: View {
Task {
PollerService.shared.restart(interval: TimeInterval(config.pollIntervalSeconds))
// (Re)establish the SSE stream using the (possibly new) subscribe URL.
// connect() is a no-op when the URL is empty, so clearing the field
// disables real-time push.
BitableEventService.shared.reconnect()
CalibrationService.shared.restart()
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,724 @@
# 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.

View File

@ -0,0 +1,302 @@
# 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) |

710
docs/TECH_INVESTIGATION.md Normal file
View File

@ -0,0 +1,710 @@
# 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 1530 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, 1530 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: 35 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.

74
scripts/build_release.sh Executable file
View File

@ -0,0 +1,74 @@
#!/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)."

View File

@ -0,0 +1,173 @@
#!/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()

View File

@ -337,7 +337,7 @@ def build_pbxproj() -> str:
target_settings = [
("ASSETCATALOG_COMPILER_APPICON_NAME", "AppIcon"),
("COMBINE_HIDPI_IMAGES", "YES"),
("CURRENT_PROJECT_VERSION", "1"),
("CURRENT_PROJECT_VERSION", "5"),
("FEISHU_APP_ID", '"$(FEISHU_APP_ID)"'),
("FEISHU_APP_SECRET", '"$(FEISHU_APP_SECRET)"'),
("FEISHU_BASE_DOMAIN", '"xorbitlab.feishu.cn"'),
@ -346,7 +346,7 @@ def build_pbxproj() -> str:
("LD_RUNPATH_SEARCH_PATHS",
'"$(inherited) @executable_path/../Frameworks"'),
("MACOSX_DEPLOYMENT_TARGET", "14.0"),
("MARKETING_VERSION", "1.0"),
("MARKETING_VERSION", "1.5"),
("PRODUCT_BUNDLE_IDENTIFIER", BUNDLE_ID),
("PRODUCT_NAME", '"$(TARGET_NAME)"'),
("SWIFT_EMIT_LOC_STRINGS", "YES"),