feat: full macOS menu bar app implementation

SwiftUI macOS menu bar app for tracking Feishu Bitable bugs:
- Menu bar icon with active bug count badge
- Popover with bug list sorted by priority, click to open in Feishu
- Floating always-on-top widget (NSPanel, optional)
- OAuth 2.0 flow with Feishu Open API (user_access_token + refresh)
- PollerService with configurable interval (default 5 min)
- Diff engine detecting new bugs, status/priority/assignee changes
- Native macOS notifications (UserNotifications framework)
- Keychain-backed token storage
- Settings window with field mapping, polling interval, widget toggle
- Support for custom Bitable column name mappings
- Zero third-party dependencies

17 source files across Models/Services/Views/ViewModels/Utils layers.
Xcode project generated via scripts/generate_xcode_project.py.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tigerenwork 2026-06-28 20:56:44 +08:00
parent a9c90f64b9
commit d9d8ce3e46
35 changed files with 2955 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
build/
DerivedData/
*.xcuserstate
xcuserdata/
Config.xcconfig
.DS_Store

View File

@ -0,0 +1,559 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
C70061E61A4A91DEE4DCD093 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2ADE595D33ADF5BA01B253B; };
264AF91CD3111E25300CB673 /* BuggerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7705247A59F50F6B6E6D286F; };
93E674C67BBD94AE8F60D9C2 /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE8899127BDCD00F1D39E8FD; };
934A2D27BD52EA7EA9138148 /* Bug.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4DE27079694EBA15A0DFAAB; };
40F0A29391DC7C7D5BD3AC86 /* BugChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE785F676266AFB5EA636597; };
8AF76AF5588E865614CBEB3F /* BugPriority.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA8A640EF741D9404316C39; };
6E97B8FF66F15C45F5BBCDC3 /* BugStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649CAAB9009813D6C1FD07E3; };
7E7A04B78BD2D7DA8B923E89 /* AppStateService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3148982E653318C7E3CD13F; };
1EE9930CFF29314317C80B41 /* FeishuAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 264DA84D71E29D64DEC1B038; };
EA309C97F6828294AE038EE0 /* FeishuError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A9F363FE066CAE6226A050; };
A53C0F63FE7B64140EE84896 /* FeishuModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 973BF857A0044A0262A1F56F; };
32E0CB6A605B7E033826F85C /* FeishuService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66321820D32BCC5D05265115; };
7B0310CEB785902145E910CE /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C98E94F5A859B0EEC7326C60; };
64973B14EC100A258F5D5DF1 /* PollerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD2692AA96D65949109FEC0E; };
DB560F1DC0B066BE60979A42 /* TokenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9677914DC46FD106A90CA724; };
570EEFE5A198913D1E28D539 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 968D99A1DA7131447D075F27; };
523A4BEC8BE348013BA23CCB /* DateFormatter+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BE6905A92E43B24E24CDE29; };
6FBC99D9861B64AF3C21FFC2 /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 527F9B3FF513B72F175A8429; };
690FBDF23267CDB564AA9014 /* URL+Feishu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E390AA6A998B6C83BD779018; };
50C462B4A25F77CD93FFBB49 /* BugStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C251D0198118D6B5D4B7C69; };
BD6E0970804F37FAA7DBB468 /* FloatingWidgetWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DC24ADCEDA5C16DFF9FA6EA; };
5AEABE301EC2352533870063 /* BugListPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17D10F852D16E2F4FC269FD2; };
E4911DC6264A1006B7FDCF30 /* BugRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3F3DB4764E379E663475EBD; };
EA6166A18AD94D499E170D07 /* OAuthSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29172EAB6F6B4526BDA23C2; };
6B62FD564DD3D1A983DFB3EB /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9AD7665552E1BC2E76337C; };
2D2411A895B80B698B8E2A82 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6A93EEBDA5A740C84995D959; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
EF0182DF85A7CD0319333F24 /* Bugger.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Bugger.app; sourceTree = BUILT_PRODUCTS_DIR; };
B2ADE595D33ADF5BA01B253B /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7705247A59F50F6B6E6D286F /* BuggerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuggerApp.swift; sourceTree = "<group>"; };
AE8899127BDCD00F1D39E8FD /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfig.swift; sourceTree = "<group>"; };
C4DE27079694EBA15A0DFAAB /* Bug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bug.swift; sourceTree = "<group>"; };
FE785F676266AFB5EA636597 /* BugChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugChange.swift; sourceTree = "<group>"; };
ADA8A640EF741D9404316C39 /* BugPriority.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugPriority.swift; sourceTree = "<group>"; };
649CAAB9009813D6C1FD07E3 /* BugStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugStatus.swift; sourceTree = "<group>"; };
E3148982E653318C7E3CD13F /* AppStateService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateService.swift; sourceTree = "<group>"; };
264DA84D71E29D64DEC1B038 /* FeishuAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuAuthService.swift; sourceTree = "<group>"; };
38A9F363FE066CAE6226A050 /* FeishuError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuError.swift; sourceTree = "<group>"; };
973BF857A0044A0262A1F56F /* FeishuModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuModels.swift; sourceTree = "<group>"; };
66321820D32BCC5D05265115 /* FeishuService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuService.swift; sourceTree = "<group>"; };
C98E94F5A859B0EEC7326C60 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
BD2692AA96D65949109FEC0E /* PollerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollerService.swift; sourceTree = "<group>"; };
9677914DC46FD106A90CA724 /* TokenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenManager.swift; sourceTree = "<group>"; };
968D99A1DA7131447D075F27 /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color+Extensions.swift; sourceTree = "<group>"; };
0BE6905A92E43B24E24CDE29 /* DateFormatter+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatter+Extensions.swift; sourceTree = "<group>"; };
527F9B3FF513B72F175A8429 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; };
E390AA6A998B6C83BD779018 /* URL+Feishu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL+Feishu.swift; sourceTree = "<group>"; };
6C251D0198118D6B5D4B7C69 /* BugStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugStore.swift; sourceTree = "<group>"; };
5DC24ADCEDA5C16DFF9FA6EA /* FloatingWidgetWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingWidgetWindow.swift; sourceTree = "<group>"; };
17D10F852D16E2F4FC269FD2 /* BugListPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugListPopover.swift; sourceTree = "<group>"; };
C3F3DB4764E379E663475EBD /* BugRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugRow.swift; sourceTree = "<group>"; };
E29172EAB6F6B4526BDA23C2 /* OAuthSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthSetupView.swift; sourceTree = "<group>"; };
9F9AD7665552E1BC2E76337C /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
6A93EEBDA5A740C84995D959 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
2EB7B1BD755DF7F2FB5F8392 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
86B3BC083DF59791BE56CFAE /* Bugger.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Bugger.entitlements; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
DE1439ACCFCEB73E6F9981A5 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
15D1373CDE06DCCF0DEC22FE = {
isa = PBXGroup;
children = (
);
path = .claude;
sourceTree = "<group>";
};
B75E6ED1F517C8DE98A7DAEA = {
isa = PBXGroup;
children = (
);
path = Bugger.xcodeproj;
sourceTree = "<group>";
};
7C0FCEE666858DE48E2D02AD = {
isa = PBXGroup;
children = (
);
path = AppIcon.appiconset;
sourceTree = "<group>";
};
FB79BDD8FFC3CC36C25BA315 = {
isa = PBXGroup;
children = (
7C0FCEE666858DE48E2D02AD,
);
path = Assets.xcassets;
sourceTree = "<group>";
};
7C4EE8955498EEF65ED6C84A = {
isa = PBXGroup;
children = (
6A93EEBDA5A740C84995D959,
2EB7B1BD755DF7F2FB5F8392,
86B3BC083DF59791BE56CFAE,
);
path = Resources;
sourceTree = "<group>";
};
B805360275E47AAD7192E4AE = {
isa = PBXGroup;
children = (
AE8899127BDCD00F1D39E8FD,
C4DE27079694EBA15A0DFAAB,
FE785F676266AFB5EA636597,
ADA8A640EF741D9404316C39,
649CAAB9009813D6C1FD07E3,
);
path = Models;
sourceTree = "<group>";
};
0B0ED8A1BE17CC4C0B3A787B = {
isa = PBXGroup;
children = (
264DA84D71E29D64DEC1B038,
38A9F363FE066CAE6226A050,
973BF857A0044A0262A1F56F,
66321820D32BCC5D05265115,
);
path = Feishu;
sourceTree = "<group>";
};
EF310F3F9A8796C845F345F5 = {
isa = PBXGroup;
children = (
E3148982E653318C7E3CD13F,
0B0ED8A1BE17CC4C0B3A787B,
C98E94F5A859B0EEC7326C60,
BD2692AA96D65949109FEC0E,
9677914DC46FD106A90CA724,
);
path = Services;
sourceTree = "<group>";
};
B38F908829C30D6B6F967EEA = {
isa = PBXGroup;
children = (
968D99A1DA7131447D075F27,
0BE6905A92E43B24E24CDE29,
527F9B3FF513B72F175A8429,
E390AA6A998B6C83BD779018,
);
path = Utils;
sourceTree = "<group>";
};
56714C7161F5C8195EA19816 = {
isa = PBXGroup;
children = (
6C251D0198118D6B5D4B7C69,
);
path = ViewModels;
sourceTree = "<group>";
};
82B5ED67A4830EDA75C6CDE1 = {
isa = PBXGroup;
children = (
5DC24ADCEDA5C16DFF9FA6EA,
);
path = FloatingWidget;
sourceTree = "<group>";
};
24C578F83DCF9CEAFCAF2E73 = {
isa = PBXGroup;
children = (
17D10F852D16E2F4FC269FD2,
C3F3DB4764E379E663475EBD,
);
path = MenuBar;
sourceTree = "<group>";
};
0308B80FA0CF6FBE7D8C7EB3 = {
isa = PBXGroup;
children = (
E29172EAB6F6B4526BDA23C2,
);
path = OAuth;
sourceTree = "<group>";
};
3053DFBFF1BD7F53471B36BB = {
isa = PBXGroup;
children = (
9F9AD7665552E1BC2E76337C,
);
path = Settings;
sourceTree = "<group>";
};
F4ED3EC6534567974F720F99 = {
isa = PBXGroup;
children = (
82B5ED67A4830EDA75C6CDE1,
24C578F83DCF9CEAFCAF2E73,
0308B80FA0CF6FBE7D8C7EB3,
3053DFBFF1BD7F53471B36BB,
);
path = Views;
sourceTree = "<group>";
};
EE6F40F7464432A6C2A761DA = {
isa = PBXGroup;
children = (
B2ADE595D33ADF5BA01B253B,
7705247A59F50F6B6E6D286F,
B805360275E47AAD7192E4AE,
EF310F3F9A8796C845F345F5,
B38F908829C30D6B6F967EEA,
56714C7161F5C8195EA19816,
F4ED3EC6534567974F720F99,
);
path = Sources;
sourceTree = "<group>";
};
A250A7E9920D61D34E4FC076 = {
isa = PBXGroup;
children = (
);
path = docs;
sourceTree = "<group>";
};
B3C3812FB431C8E80348A58A = {
isa = PBXGroup;
children = (
);
path = scripts;
sourceTree = "<group>";
};
8C4FDD7B635240BD562EBB49 = {
isa = PBXGroup;
children = (
287BE755981C092238309A9A,
8AD6A16612A2DB7B2F899787,
B23D89B2D2DF3745A003AEB1,
);
sourceTree = "<group>";
};
B805360275E47AAD7192E4AE = {
isa = PBXGroup;
children = (
AE8899127BDCD00F1D39E8FD,
C4DE27079694EBA15A0DFAAB,
FE785F676266AFB5EA636597,
ADA8A640EF741D9404316C39,
649CAAB9009813D6C1FD07E3,
);
path = Models;
sourceTree = "<group>";
};
0B0ED8A1BE17CC4C0B3A787B = {
isa = PBXGroup;
children = (
264DA84D71E29D64DEC1B038,
38A9F363FE066CAE6226A050,
973BF857A0044A0262A1F56F,
66321820D32BCC5D05265115,
);
path = Feishu;
sourceTree = "<group>";
};
EF310F3F9A8796C845F345F5 = {
isa = PBXGroup;
children = (
E3148982E653318C7E3CD13F,
0B0ED8A1BE17CC4C0B3A787B,
C98E94F5A859B0EEC7326C60,
BD2692AA96D65949109FEC0E,
9677914DC46FD106A90CA724,
);
path = Services;
sourceTree = "<group>";
};
B38F908829C30D6B6F967EEA = {
isa = PBXGroup;
children = (
968D99A1DA7131447D075F27,
0BE6905A92E43B24E24CDE29,
527F9B3FF513B72F175A8429,
E390AA6A998B6C83BD779018,
);
path = Utils;
sourceTree = "<group>";
};
56714C7161F5C8195EA19816 = {
isa = PBXGroup;
children = (
6C251D0198118D6B5D4B7C69,
);
path = ViewModels;
sourceTree = "<group>";
};
82B5ED67A4830EDA75C6CDE1 = {
isa = PBXGroup;
children = (
5DC24ADCEDA5C16DFF9FA6EA,
);
path = FloatingWidget;
sourceTree = "<group>";
};
24C578F83DCF9CEAFCAF2E73 = {
isa = PBXGroup;
children = (
17D10F852D16E2F4FC269FD2,
C3F3DB4764E379E663475EBD,
);
path = MenuBar;
sourceTree = "<group>";
};
0308B80FA0CF6FBE7D8C7EB3 = {
isa = PBXGroup;
children = (
E29172EAB6F6B4526BDA23C2,
);
path = OAuth;
sourceTree = "<group>";
};
3053DFBFF1BD7F53471B36BB = {
isa = PBXGroup;
children = (
9F9AD7665552E1BC2E76337C,
);
path = Settings;
sourceTree = "<group>";
};
F4ED3EC6534567974F720F99 = {
isa = PBXGroup;
children = (
82B5ED67A4830EDA75C6CDE1,
24C578F83DCF9CEAFCAF2E73,
0308B80FA0CF6FBE7D8C7EB3,
3053DFBFF1BD7F53471B36BB,
);
path = Views;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
42AEFBAE01D2DFD981F7DA7D /* Bugger */ = {
isa = PBXNativeTarget;
buildConfigurationList = 74C875FD1C5C02E06FAC0545 /* Build configuration list for PBXNativeTarget "Bugger" */;
buildPhases = (
3B7DFB0747829707F437ED7F /* Sources */,
DE1439ACCFCEB73E6F9981A5 /* Frameworks */,
3519654520012F56AC9A0E86 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = Bugger;
productName = Bugger;
productReference = EF0182DF85A7CD0319333F24 /* Bugger.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
46F86FAA6BBF9AC94A7E4595 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1500;
LastUpgradeCheck = 1500;
TargetAttributes = {
42AEFBAE01D2DFD981F7DA7D = {
CreatedOnToolsVersion = 15.0;
};
};
};
buildConfigurationList = DD445E777372328CDC08F332 /* Build configuration list for PBXProject "Bugger" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 8C4FDD7B635240BD562EBB49;
productRefGroup = B23D89B2D2DF3745A003AEB1 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
42AEFBAE01D2DFD981F7DA7D /* Bugger */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
3519654520012F56AC9A0E86 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
2D2411A895B80B698B8E2A82 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
3B7DFB0747829707F437ED7F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
C70061E61A4A91DEE4DCD093 /* AppDelegate.swift in Sources */,
264AF91CD3111E25300CB673 /* BuggerApp.swift in Sources */,
93E674C67BBD94AE8F60D9C2 /* AppConfig.swift in Sources */,
934A2D27BD52EA7EA9138148 /* Bug.swift in Sources */,
40F0A29391DC7C7D5BD3AC86 /* BugChange.swift in Sources */,
8AF76AF5588E865614CBEB3F /* BugPriority.swift in Sources */,
6E97B8FF66F15C45F5BBCDC3 /* BugStatus.swift in Sources */,
7E7A04B78BD2D7DA8B923E89 /* AppStateService.swift in Sources */,
1EE9930CFF29314317C80B41 /* FeishuAuthService.swift in Sources */,
EA309C97F6828294AE038EE0 /* FeishuError.swift in Sources */,
A53C0F63FE7B64140EE84896 /* FeishuModels.swift in Sources */,
32E0CB6A605B7E033826F85C /* FeishuService.swift in Sources */,
7B0310CEB785902145E910CE /* NotificationService.swift in Sources */,
64973B14EC100A258F5D5DF1 /* PollerService.swift in Sources */,
DB560F1DC0B066BE60979A42 /* TokenManager.swift in Sources */,
570EEFE5A198913D1E28D539 /* Color+Extensions.swift in Sources */,
523A4BEC8BE348013BA23CCB /* DateFormatter+Extensions.swift in Sources */,
6FBC99D9861B64AF3C21FFC2 /* KeychainHelper.swift in Sources */,
690FBDF23267CDB564AA9014 /* URL+Feishu.swift in Sources */,
50C462B4A25F77CD93FFBB49 /* BugStore.swift in Sources */,
BD6E0970804F37FAA7DBB468 /* FloatingWidgetWindow.swift in Sources */,
5AEABE301EC2352533870063 /* BugListPopover.swift in Sources */,
E4911DC6264A1006B7FDCF30 /* BugRow.swift in Sources */,
EA6166A18AD94D499E170D07 /* OAuthSetupView.swift in Sources */,
6B62FD564DD3D1A983DFB3EB /* SettingsView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
F76147CE86058CC1939088C9 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ENABLE_MODULES = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
GCC_DYNAMIC_NO_PIC = NO;
GCC_OPTIMIZATION_LEVEL = 0;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = -Onone;
};
name = Debug;
};
97C89C522DCD700C1F32C445 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ENABLE_MODULES = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf-with-dsym;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
36F7B61E0BB35F5EE850C3BE /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Resources/Bugger.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = ;
ENABLE_HARDENED_RUNTIME = YES;
FEISHU_APP_ID = ;
FEISHU_APP_SECRET = ;
FEISHU_BASE_DOMAIN = xorbitlab.feishu.cn;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Resources/Info.plist;
LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/../Frameworks;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.xorbitlab.bugger;
PRODUCT_NAME = $(TARGET_NAME);
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
C961A2B6F28F76EF702C6931 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Resources/Bugger.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = ;
ENABLE_HARDENED_RUNTIME = YES;
FEISHU_APP_ID = ;
FEISHU_APP_SECRET = ;
FEISHU_BASE_DOMAIN = xorbitlab.feishu.cn;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Resources/Info.plist;
LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/../Frameworks;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.xorbitlab.bugger;
PRODUCT_NAME = $(TARGET_NAME);
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
DD445E777372328CDC08F332 /* Build configuration list for PBXProject "Bugger" */ = {
isa = XCConfigurationList;
buildConfigurations = (
F76147CE86058CC1939088C9 /* Debug */,
97C89C522DCD700C1F32C445 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
74C875FD1C5C02E06FAC0545 /* Build configuration list for PBXNativeTarget "Bugger" */ = {
isa = XCConfigurationList;
buildConfigurations = (
36F7B61E0BB35F5EE850C3BE /* Debug */,
C961A2B6F28F76EF702C6931 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 46F86FAA6BBF9AC94A7E4595 /* Project object */;
}

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
</Workspace>

View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1500"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "42AEFBAE01D2DFD981F7DA7D"
BuildableName = "Bugger.app"
BlueprintName = "Bugger"
ReferencedContainer = "container:Bugger.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "42AEFBAE01D2DFD981F7DA7D"
BuildableName = "Bugger.app"
BlueprintName = "Bugger"
ReferencedContainer = "container:Bugger.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
</Scheme>

6
Config.xcconfig.example Normal file
View File

@ -0,0 +1,6 @@
// Copy to Config.xcconfig and fill in your Feishu app credentials.
// In Xcode: Project > Info > Configurations > set Debug/Release to Config.xcconfig
FEISHU_APP_ID = cli_your_app_id
FEISHU_APP_SECRET = your_app_secret
FEISHU_BASE_DOMAIN = xorbitlab.feishu.cn

View File

@ -0,0 +1,58 @@
{
"images" : [
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.network.client</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.xorbitlab.bugger</string>
</array>
</dict>
</plist>

43
Resources/Info.plist Normal file
View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.xorbitlab.bugger</string>
<key>CFBundleURLSchemes</key>
<array>
<string>bugger</string>
</array>
</dict>
</array>
<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>
</dict>
</plist>

99
Sources/AppDelegate.swift Normal file
View File

@ -0,0 +1,99 @@
import AppKit
import ServiceManagement
import SwiftUI
final class AppDelegate: NSObject, NSApplicationDelegate {
static weak var shared: AppDelegate?
private var statusItem: NSStatusItem!
private var popover: NSPopover!
private var floatingWidget: FloatingWidgetWindow?
private let bugStore = BugStore.shared
func applicationDidFinishLaunching(_ notification: Notification) {
Self.shared = self
NSApp.setActivationPolicy(.accessory)
NSAppleEventManager.shared().setEventHandler(
self,
andSelector: #selector(handleURLEvent(_:withReplyEvent:)),
forEventClass: AEEventClass(kInternetEventClass),
andEventID: AEEventID(kAEGetURL)
)
setupStatusItem()
setupPopover()
if let config = AppStateService.shared.config {
if config.showFloatingWidget {
showFloatingWidget()
}
if config.launchAtLogin {
try? SMAppService.mainApp.register()
}
}
Task {
await PollerService.shared.startIfConfigured()
}
}
private func setupStatusItem() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem.button {
button.image = NSImage(systemSymbolName: "ant.fill", accessibilityDescription: "Bugger")
button.action = #selector(togglePopover)
button.target = self
}
updateBadge(count: bugStore.unseenActiveCount)
}
private func setupPopover() {
popover = NSPopover()
popover.contentSize = NSSize(width: 360, height: 500)
popover.behavior = .transient
popover.contentViewController = NSHostingController(rootView: BugListPopover())
}
func updateBadge(count: Int) {
if count > 0 {
statusItem.button?.title = " \(count)"
} else {
statusItem.button?.title = ""
}
}
@objc func togglePopover() {
guard let button = statusItem.button else { return }
if popover.isShown {
closePopover()
} else {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
popover.contentViewController?.view.window?.makeKey()
}
}
func closePopover() {
popover.performClose(nil)
}
func showFloatingWidget() {
guard floatingWidget == nil else { return }
floatingWidget = FloatingWidgetWindow()
floatingWidget?.orderFrontRegardless()
}
func hideFloatingWidget() {
floatingWidget?.close()
floatingWidget = nil
}
@objc private func handleURLEvent(_ event: NSAppleEventDescriptor, withReplyEvent: NSAppleEventDescriptor) {
guard let urlString = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue,
let url = URL(string: urlString) else {
return
}
OAuthCallbackHandler.handle(url)
}
}

13
Sources/BuggerApp.swift Normal file
View File

@ -0,0 +1,13 @@
import SwiftUI
@main
struct BuggerApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
var body: some Scene {
Settings {
SettingsView()
}
.windowResizability(.contentSize)
}
}

View File

@ -0,0 +1,26 @@
import Foundation
struct AppConfig: Codable, Equatable {
var appToken: String = ""
var tableId: String = ""
var assigneeName: String = ""
var fieldMappings: FieldMappings = FieldMappings()
var pollIntervalSeconds: Int = 300
var showFloatingWidget: Bool = false
var launchAtLogin: Bool = true
var feishuBaseDomain: String = "xorbitlab.feishu.cn"
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 isConfigured: Bool {
!appToken.isEmpty && !tableId.isEmpty
}
}

17
Sources/Models/Bug.swift Normal file
View File

@ -0,0 +1,17 @@
import Foundation
struct Bug: Identifiable, Equatable, Hashable {
let id: String
let title: String
let priority: BugPriority
let status: BugStatus
let assignee: String
let reporter: String?
let createdAt: Date
let updatedAt: Date
let feishuURL: URL
var age: TimeInterval {
Date().timeIntervalSince(createdAt)
}
}

View File

@ -0,0 +1,15 @@
import Foundation
struct BugChange {
let type: ChangeType
let bug: Bug
let oldStatus: BugStatus?
let oldAssignee: String?
enum ChangeType {
case newBug
case statusChanged
case priorityChanged
case assigneeChanged
}
}

View File

@ -0,0 +1,29 @@
import Foundation
enum BugPriority: String, Codable, Comparable, CaseIterable {
case p0 = "P0"
case p1 = "P1"
case p2 = "P2"
case p3 = "P3"
case unknown
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let raw = try container.decode(String.self)
self = BugPriority(rawValue: raw) ?? .unknown
}
static func < (lhs: BugPriority, rhs: BugPriority) -> Bool {
order(lhs) < order(rhs)
}
private static func order(_ priority: BugPriority) -> Int {
switch priority {
case .p0: 0
case .p1: 1
case .p2: 2
case .p3: 3
case .unknown: 4
}
}
}

View File

@ -0,0 +1,16 @@
import Foundation
enum BugStatus: String, Codable, CaseIterable {
case open = "Open"
case inProgress = "In Progress"
case inReview = "In Review"
case resolved = "Resolved"
case closed = "Closed"
case unknown
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let raw = try container.decode(String.self)
self = BugStatus(rawValue: raw) ?? .unknown
}
}

View File

@ -0,0 +1,50 @@
import Foundation
final class AppStateService {
static let shared = AppStateService()
private let configKey = "bugger.config"
private let seenBugsKey = "bugger.seenBugs"
private(set) var config: AppConfig? {
didSet { persistConfig() }
}
private(set) var persistedSeenBugs: Set<String> = [] {
didSet { persistSeenBugs() }
}
private init() {
if let data = UserDefaults.standard.data(forKey: configKey),
let config = try? JSONDecoder().decode(AppConfig.self, from: data) {
self.config = config
}
if let data = UserDefaults.standard.data(forKey: seenBugsKey),
let ids = try? JSONDecoder().decode(Set<String>.self, from: data) {
self.persistedSeenBugs = ids
}
}
func saveConfig(_ config: AppConfig) {
self.config = config
}
func updateSeenBugs(_ ids: Set<String>) {
persistedSeenBugs = ids
}
private func persistConfig() {
guard let config,
let data = try? JSONEncoder().encode(config) else {
return
}
UserDefaults.standard.set(data, forKey: configKey)
}
private func persistSeenBugs() {
guard let data = try? JSONEncoder().encode(persistedSeenBugs) else {
return
}
UserDefaults.standard.set(data, forKey: seenBugsKey)
}
}

View File

@ -0,0 +1,133 @@
import Foundation
final class FeishuAuthService {
private let baseURL = "https://open.feishu.cn/open-apis"
private let appId: String
private let appSecret: String
private let redirectURI = "bugger://oauth/callback"
private let session: URLSession
private let decoder: JSONDecoder
private var cachedTenantToken: String?
private var tenantTokenExpiry: Date?
init(session: URLSession = .shared) {
self.session = session
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
self.decoder = decoder
let id = Bundle.main.object(forInfoDictionaryKey: "FEISHU_APP_ID") as? String ?? ""
let secret = Bundle.main.object(forInfoDictionaryKey: "FEISHU_APP_SECRET") as? String ?? ""
self.appId = id
self.appSecret = secret
}
var hasCredentials: Bool {
!appId.isEmpty && !appSecret.isEmpty && appId != "$(FEISHU_APP_ID)"
}
var authorizeURL: URL {
var components = URLComponents(string: "\(baseURL)/authen/v1/authorize")!
components.queryItems = [
URLQueryItem(name: "app_id", value: appId),
URLQueryItem(name: "redirect_uri", value: redirectURI),
URLQueryItem(name: "scope", value: "bitable:app:readonly")
]
return components.url!
}
func exchangeCode(_ code: String) async throws -> OAuthTokenData {
let tenantToken = try await tenantAccessToken()
let url = URL(string: "\(baseURL)/authen/v1/oidc/access_token")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(tenantToken)", forHTTPHeaderField: "Authorization")
request.httpBody = try JSONEncoder().encode([
"grant_type": "authorization_code",
"code": code
])
return try await decodeAPIResponse(request)
}
func refreshAccessToken(_ refreshToken: String) async throws -> OAuthTokenData {
let tenantToken = try await tenantAccessToken()
let url = URL(string: "\(baseURL)/authen/v1/oidc/refresh_access_token")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(tenantToken)", forHTTPHeaderField: "Authorization")
request.httpBody = try JSONEncoder().encode([
"grant_type": "refresh_token",
"refresh_token": refreshToken
])
return try await decodeAPIResponse(request)
}
func fetchCurrentUserName(accessToken: String) async throws -> String {
let url = URL(string: "\(baseURL)/authen/v1/user_info")!
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
let response: FeishuAPIResponse<UserInfoData> = try await send(request)
guard let data = response.data else {
throw FeishuError.apiError(code: response.code, message: response.msg)
}
return data.name ?? data.enName ?? "Unknown"
}
private func tenantAccessToken() async throws -> String {
if let cachedTenantToken,
let tenantTokenExpiry,
tenantTokenExpiry > Date().addingTimeInterval(60) {
return cachedTenantToken
}
guard hasCredentials else {
throw FeishuError.missingCredentials
}
let url = URL(string: "\(baseURL)/auth/v3/tenant_access_token/internal")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode([
"app_id": appId,
"app_secret": appSecret
])
let response: FeishuAPIResponse<TenantAccessTokenData> = try await send(request)
guard let data = response.data else {
throw FeishuError.apiError(code: response.code, message: response.msg)
}
cachedTenantToken = data.tenantAccessToken
tenantTokenExpiry = Date().addingTimeInterval(TimeInterval(data.expire))
return data.tenantAccessToken
}
private func decodeAPIResponse<T: Decodable>(_ request: URLRequest) async throws -> T {
let response: FeishuAPIResponse<T> = try await send(request)
guard response.code == 0, let data = response.data else {
throw FeishuError.apiError(code: response.code, message: response.msg)
}
return data
}
private func send<T: Decodable>(_ request: URLRequest) async throws -> T {
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw FeishuError.networkError(nil)
}
if httpResponse.statusCode == 401 {
throw FeishuError.unauthorized
}
do {
return try decoder.decode(T.self, from: data)
} catch {
throw FeishuError.decodingError(error)
}
}
}

View File

@ -0,0 +1,27 @@
import Foundation
enum FeishuError: Error, LocalizedError {
case unauthorized
case networkError(Error?)
case apiError(code: Int, message: String)
case decodingError(Error)
case notConfigured
case missingCredentials
var errorDescription: String? {
switch self {
case .unauthorized:
return "Feishu authorization expired. Please re-authenticate."
case .networkError(let error):
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 .notConfigured:
return "Feishu table not configured. Open Settings."
case .missingCredentials:
return "Feishu app credentials are missing. Set FEISHU_APP_ID and FEISHU_APP_SECRET."
}
}
}

View File

@ -0,0 +1,163 @@
import Foundation
struct FeishuAPIResponse<T: Decodable>: Decodable {
let code: Int
let msg: String
let data: T?
}
struct RecordListData: Decodable {
let items: [RecordItem]
let hasMore: Bool
let pageToken: String?
let total: Int?
}
struct RecordItem: Decodable {
let recordId: String
let fields: [String: JSONValue]
enum CodingKeys: String, CodingKey {
case recordId = "record_id"
case fields
}
}
struct OAuthTokenData: Decodable {
let accessToken: String
let tokenType: String?
let expiresIn: Int
let refreshToken: String?
let refreshExpiresIn: Int?
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case tokenType = "token_type"
case expiresIn = "expires_in"
case refreshToken = "refresh_token"
case refreshExpiresIn = "refresh_expires_in"
}
}
struct TenantAccessTokenData: Decodable {
let tenantAccessToken: String
let expire: Int
enum CodingKeys: String, CodingKey {
case tenantAccessToken = "tenant_access_token"
case expire
}
}
struct UserInfoData: Decodable {
let name: String?
let enName: String?
enum CodingKeys: String, CodingKey {
case name
case enName = "en_name"
}
}
enum JSONValue: Decodable {
case string(String)
case number(Double)
case bool(Bool)
case array([JSONValue])
case object([String: JSONValue])
case null
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
} else if let value = try? container.decode(Bool.self) {
self = .bool(value)
} else if let value = try? container.decode(Double.self) {
self = .number(value)
} else if let value = try? container.decode(String.self) {
self = .string(value)
} else if let value = try? container.decode([JSONValue].self) {
self = .array(value)
} else if let value = try? container.decode([String: JSONValue].self) {
self = .object(value)
} else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Unsupported JSON value"
)
}
}
var stringValue: String? {
if case let .string(value) = self { return value }
if case let .number(value) = self { return String(value) }
return nil
}
var firstUserName: String? {
guard case let .array(items) = self else { return nil }
for item in items {
if case let .object(fields) = item,
let name = fields["name"]?.stringValue {
return name
}
}
return nil
}
}
enum BugMapper {
static func map(
_ record: RecordItem,
config: AppConfig
) -> Bug {
let fields = record.fields
let mappings = config.fieldMappings
return Bug(
id: record.recordId,
title: stringValue(in: fields, key: mappings.titleField) ?? "Untitled",
priority: BugPriority(rawValue: stringValue(in: fields, key: mappings.priorityField) ?? "") ?? .unknown,
status: BugStatus(rawValue: stringValue(in: fields, key: mappings.statusField) ?? "") ?? .unknown,
assignee: userName(in: fields, key: mappings.assigneeField) ?? "Unknown",
reporter: userName(in: fields, key: mappings.reporterField),
createdAt: FeishuDateParser.parse(rawValue(in: fields, key: mappings.createdAtField)) ?? .distantPast,
updatedAt: FeishuDateParser.parse(rawValue(in: fields, key: mappings.updatedAtField)) ?? .distantPast,
feishuURL: FeishuURLBuilder.recordURL(
baseDomain: config.feishuBaseDomain,
appToken: config.appToken,
tableId: config.tableId,
recordId: record.recordId
)
)
}
private static func rawValue(in fields: [String: JSONValue], key: String) -> Any? {
guard let value = fields[key] else { return nil }
switch value {
case let .string(string): return string
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
}
case let .object(object):
return object["name"]?.stringValue ?? object["text"]?.stringValue
case .null:
return nil
}
}
private static func stringValue(in fields: [String: JSONValue], key: String) -> String? {
fields[key]?.stringValue
}
private static func userName(in fields: [String: JSONValue], key: String) -> String? {
fields[key]?.firstUserName ?? fields[key]?.stringValue
}
}

