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:
parent
a9c90f64b9
commit
d9d8ce3e46
|
|
@ -0,0 +1,6 @@
|
|||
build/
|
||||
DerivedData/
|
||||
*.xcuserstate
|
||||
xcuserdata/
|
||||
Config.xcconfig
|
||||
.DS_Store
|
||||
|
|
@ -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 */;
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
</Workspace>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct BuggerApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
Settings {
|
||||
SettingsView()
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)")!
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue