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