View File

@ -0,0 +1,95 @@
import Foundation
final class FeishuService {
private let baseURL = "https://open.feishu.cn/open-apis"
private let session: URLSession
private let decoder: JSONDecoder
init(session: URLSession = .shared) {
self.session = session
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
self.decoder = decoder
}
func fetchBugs(
config: AppConfig,
assigneeName: String,
accessToken: String
) 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
)
allRecords.append(contentsOf: page.items)
pageToken = page.hasMore ? page.pageToken : nil
} while pageToken != nil
return allRecords
.map { BugMapper.map($0, config: config) }
.filter { $0.assignee == assigneeName }
}
func fetchRecordCount(
appToken: String,
tableId: String,
accessToken: String
) async throws -> Int {
let page = try await fetchPage(
appToken: appToken,
tableId: tableId,
pageToken: nil,
pageSize: 1,
accessToken: accessToken
)
return page.total ?? page.items.count
}
private func fetchPage(
appToken: String,
tableId: String,
pageToken: String?,
pageSize: Int = 500,
accessToken: String
) async throws -> RecordListData {
var components = URLComponents(
string: "\(baseURL)/bitable/v1/apps/\(appToken)/tables/\(tableId)/records"
)!
var queryItems = [URLQueryItem(name: "page_size", value: String(pageSize))]
if let pageToken {
queryItems.append(URLQueryItem(name: "page_token", value: pageToken))
}
components.queryItems = queryItems
var request = URLRequest(url: components.url!)
request.httpMethod = "GET"
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.timeoutInterval = 30
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw FeishuError.networkError(nil)
}
if httpResponse.statusCode == 401 {
throw FeishuError.unauthorized
}
let apiResponse: FeishuAPIResponse<RecordListData>
do {
apiResponse = try decoder.decode(FeishuAPIResponse<RecordListData>.self, from: data)
} catch {
throw FeishuError.decodingError(error)
}
guard apiResponse.code == 0, let pageData = apiResponse.data else {
throw FeishuError.apiError(code: apiResponse.code, message: apiResponse.msg)
}
return pageData
}
}

View File

@ -0,0 +1,105 @@
import AppKit
import Foundation
import UserNotifications
final class NotificationService: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationService()
private let center = UNUserNotificationCenter.current()
private var isAuthorized = false
private override init() {
super.init()
center.delegate = self
}
func requestAuthorizationIfNeeded() async {
guard !isAuthorized else { return }
let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
isAuthorized = granted
}
func handleChanges(_ changes: [BugChange]) async {
await requestAuthorizationIfNeeded()
guard isAuthorized else { return }
let significant = changes
.filter { $0.type == .newBug || $0.type == .priorityChanged }
.prefix(3)
for change in significant {
deliver(change)
}
let statusChanges = changes.filter { $0.type == .statusChanged }
if statusChanges.count > 1 {
deliverBatchStatusChange(statusChanges)
} else if let single = statusChanges.first {
deliver(single)
}
}
private func deliver(_ change: BugChange) {
let content = UNMutableNotificationContent()
switch change.type {
case .newBug:
content.title = "New Bug Assigned"
content.body = "[\(change.bug.priority.rawValue)] \(change.bug.title)"
content.sound = .default
case .statusChanged:
content.title = "Bug Status Changed"
content.body = "\(change.bug.title)\(change.bug.status.rawValue)"
case .priorityChanged:
content.title = "Bug Priority Changed"
content.body = "\(change.bug.title)\(change.bug.priority.rawValue)"
case .assigneeChanged:
return
}
content.userInfo = [
"bugId": change.bug.id,
"feishuURL": change.bug.feishuURL.absoluteString
]
let request = UNNotificationRequest(
identifier: "bugger-\(change.bug.id)-\(Date().timeIntervalSince1970)",
content: content,
trigger: nil
)
center.add(request)
}
private func deliverBatchStatusChange(_ changes: [BugChange]) {
let content = UNMutableNotificationContent()
content.title = "\(changes.count) Bugs Updated"
content.body = changes.prefix(3).map { "\($0.bug.title)" }.joined(separator: "\n")
content.sound = .default
let request = UNNotificationRequest(
identifier: "bugger-batch-\(Date().timeIntervalSince1970)",
content: content,
trigger: nil
)
center.add(request)
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .sound])
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
if let urlString = response.notification.request.content.userInfo["feishuURL"] as? String,
let url = URL(string: urlString) {
NSWorkspace.shared.open(url)
}
completionHandler()
}
}

View File

@ -0,0 +1,100 @@
import AppKit
import Foundation
@Observable
final class PollerService {
static let shared = PollerService()
private let bugStore = BugStore.shared
private let feishuService = FeishuService()
private let tokenManager = TokenManager.shared
private let configService = AppStateService.shared
private var timer: Timer?
private var isFetching = false
private(set) var isRunning = false
private init() {}
func startIfConfigured() async {
guard let config = configService.config,
config.isConfigured,
tokenManager.isAuthenticated else {
return
}
start(interval: TimeInterval(config.pollIntervalSeconds))
}
func start(interval: TimeInterval) {
guard !isRunning else { return }
isRunning = true
Task { await performFetch() }
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
Task { await self?.performFetch() }
}
timer?.tolerance = interval * 0.1
}
func stop() {
timer?.invalidate()
timer = nil
isRunning = false
}
func restart(interval: TimeInterval) {
stop()
start(interval: interval)
}
func fetchNow() async {
await performFetch()
}
private func performFetch() async {
guard !isFetching else { return }
isFetching = true
defer { isFetching = false }
await MainActor.run {
bugStore.setLoading(true)
bugStore.setError(nil)
}
do {
let config = try getConfig()
let token = try await tokenManager.getAccessToken()
let assignee = try await tokenManager.resolveAssigneeName(config: config, accessToken: token)
let bugs = try await feishuService.fetchBugs(
config: config,
assigneeName: assignee,
accessToken: token
)
await MainActor.run {
bugStore.update(with: bugs)
bugStore.setLoading(false)
AppDelegate.shared?.updateBadge(count: bugStore.unseenActiveCount)
}
} catch FeishuError.unauthorized {
await MainActor.run {
bugStore.setLoading(false)
tokenManager.clearTokens()
}
} catch {
await MainActor.run {
bugStore.setLoading(false)
bugStore.setError(error)
}
}
}
private func getConfig() throws -> AppConfig {
guard let config = configService.config, config.isConfigured else {
throw FeishuError.notConfigured
}
return config
}
}

View File

@ -0,0 +1,138 @@
import Foundation
@Observable
final class TokenManager {
static let shared = TokenManager()
private let keychain = KeychainHelper.shared
private let authService = FeishuAuthService()
private let accessTokenKey = "feishu.access_token"
private let refreshTokenKey = "feishu.refresh_token"
private let tokenExpiryKey = "feishu.token_expiry"
enum State: Equatable {
case unauthenticated
case authenticating
case authenticated
case refreshing
case error(String)
}
private(set) var state: State = .unauthenticated
private init() {
if keychain.read(accessTokenKey) != nil {
state = .authenticated
}
}
var isAuthenticated: Bool {
if case .authenticated = state { return true }
if keychain.read(accessTokenKey) != nil { return true }
return false
}
var authorizeURL: URL? {
guard authService.hasCredentials else { return nil }
return authService.authorizeURL
}
func getAccessToken() async throws -> String {
if let token = keychain.read(accessTokenKey),
let expiry = UserDefaults.standard.object(forKey: tokenExpiryKey) as? Date,
expiry > Date().addingTimeInterval(60) {
state = .authenticated
return token
}
guard let refreshToken = keychain.read(refreshTokenKey) else {
state = .unauthenticated
throw TokenError.noRefreshToken
}
state = .refreshing
do {
let response = try await authService.refreshAccessToken(refreshToken)
storeTokens(
access: response.accessToken,
refresh: response.refreshToken ?? refreshToken,
expiresIn: response.expiresIn
)
state = .authenticated
return response.accessToken
} catch {
state = .error(error.localizedDescription)
throw error
}
}
func storeTokens(access: String, refresh: String, expiresIn: Int) {
keychain.write(access, forKey: accessTokenKey)
keychain.write(refresh, forKey: refreshTokenKey)
let expiry = Date().addingTimeInterval(TimeInterval(expiresIn))
UserDefaults.standard.set(expiry, forKey: tokenExpiryKey)
state = .authenticated
}
func clearTokens() {
keychain.delete(accessTokenKey)
keychain.delete(refreshTokenKey)
UserDefaults.standard.removeObject(forKey: tokenExpiryKey)
state = .unauthenticated
}
func setError(_ message: String) {
state = .error(message)
}
func handleCallback(url: URL) async throws {
guard let code = extractCode(from: url) else {
throw TokenError.invalidCallback
}
state = .authenticating
let response = try await authService.exchangeCode(code)
storeTokens(
access: response.accessToken,
refresh: response.refreshToken ?? "",
expiresIn: response.expiresIn
)
state = .authenticated
}
func resolveAssigneeName(config: AppConfig, accessToken: String) async throws -> String {
if !config.assigneeName.isEmpty {
return config.assigneeName
}
return try await authService.fetchCurrentUserName(accessToken: accessToken)
}
private func extractCode(from url: URL) -> String? {
guard url.scheme == "bugger",
url.host == "oauth",
url.path == "/callback" else {
return nil
}
return URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?
.first(where: { $0.name == "code" })?
.value
}
}
enum TokenError: Error, LocalizedError {
case noRefreshToken
case invalidCallback
case refreshFailed
var errorDescription: String? {
switch self {
case .noRefreshToken:
return "No Feishu session found. Please connect Feishu."
case .invalidCallback:
return "Invalid OAuth callback URL."
case .refreshFailed:
return "Failed to refresh Feishu token."
}
}
}

View File

@ -0,0 +1,26 @@
import SwiftUI
extension BugPriority {
var color: Color {
switch self {
case .p0: .red
case .p1: .orange
case .p2: .blue
case .p3: .gray
case .unknown: .gray.opacity(0.5)
}
}
}
extension BugStatus {
var color: Color {
switch self {
case .open: .red
case .inProgress: .yellow
case .inReview: .purple
case .resolved: .green
case .closed: .gray
case .unknown: .gray.opacity(0.5)
}
}
}

View File

@ -0,0 +1,25 @@
import Foundation
enum FeishuDateParser {
static func parse(_ value: Any?) -> Date? {
switch value {
case let milliseconds as Double:
return Date(timeIntervalSince1970: milliseconds / 1000.0)
case let milliseconds as Int:
return Date(timeIntervalSince1970: Double(milliseconds) / 1000.0)
case let string as String:
if let milliseconds = Double(string) {
return Date(timeIntervalSince1970: milliseconds / 1000.0)
}
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: string) {
return date
}
formatter.formatOptions = [.withInternetDateTime]
return formatter.date(from: string)
default:
return nil
}
}
}

View File

@ -0,0 +1,47 @@
import Foundation
import Security
final class KeychainHelper {
static let shared = KeychainHelper()
private let service = "com.xorbitlab.bugger"
private init() {}
func write(_ value: String, forKey key: String) {
let data = Data(value.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
func read(_ key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data else {
return nil
}
return String(data: data, encoding: .utf8)
}
func delete(_ key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
}

View File

@ -0,0 +1,16 @@
import Foundation
enum FeishuURLBuilder {
static func recordURL(
baseDomain: String,
appToken: String,
tableId: String,
recordId: String
) -> URL {
URL(string: "https://\(baseDomain)/base/\(appToken)/table/\(tableId)/record/\(recordId)")!
}
static func tableURL(baseDomain: String, appToken: String) -> URL {
URL(string: "https://\(baseDomain)/base/\(appToken)")!
}
}

View File

@ -0,0 +1,108 @@
import Foundation
@Observable
final class BugStore {
static let shared = BugStore()
private(set) var bugs: [Bug] = []
private(set) var unseenBugs: Set<String> = []
private(set) var lastUpdated: Date?
private(set) var isLoading = false
private(set) var error: Error?
var activeBugs: [Bug] {
bugs.filter { $0.status != .closed && $0.status != .resolved }
}
var unseenActiveCount: Int {
activeBugs.filter { unseenBugs.contains($0.id) }.count
}
var bugsSortedByPriority: [Bug] {
activeBugs.sorted { lhs, rhs in
if lhs.priority != rhs.priority {
return lhs.priority < rhs.priority
}
return lhs.createdAt < rhs.createdAt
}
}
private init() {
unseenBugs = AppStateService.shared.persistedSeenBugs
}
func update(with newBugs: [Bug]) {
let oldBugs = bugs
let oldIDs = Set(oldBugs.map(\.id))
let newIDs = Set(newBugs.map(\.id))
let added = newIDs.subtracting(oldIDs)
unseenBugs.formUnion(added)
let changes = detectChanges(old: oldBugs, new: newBugs)
if !changes.isEmpty {
Task {
await NotificationService.shared.handleChanges(changes)
}
}
bugs = newBugs
lastUpdated = Date()
error = nil
AppStateService.shared.updateSeenBugs(unseenBugs)
}
func markSeen(_ bugID: String) {
unseenBugs.remove(bugID)
AppStateService.shared.updateSeenBugs(unseenBugs)
}
func markAllSeen() {
unseenBugs.removeAll()
AppStateService.shared.updateSeenBugs(unseenBugs)
}
func setLoading(_ loading: Bool) {
isLoading = loading
}
func setError(_ error: Error?) {
self.error = error
}
private func detectChanges(old: [Bug], new: [Bug]) -> [BugChange] {
let oldMap = Dictionary(uniqueKeysWithValues: old.map { ($0.id, $0) })
var changes: [BugChange] = []
for newBug in new {
guard let oldBug = oldMap[newBug.id] else {
changes.append(
BugChange(type: .newBug, bug: newBug, oldStatus: nil, oldAssignee: nil)
)
continue
}
if oldBug.status != newBug.status {
changes.append(
BugChange(type: .statusChanged, bug: newBug, oldStatus: oldBug.status, oldAssignee: nil)
)
}
if oldBug.priority != newBug.priority {
changes.append(
BugChange(type: .priorityChanged, bug: newBug, oldStatus: nil, oldAssignee: nil)
)
}
if oldBug.assignee != newBug.assignee {
changes.append(
BugChange(
type: .assigneeChanged,
bug: newBug,
oldStatus: nil,
oldAssignee: oldBug.assignee
)
)
}
}
return changes
}
}

View File

@ -0,0 +1,60 @@
import AppKit
import SwiftUI
final class FloatingWidgetWindow: NSPanel {
init() {
super.init(
contentRect: NSRect(x: 0, y: 0, width: 80, height: 60),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
isFloatingPanel = true
level = .floating
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
isOpaque = false
backgroundColor = .clear
hasShadow = true
isMovableByWindowBackground = true
hidesOnDeactivate = false
animationBehavior = .none
if let screen = NSScreen.main {
let screenFrame = screen.visibleFrame
setFrameOrigin(NSPoint(x: screenFrame.maxX - 100, y: screenFrame.minY + 200))
}
contentView = NSHostingView(rootView: FloatingWidgetView())
}
override var canBecomeKey: Bool { false }
override var canBecomeMain: Bool { false }
}
struct FloatingWidgetView: View {
@State private var bugStore = BugStore.shared
var body: some View {
Button {
AppDelegate.shared?.togglePopover()
} label: {
HStack(spacing: 6) {
Image(systemName: "ant.fill")
.font(.title3)
Text("\(bugStore.unseenActiveCount)")
.font(.title2)
.fontWeight(.bold)
.contentTransition(.numericText())
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.ultraThinMaterial)
.shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 4)
)
}
.buttonStyle(.plain)
}
}

View File

@ -0,0 +1,149 @@
import AppKit
import SwiftUI
struct BugListPopover: View {
@State private var bugStore = BugStore.shared
@State private var tokenManager = TokenManager.shared
var body: some View {
VStack(spacing: 0) {
header
Divider()
content
Divider()
footer
}
.frame(minWidth: 340, idealWidth: 360, maxWidth: 400, minHeight: 280)
}
private var header: some View {
HStack {
Text("My Bugs")
.font(.headline)
Spacer()
if bugStore.isLoading {
ProgressView()
.scaleEffect(0.7)
.frame(width: 16, height: 16)
}
if let lastUpdated = bugStore.lastUpdated {
Text(formatted(lastUpdated))
.font(.caption)
.foregroundStyle(.secondary)
}
Button("Mark all seen") {
bugStore.markAllSeen()
AppDelegate.shared?.updateBadge(count: bugStore.unseenActiveCount)
}
.font(.caption)
}
.padding(.horizontal)
.padding(.vertical, 8)
}
@ViewBuilder
private var content: some View {
if !tokenManager.isAuthenticated {
OAuthSetupView()
} else if let error = bugStore.error {
ErrorStateView(error: error)
} else if bugStore.bugsSortedByPriority.isEmpty && !bugStore.isLoading {
EmptyStateView()
} else {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(bugStore.bugsSortedByPriority) { bug in
BugRow(
bug: bug,
isUnseen: bugStore.unseenBugs.contains(bug.id)
)
.onTapGesture {
openInFeishu(bug)
bugStore.markSeen(bug.id)
AppDelegate.shared?.updateBadge(count: bugStore.unseenActiveCount)
}
Divider().padding(.leading, 44)
}
}
}
}
}
private var footer: some View {
HStack {
Button(action: openFeishuTable) {
Label("Open in Feishu", systemImage: "arrow.up.forward.app")
}
Spacer()
Button("Refresh") {
Task { await PollerService.shared.fetchNow() }
}
SettingsLink {
Label("Settings", systemImage: "gear")
}
}
.padding(.horizontal)
.padding(.vertical, 6)
.buttonStyle(.plain)
.font(.caption)
}
private func openInFeishu(_ bug: Bug) {
NSWorkspace.shared.open(bug.feishuURL)
AppDelegate.shared?.closePopover()
}
private func openFeishuTable() {
guard let config = AppStateService.shared.config else { return }
let url = FeishuURLBuilder.tableURL(
baseDomain: config.feishuBaseDomain,
appToken: config.appToken
)
NSWorkspace.shared.open(url)
}
private func formatted(_ date: Date) -> String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return formatter.localizedString(for: date, relativeTo: Date())
}
}
struct EmptyStateView: View {
var body: some View {
VStack(spacing: 8) {
Image(systemName: "checkmark.circle")
.font(.largeTitle)
.foregroundStyle(.green)
Text("No active bugs")
.font(.headline)
Text("You're all clear!")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
}
struct ErrorStateView: View {
let error: Error
var body: some View {
VStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
.foregroundStyle(.orange)
Text("Failed to load bugs")
.font(.headline)
Text(error.localizedDescription)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
}

View File

@ -0,0 +1,82 @@
import SwiftUI
struct BugRow: View {
let bug: Bug
let isUnseen: Bool
var body: some View {
HStack(spacing: 10) {
PriorityBadge(priority: bug.priority)
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(bug.title)
.font(.system(size: 13))
.lineLimit(1)
if isUnseen {
Circle()
.fill(.blue)
.frame(width: 6, height: 6)
}
}
HStack(spacing: 8) {
StatusPill(status: bug.status)
Text(ageString(bug.age))
.font(.caption2)
.foregroundStyle(.secondary)
if let reporter = bug.reporter {
Text("by \(reporter)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(isUnseen ? Color.blue.opacity(0.05) : Color.clear)
}
private func ageString(_ age: TimeInterval) -> String {
let days = Int(age / 86_400)
let hours = Int(age / 3_600)
if days > 0 { return "\(days)d" }
if hours > 0 { return "\(hours)h" }
return "just now"
}
}
struct PriorityBadge: View {
let priority: BugPriority
var body: some View {
Text(priority == .unknown ? "?" : priority.rawValue)
.font(.caption)
.fontWeight(.bold)
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(priority.color)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
}
struct StatusPill: View {
let status: BugStatus
var body: some View {
Text(status == .unknown ? "Unknown" : status.rawValue)
.font(.caption2)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(status.color.opacity(0.15))
.foregroundStyle(status.color)
.clipShape(RoundedRectangle(cornerRadius: 3))
}
}

View File

@ -0,0 +1,76 @@
import AppKit
import SwiftUI
struct OAuthSetupView: View {
@State private var tokenManager = TokenManager.shared
@State private var statusMessage: String?
var body: some View {
VStack(spacing: 16) {
Image(systemName: "link.circle")
.font(.system(size: 40))
.foregroundStyle(.blue)
Text("Connect Feishu")
.font(.headline)
Text("Authorize Bugger to read bugs assigned to you from your Feishu Bitable.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if tokenManager.authorizeURL == nil {
Text("Set FEISHU_APP_ID and FEISHU_APP_SECRET in the Xcode build settings before connecting.")
.font(.caption)
.foregroundStyle(.orange)
.multilineTextAlignment(.center)
} else {
Button("Connect Feishu") {
connectFeishu()
}
.buttonStyle(.borderedProminent)
}
if case .authenticating = tokenManager.state {
ProgressView("Completing sign-in...")
}
if case .error(let message) = tokenManager.state {
Text(message)
.font(.caption)
.foregroundStyle(.red)
.multilineTextAlignment(.center)
}
if let statusMessage {
Text(statusMessage)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func connectFeishu() {
guard let url = tokenManager.authorizeURL else { return }
statusMessage = "Complete authorization in your browser."
NSWorkspace.shared.open(url)
}
}
enum OAuthCallbackHandler {
static func handle(_ url: URL) {
Task {
do {
try await TokenManager.shared.handleCallback(url: url)
await PollerService.shared.startIfConfigured()
await PollerService.shared.fetchNow()
} catch {
await MainActor.run {
TokenManager.shared.setError(error.localizedDescription)
}
}
}
}
}

View File

@ -0,0 +1,126 @@
import ServiceManagement
import SwiftUI
struct SettingsView: View {
@State private var config: AppConfig
@State private var isTestingConnection = false
@State private var connectionResult: String?
init() {
_config = State(initialValue: AppStateService.shared.config ?? AppConfig())
}
var body: some View {
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)
HStack {
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)
}
}
}
Section("Polling") {
Picker("Check every", selection: $config.pollIntervalSeconds) {
Text("1 minute").tag(60)
Text("5 minutes").tag(300)
Text("10 minutes").tag(600)
Text("30 minutes").tag(1800)
}
}
Section("Display") {
Toggle("Show floating widget", isOn: $config.showFloatingWidget)
.onChange(of: config.showFloatingWidget) { _, enabled in
if enabled {
AppDelegate.shared?.showFloatingWidget()
} else {
AppDelegate.shared?.hideFloatingWidget()
}
}
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)
}
HStack {
Button("Save") {
save()
}
.keyboardShortcut(.return)
Button("Disconnect Feishu") {
TokenManager.shared.clearTokens()
PollerService.shared.stop()
}
.foregroundStyle(.red)
}
}
.padding()
.frame(width: 440, height: 560)
}
private func save() {
AppStateService.shared.saveConfig(config)
connectionResult = "Saved ✓"
if config.launchAtLogin {
try? SMAppService.mainApp.register()
} else {
try? SMAppService.mainApp.unregister()
}
if config.showFloatingWidget {
AppDelegate.shared?.showFloatingWidget()
} else {
AppDelegate.shared?.hideFloatingWidget()
}
Task {
PollerService.shared.restart(interval: TimeInterval(config.pollIntervalSeconds))
}
}
private func testConnection() {
isTestingConnection = true
connectionResult = nil
Task {
do {
let token = try await TokenManager.shared.getAccessToken()
let count = try await FeishuService().fetchRecordCount(
appToken: config.appToken,
tableId: config.tableId,
accessToken: token
)
connectionResult = "Connected ✓ (\(count) records)"
} catch {
connectionResult = "Failed: \(error.localizedDescription)"
}
isTestingConnection = false
}
}
}

View File

@ -0,0 +1,472 @@
#!/usr/bin/env python3
"""Generate Bugger.xcodeproj from source tree."""
import hashlib
import os
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PROJECT_NAME = "Bugger"
BUNDLE_ID = "com.xorbitlab.bugger"
SOURCE_ROOT = ROOT / "Sources"
RESOURCE_ROOT = ROOT / "Resources"
def uuid(seed: str) -> str:
digest = hashlib.md5(seed.encode()).hexdigest().upper()
return digest[:24]
def collect_swift_files() -> list[Path]:
return sorted(SOURCE_ROOT.rglob("*.swift"))
def collect_resources() -> list[Path]:
resources = []
for path in sorted(RESOURCE_ROOT.rglob("*")):
if path.is_file():
resources.append(path)
return resources
def pbxproj() -> str:
swift_files = collect_swift_files()
resources = collect_resources()
project_id = uuid("project")
target_id = uuid("target")
sources_phase_id = uuid("sources-phase")
resources_phase_id = uuid("resources-phase")
frameworks_phase_id = uuid("frameworks-phase")
product_ref_id = uuid("product-ref")
project_config_list_id = uuid("project-config-list")
target_config_list_id = uuid("target-config-list")
debug_config_id = uuid("debug-config")
release_config_id = uuid("release-config")
target_debug_config_id = uuid("target-debug-config")
target_release_config_id = uuid("target-release-config")
main_group_id = uuid("main-group")
products_group_id = uuid("products-group")
sources_group_id = uuid("sources-group")
resources_group_id = uuid("resources-group")
file_refs: dict[Path, str] = {}
build_files: list[tuple[str, str, str]] = []
for path in swift_files:
ref_id = uuid(f"file-{path}")
build_id = uuid(f"build-{path}")
file_refs[path] = ref_id
build_files.append((build_id, ref_id, "Sources"))
resource_refs: dict[Path, str] = {}
for path in resources:
ref_id = uuid(f"res-{path}")
build_id = uuid(f"res-build-{path}")
resource_refs[path] = ref_id
build_files.append((build_id, ref_id, "Resources"))
def group_for(path: Path, parent: str) -> str:
rel = path.relative_to(ROOT)
parts = rel.parts[:-1]
group_ids = [main_group_id]
current = ""
for part in parts:
current = f"{current}/{part}"
group_ids.append(uuid(f"group-{current}"))
return group_ids[-1]
groups: dict[str, list[str]] = {}
child_groups: dict[str, set[str]] = {}
def add_to_group(group_id: str, child_id: str):
groups.setdefault(group_id, []).append(child_id)
add_to_group(main_group_id, sources_group_id)
add_to_group(main_group_id, resources_group_id)
add_to_group(main_group_id, products_group_id)
add_to_group(products_group_id, product_ref_id)
def ensure_group_chain(rel_parts: tuple[str, ...], root_group: str):
current = root_group
built = []
for part in rel_parts:
built.append(part)
gid = uuid(f"group-{'/'.join(built)}")
if gid not in groups and gid != current:
add_to_group(current, gid)
current = gid
return current
for path in swift_files:
rel = path.relative_to(SOURCE_ROOT)
parent = ensure_group_chain(rel.parts[:-1], sources_group_id)
add_to_group(parent, file_refs[path])
for path in resources:
rel = path.relative_to(RESOURCE_ROOT)
if len(rel.parts) > 1:
parent = ensure_group_chain(rel.parts[:-1], resources_group_id)
else:
parent = resources_group_id
add_to_group(parent, resource_refs[path])
lines: list[str] = []
lines.append("// !$*UTF8*$!")
lines.append("{")
lines.append("\tarchiveVersion = 1;")
lines.append("\tclasses = {};")
lines.append("\tobjectVersion = 56;")
lines.append("\tobjects = {")
lines.append(f"\n/* Begin PBXBuildFile section */")
for build_id, ref_id, kind in build_files:
if kind == "Sources":
lines.append(f"\t\t{build_id} /* {Path(ref_id).name} in Sources */ = {{isa = PBXBuildFile; fileRef = {ref_id}; }};")
else:
lines.append(f"\t\t{build_id} /* {ref_id} in Resources */ = {{isa = PBXBuildFile; fileRef = {ref_id}; }};")
lines.append("/* End PBXBuildFile section */\n")
lines.append("/* Begin PBXFileReference section */")
lines.append(f"\t\t{product_ref_id} /* {PROJECT_NAME}.app */ = {{isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = {PROJECT_NAME}.app; sourceTree = BUILT_PRODUCTS_DIR; }};")
for path, ref_id in file_refs.items():
rel = path.relative_to(ROOT)
lines.append(f"\t\t{ref_id} /* {path.name} */ = {{isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = {path.name}; sourceTree = \"<group>\"; }};")
for path, ref_id in resource_refs.items():
rel = path.relative_to(ROOT)
suffix = path.suffix.lower()
if suffix == ".plist":
file_type = "text.plist.xml"
elif suffix == ".entitlements":
file_type = "text.plist.entitlements"
elif path.name == "Contents.json":
file_type = "text.json"
else:
file_type = "folder.assetcatalog" if path.parent.suffix == ".xcassets" and path.name == "Contents.json" and path.parent.parent.name == "Assets.xcassets" else "text.json"
if path.name == "Contents.json" and "Assets.xcassets" in str(path):
if path.parent == RESOURCE_ROOT / "Assets.xcassets":
continue
if path.parent.name.endswith(".appiconset"):
file_type = "text.json"
elif path.parent == RESOURCE_ROOT / "Assets.xcassets":
file_type = "folder.assetcatalog"
# asset catalog handled as single folder reference below
if "Assets.xcassets" in str(path) and path != RESOURCE_ROOT / "Assets.xcassets":
continue
if path == RESOURCE_ROOT / "Assets.xcassets":
lines.append(f"\t\t{ref_id} /* Assets.xcassets */ = {{isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = \"<group>\"; }};")
continue
lines.append(f"\t\t{ref_id} /* {path.name} */ = {{isa = PBXFileReference; lastKnownFileType = {file_type}; path = {path.name}; sourceTree = \"<group>\"; }};")
lines.append("/* End PBXFileReference section */\n")
# Simpler approach: rewrite resource refs cleanly
resource_file_refs = []
asset_ref = uuid("asset-catalog")
info_ref = uuid("info-plist")
ent_ref = uuid("entitlements")
resource_file_refs = [
(RESOURCE_ROOT / "Assets.xcassets", asset_ref, "folder.assetcatalog", "Assets.xcassets"),
(RESOURCE_ROOT / "Info.plist", info_ref, "text.plist.xml", "Info.plist"),
(RESOURCE_ROOT / "Bugger.entitlements", ent_ref, "text.plist.entitlements", "Bugger.entitlements"),
]
lines = []
lines.append("// !$*UTF8*$!")
lines.append("{")
lines.append("\tarchiveVersion = 1;")
lines.append("\tclasses = {};")
lines.append("\tobjectVersion = 56;")
lines.append("\tobjects = {")
lines.append("\n/* Begin PBXBuildFile section */")
swift_build_entries = []
for path in swift_files:
ref_id = uuid(f"file-{path}")
build_id = uuid(f"build-{path}")
swift_build_entries.append((build_id, ref_id, path.name))
lines.append(f"\t\t{build_id} /* {path.name} in Sources */ = {{isa = PBXBuildFile; fileRef = {ref_id}; }};")
asset_build_id = uuid("asset-build")
lines.append(f"\t\t{asset_build_id} /* Assets.xcassets in Resources */ = {{isa = PBXBuildFile; fileRef = {asset_ref}; }};")
lines.append("/* End PBXBuildFile section */\n")
lines.append("/* Begin PBXFileReference section */")
lines.append(f"\t\t{product_ref_id} /* {PROJECT_NAME}.app */ = {{isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = {PROJECT_NAME}.app; sourceTree = BUILT_PRODUCTS_DIR; }};")
swift_ref_entries = []
for path in swift_files:
ref_id = uuid(f"file-{path}")
swift_ref_entries.append((ref_id, path))
lines.append(f"\t\t{ref_id} /* {path.name} */ = {{isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = {path.name}; sourceTree = \"<group>\"; }};")
lines.append(f"\t\t{asset_ref} /* Assets.xcassets */ = {{isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = \"<group>\"; }};")
lines.append(f"\t\t{info_ref} /* Info.plist */ = {{isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = \"<group>\"; }};")
lines.append(f"\t\t{ent_ref} /* Bugger.entitlements */ = {{isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Bugger.entitlements; sourceTree = \"<group>\"; }};")
lines.append("/* End PBXFileReference section */\n")
def render_groups():
group_defs = []
def walk(dir_path: Path, group_name: str, group_id: str, parent_set: list[str]):
children = []
for child in sorted(dir_path.iterdir()):
if child.is_dir() and child.name != ".DS_Store":
child_id = uuid(f"group-{child}")
children.append(child_id)
walk(child, child.name, child_id, [])
elif child.suffix == ".swift":
ref_id = uuid(f"file-{child}")
children.append(ref_id)
if dir_path == RESOURCE_ROOT:
children = [asset_ref, info_ref, ent_ref]
if dir_path == ROOT:
children = [sources_group_id, resources_group_id, products_group_id]
if dir_path == SOURCE_ROOT:
children = []
for child in sorted(dir_path.iterdir()):
if child.is_dir():
children.append(uuid(f"group-{child}"))
elif child.suffix == ".swift":
children.append(uuid(f"file-{child}"))
child_lines = "\n".join([f"\t\t\t\t{id_}," for id_ in children])
path_line = f"path = {group_name};" if group_name not in ("", PROJECT_NAME) else ""
if group_id == main_group_id:
group_defs.append(
f"\t\t{group_id} = {{\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n{child_lines}\n\t\t\t);\n\t\t\tsourceTree = \"<group>\";\n\t\t}};"
)
elif group_id == products_group_id:
group_defs.append(
f"\t\t{group_id} = {{\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t{product_ref_id},\n\t\t\t);\n\t\t\tname = Products;\n\t\t\tsourceTree = \"<group>\";\n\t\t}};"
)
elif group_id == sources_group_id:
group_defs.append(
f"\t\t{group_id} = {{\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n{child_lines}\n\t\t\t);\n\t\t\tpath = Sources;\n\t\t\tsourceTree = \"<group>\";\n\t\t}};"
)
elif group_id == resources_group_id:
group_defs.append(
f"\t\t{group_id} = {{\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t{asset_ref},\n\t\t\t\t{info_ref},\n\t\t\t\t{ent_ref},\n\t\t\t);\n\t\t\tpath = Resources;\n\t\t\tsourceTree = \"<group>\";\n\t\t}};"
)
else:
rel = dir_path.relative_to(SOURCE_ROOT) if str(dir_path).startswith(str(SOURCE_ROOT)) else dir_path.name
group_defs.append(
f"\t\t{group_id} = {{\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n{child_lines}\n\t\t\t);\n\t\t\tpath = {dir_path.name};\n\t\t\tsourceTree = \"<group>\";\n\t\t}};"
)
walk(ROOT, PROJECT_NAME, main_group_id, [])
for child in sorted(SOURCE_ROOT.iterdir()):
if child.is_dir():
walk(child, child.name, uuid(f"group-{child}"), [])
return "\n".join(group_defs)
lines.append("/* Begin PBXFrameworksBuildPhase section */")
lines.append(f"\t\t{frameworks_phase_id} /* Frameworks */ = {{")
lines.append("\t\t\tisa = PBXFrameworksBuildPhase;")
lines.append("\t\t\tbuildActionMask = 2147483647;")
lines.append("\t\t\tfiles = (")
lines.append("\t\t\t);")
lines.append("\t\t\trunOnlyForDeploymentPostprocessing = 0;")
lines.append("\t\t};")
lines.append("/* End PBXFrameworksBuildPhase section */\n")
lines.append("/* Begin PBXGroup section */")
lines.append(render_groups())
lines.append("/* End PBXGroup section */\n")
lines.append("/* Begin PBXNativeTarget section */")
lines.append(f"\t\t{target_id} /* {PROJECT_NAME} */ = {{")
lines.append("\t\t\tisa = PBXNativeTarget;")
lines.append(f"\t\t\tbuildConfigurationList = {target_config_list_id} /* Build configuration list for PBXNativeTarget \"{PROJECT_NAME}\" */;")
lines.append("\t\t\tbuildPhases = (")
lines.append(f"\t\t\t\t{sources_phase_id} /* Sources */,")
lines.append(f"\t\t\t\t{frameworks_phase_id} /* Frameworks */,")
lines.append(f"\t\t\t\t{resources_phase_id} /* Resources */,")
lines.append("\t\t\t);")
lines.append("\t\t\tbuildRules = (")
lines.append("\t\t\t);")
lines.append("\t\t\tdependencies = (")
lines.append("\t\t\t);")
lines.append(f"\t\t\tname = {PROJECT_NAME};")
lines.append(f"\t\t\tproductName = {PROJECT_NAME};")
lines.append(f"\t\t\tproductReference = {product_ref_id} /* {PROJECT_NAME}.app */;")
lines.append("\t\t\tproductType = \"com.apple.product-type.application\";")
lines.append("\t\t};")
lines.append("/* End PBXNativeTarget section */\n")
lines.append("/* Begin PBXProject section */")
lines.append(f"\t\t{project_id} /* Project object */ = {{")
lines.append("\t\t\tisa = PBXProject;")
lines.append("\t\t\tattributes = {")
lines.append("\t\t\t\tBuildIndependentTargetsInParallel = 1;")
lines.append("\t\t\t\tLastSwiftUpdateCheck = 1500;")
lines.append("\t\t\t\tLastUpgradeCheck = 1500;")
lines.append("\t\t\t\tTargetAttributes = {")
lines.append(f"\t\t\t\t\t{target_id} = {{")
lines.append("\t\t\t\t\t\tCreatedOnToolsVersion = 15.0;")
lines.append("\t\t\t\t\t};")
lines.append("\t\t\t\t};")
lines.append("\t\t\t};")
lines.append(f"\t\t\tbuildConfigurationList = {project_config_list_id} /* Build configuration list for PBXProject \"{PROJECT_NAME}\" */;")
lines.append("\t\t\tcompatibilityVersion = \"Xcode 14.0\";")
lines.append("\t\t\tdevelopmentRegion = en;")
lines.append("\t\t\thasScannedForEncodings = 0;")
lines.append("\t\t\tknownRegions = (")
lines.append("\t\t\t\ten,")
lines.append("\t\t\t\tBase,")
lines.append("\t\t\t);")
lines.append(f"\t\t\tmainGroup = {main_group_id};")
lines.append(f"\t\t\tproductRefGroup = {products_group_id} /* Products */;")
lines.append("\t\t\tprojectDirPath = \"\";")
lines.append("\t\t\tprojectRoot = \"\";")
lines.append("\t\t\ttargets = (")
lines.append(f"\t\t\t\t{target_id} /* {PROJECT_NAME} */,")
lines.append("\t\t\t);")
lines.append("\t\t};")
lines.append("/* End PBXProject section */\n")
lines.append("/* Begin PBXResourcesBuildPhase section */")
lines.append(f"\t\t{resources_phase_id} /* Resources */ = {{")
lines.append("\t\t\tisa = PBXResourcesBuildPhase;")
lines.append("\t\t\tbuildActionMask = 2147483647;")
lines.append("\t\t\tfiles = (")
lines.append(f"\t\t\t\t{asset_build_id} /* Assets.xcassets in Resources */,")
lines.append("\t\t\t);")
lines.append("\t\t\trunOnlyForDeploymentPostprocessing = 0;")
lines.append("\t\t};")
lines.append("/* End PBXResourcesBuildPhase section */\n")
lines.append("/* Begin PBXSourcesBuildPhase section */")
lines.append(f"\t\t{sources_phase_id} /* Sources */ = {{")
lines.append("\t\t\tisa = PBXSourcesBuildPhase;")
lines.append("\t\t\tbuildActionMask = 2147483647;")
lines.append("\t\t\tfiles = (")
for build_id, _, name in swift_build_entries:
lines.append(f"\t\t\t\t{build_id} /* {name} in Sources */,")
lines.append("\t\t\t);")
lines.append("\t\t\trunOnlyForDeploymentPostprocessing = 0;")
lines.append("\t\t};")
lines.append("/* End PBXSourcesBuildPhase section */\n")
common_settings = [
("ASSETCATALOG_COMPILER_APPICON_NAME", "AppIcon"),
("CODE_SIGN_ENTITLEMENTS", "Resources/Bugger.entitlements"),
("CODE_SIGN_STYLE", "Automatic"),
("COMBINE_HIDPI_IMAGES", "YES"),
("CURRENT_PROJECT_VERSION", "1"),
("DEVELOPMENT_TEAM", ""),
("ENABLE_HARDENED_RUNTIME", "YES"),
("FEISHU_APP_ID", ""),
("FEISHU_APP_SECRET", ""),
("FEISHU_BASE_DOMAIN", "xorbitlab.feishu.cn"),
("GENERATE_INFOPLIST_FILE", "NO"),
("INFOPLIST_FILE", "Resources/Info.plist"),
("LD_RUNPATH_SEARCH_PATHS", "$(inherited) @executable_path/../Frameworks"),
("MACOSX_DEPLOYMENT_TARGET", "14.0"),
("MARKETING_VERSION", "1.0"),
("PRODUCT_BUNDLE_IDENTIFIER", BUNDLE_ID),
("PRODUCT_NAME", "$(TARGET_NAME)"),
("SWIFT_EMIT_LOC_STRINGS", "YES"),
("SWIFT_VERSION", "5.0"),
]
def config_block(config_id: str, name: str, settings: list[tuple[str, str]]) -> str:
setting_lines = "\n".join([f"\t\t\t\t{key} = {value};" for key, value in settings])
return (
f"\t\t{config_id} /* {name} */ = {{\n"
f"\t\t\tisa = XCBuildConfiguration;\n"
f"\t\t\tbuildSettings = {{\n{setting_lines}\n"
f"\t\t\t}};\n"
f"\t\t\tname = {name};\n"
f"\t\t}};"
)
lines.append("/* Begin XCBuildConfiguration section */")
lines.append(config_block(debug_config_id, "Debug", [("ALWAYS_SEARCH_USER_PATHS", "NO"), ("CLANG_ENABLE_MODULES", "YES"), ("COPY_PHASE_STRIP", "NO"), ("DEBUG_INFORMATION_FORMAT", "dwarf"), ("GCC_DYNAMIC_NO_PIC", "NO"), ("GCC_OPTIMIZATION_LEVEL", "0"), ("ONLY_ACTIVE_ARCH", "YES"), ("SDKROOT", "macosx"), ("SWIFT_ACTIVE_COMPILATION_CONDITIONS", "DEBUG"), ("SWIFT_OPTIMIZATION_LEVEL", "-Onone")]))
lines.append(config_block(release_config_id, "Release", [("ALWAYS_SEARCH_USER_PATHS", "NO"), ("CLANG_ENABLE_MODULES", "YES"), ("COPY_PHASE_STRIP", "NO"), ("DEBUG_INFORMATION_FORMAT", "dwarf-with-dsym"), ("SDKROOT", "macosx"), ("SWIFT_COMPILATION_MODE", "wholemodule")]))
lines.append(config_block(target_debug_config_id, "Debug", common_settings))
lines.append(config_block(target_release_config_id, "Release", common_settings))
lines.append("/* End XCBuildConfiguration section */\n")
lines.append("/* Begin XCConfigurationList section */")
lines.append(f"\t\t{project_config_list_id} /* Build configuration list for PBXProject \"{PROJECT_NAME}\" */ = {{")
lines.append("\t\t\tisa = XCConfigurationList;")
lines.append("\t\t\tbuildConfigurations = (")
lines.append(f"\t\t\t\t{debug_config_id} /* Debug */,")
lines.append(f"\t\t\t\t{release_config_id} /* Release */,")
lines.append("\t\t\t);")
lines.append("\t\t\tdefaultConfigurationIsVisible = 0;")
lines.append("\t\t\tdefaultConfigurationName = Release;")
lines.append("\t\t};")
lines.append(f"\t\t{target_config_list_id} /* Build configuration list for PBXNativeTarget \"{PROJECT_NAME}\" */ = {{")
lines.append("\t\t\tisa = XCConfigurationList;")
lines.append("\t\t\tbuildConfigurations = (")
lines.append(f"\t\t\t\t{target_debug_config_id} /* Debug */,")
lines.append(f"\t\t\t\t{target_release_config_id} /* Release */,")
lines.append("\t\t\t);")
lines.append("\t\t\tdefaultConfigurationIsVisible = 0;")
lines.append("\t\t\tdefaultConfigurationName = Release;")
lines.append("\t\t};")
lines.append("/* End XCConfigurationList section */")
lines.append("\t};")
lines.append(f"\trootObject = {project_id} /* Project object */;")
lines.append("}")
return "\n".join(lines)
def main():
project_dir = ROOT / f"{PROJECT_NAME}.xcodeproj"
project_dir.mkdir(exist_ok=True)
(project_dir / "project.pbxproj").write_text(pbxproj())
scheme_dir = project_dir / "xcshareddata" / "xcschemes"
scheme_dir.mkdir(parents=True, exist_ok=True)
(scheme_dir / f"{PROJECT_NAME}.xcscheme").write_text(f"""<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1500"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "{uuid('target')}"
BuildableName = "{PROJECT_NAME}.app"
BlueprintName = "{PROJECT_NAME}"
ReferencedContainer = "container:{PROJECT_NAME}.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "{uuid('target')}"
BuildableName = "{PROJECT_NAME}.app"
BlueprintName = "{PROJECT_NAME}"
ReferencedContainer = "container:{PROJECT_NAME}.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
</Scheme>
""")
print(f"Generated {project_dir}")
if __name__ == "__main__":
main()