diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3e089a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +build/ +DerivedData/ +*.xcuserstate +xcuserdata/ +Config.xcconfig +.DS_Store diff --git a/Bugger.xcodeproj/project.pbxproj b/Bugger.xcodeproj/project.pbxproj new file mode 100644 index 0000000..bd71bba --- /dev/null +++ b/Bugger.xcodeproj/project.pbxproj @@ -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 = ""; }; + 7705247A59F50F6B6E6D286F /* BuggerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuggerApp.swift; sourceTree = ""; }; + AE8899127BDCD00F1D39E8FD /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfig.swift; sourceTree = ""; }; + C4DE27079694EBA15A0DFAAB /* Bug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bug.swift; sourceTree = ""; }; + FE785F676266AFB5EA636597 /* BugChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugChange.swift; sourceTree = ""; }; + ADA8A640EF741D9404316C39 /* BugPriority.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugPriority.swift; sourceTree = ""; }; + 649CAAB9009813D6C1FD07E3 /* BugStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugStatus.swift; sourceTree = ""; }; + E3148982E653318C7E3CD13F /* AppStateService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateService.swift; sourceTree = ""; }; + 264DA84D71E29D64DEC1B038 /* FeishuAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuAuthService.swift; sourceTree = ""; }; + 38A9F363FE066CAE6226A050 /* FeishuError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuError.swift; sourceTree = ""; }; + 973BF857A0044A0262A1F56F /* FeishuModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuModels.swift; sourceTree = ""; }; + 66321820D32BCC5D05265115 /* FeishuService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuService.swift; sourceTree = ""; }; + C98E94F5A859B0EEC7326C60 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + BD2692AA96D65949109FEC0E /* PollerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollerService.swift; sourceTree = ""; }; + 9677914DC46FD106A90CA724 /* TokenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenManager.swift; sourceTree = ""; }; + 968D99A1DA7131447D075F27 /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color+Extensions.swift; sourceTree = ""; }; + 0BE6905A92E43B24E24CDE29 /* DateFormatter+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatter+Extensions.swift; sourceTree = ""; }; + 527F9B3FF513B72F175A8429 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = ""; }; + E390AA6A998B6C83BD779018 /* URL+Feishu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL+Feishu.swift; sourceTree = ""; }; + 6C251D0198118D6B5D4B7C69 /* BugStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugStore.swift; sourceTree = ""; }; + 5DC24ADCEDA5C16DFF9FA6EA /* FloatingWidgetWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingWidgetWindow.swift; sourceTree = ""; }; + 17D10F852D16E2F4FC269FD2 /* BugListPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugListPopover.swift; sourceTree = ""; }; + C3F3DB4764E379E663475EBD /* BugRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugRow.swift; sourceTree = ""; }; + E29172EAB6F6B4526BDA23C2 /* OAuthSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthSetupView.swift; sourceTree = ""; }; + 9F9AD7665552E1BC2E76337C /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 6A93EEBDA5A740C84995D959 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 2EB7B1BD755DF7F2FB5F8392 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 86B3BC083DF59791BE56CFAE /* Bugger.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Bugger.entitlements; sourceTree = ""; }; +/* 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 = ""; + }; + B75E6ED1F517C8DE98A7DAEA = { + isa = PBXGroup; + children = ( + + ); + path = Bugger.xcodeproj; + sourceTree = ""; + }; + 7C0FCEE666858DE48E2D02AD = { + isa = PBXGroup; + children = ( + + ); + path = AppIcon.appiconset; + sourceTree = ""; + }; + FB79BDD8FFC3CC36C25BA315 = { + isa = PBXGroup; + children = ( + 7C0FCEE666858DE48E2D02AD, + ); + path = Assets.xcassets; + sourceTree = ""; + }; + 7C4EE8955498EEF65ED6C84A = { + isa = PBXGroup; + children = ( + 6A93EEBDA5A740C84995D959, + 2EB7B1BD755DF7F2FB5F8392, + 86B3BC083DF59791BE56CFAE, + ); + path = Resources; + sourceTree = ""; + }; + B805360275E47AAD7192E4AE = { + isa = PBXGroup; + children = ( + AE8899127BDCD00F1D39E8FD, + C4DE27079694EBA15A0DFAAB, + FE785F676266AFB5EA636597, + ADA8A640EF741D9404316C39, + 649CAAB9009813D6C1FD07E3, + ); + path = Models; + sourceTree = ""; + }; + 0B0ED8A1BE17CC4C0B3A787B = { + isa = PBXGroup; + children = ( + 264DA84D71E29D64DEC1B038, + 38A9F363FE066CAE6226A050, + 973BF857A0044A0262A1F56F, + 66321820D32BCC5D05265115, + ); + path = Feishu; + sourceTree = ""; + }; + EF310F3F9A8796C845F345F5 = { + isa = PBXGroup; + children = ( + E3148982E653318C7E3CD13F, + 0B0ED8A1BE17CC4C0B3A787B, + C98E94F5A859B0EEC7326C60, + BD2692AA96D65949109FEC0E, + 9677914DC46FD106A90CA724, + ); + path = Services; + sourceTree = ""; + }; + B38F908829C30D6B6F967EEA = { + isa = PBXGroup; + children = ( + 968D99A1DA7131447D075F27, + 0BE6905A92E43B24E24CDE29, + 527F9B3FF513B72F175A8429, + E390AA6A998B6C83BD779018, + ); + path = Utils; + sourceTree = ""; + }; + 56714C7161F5C8195EA19816 = { + isa = PBXGroup; + children = ( + 6C251D0198118D6B5D4B7C69, + ); + path = ViewModels; + sourceTree = ""; + }; + 82B5ED67A4830EDA75C6CDE1 = { + isa = PBXGroup; + children = ( + 5DC24ADCEDA5C16DFF9FA6EA, + ); + path = FloatingWidget; + sourceTree = ""; + }; + 24C578F83DCF9CEAFCAF2E73 = { + isa = PBXGroup; + children = ( + 17D10F852D16E2F4FC269FD2, + C3F3DB4764E379E663475EBD, + ); + path = MenuBar; + sourceTree = ""; + }; + 0308B80FA0CF6FBE7D8C7EB3 = { + isa = PBXGroup; + children = ( + E29172EAB6F6B4526BDA23C2, + ); + path = OAuth; + sourceTree = ""; + }; + 3053DFBFF1BD7F53471B36BB = { + isa = PBXGroup; + children = ( + 9F9AD7665552E1BC2E76337C, + ); + path = Settings; + sourceTree = ""; + }; + F4ED3EC6534567974F720F99 = { + isa = PBXGroup; + children = ( + 82B5ED67A4830EDA75C6CDE1, + 24C578F83DCF9CEAFCAF2E73, + 0308B80FA0CF6FBE7D8C7EB3, + 3053DFBFF1BD7F53471B36BB, + ); + path = Views; + sourceTree = ""; + }; + EE6F40F7464432A6C2A761DA = { + isa = PBXGroup; + children = ( + B2ADE595D33ADF5BA01B253B, + 7705247A59F50F6B6E6D286F, + B805360275E47AAD7192E4AE, + EF310F3F9A8796C845F345F5, + B38F908829C30D6B6F967EEA, + 56714C7161F5C8195EA19816, + F4ED3EC6534567974F720F99, + ); + path = Sources; + sourceTree = ""; + }; + A250A7E9920D61D34E4FC076 = { + isa = PBXGroup; + children = ( + + ); + path = docs; + sourceTree = ""; + }; + B3C3812FB431C8E80348A58A = { + isa = PBXGroup; + children = ( + + ); + path = scripts; + sourceTree = ""; + }; + 8C4FDD7B635240BD562EBB49 = { + isa = PBXGroup; + children = ( + 287BE755981C092238309A9A, + 8AD6A16612A2DB7B2F899787, + B23D89B2D2DF3745A003AEB1, + ); + sourceTree = ""; + }; + B805360275E47AAD7192E4AE = { + isa = PBXGroup; + children = ( + AE8899127BDCD00F1D39E8FD, + C4DE27079694EBA15A0DFAAB, + FE785F676266AFB5EA636597, + ADA8A640EF741D9404316C39, + 649CAAB9009813D6C1FD07E3, + ); + path = Models; + sourceTree = ""; + }; + 0B0ED8A1BE17CC4C0B3A787B = { + isa = PBXGroup; + children = ( + 264DA84D71E29D64DEC1B038, + 38A9F363FE066CAE6226A050, + 973BF857A0044A0262A1F56F, + 66321820D32BCC5D05265115, + ); + path = Feishu; + sourceTree = ""; + }; + EF310F3F9A8796C845F345F5 = { + isa = PBXGroup; + children = ( + E3148982E653318C7E3CD13F, + 0B0ED8A1BE17CC4C0B3A787B, + C98E94F5A859B0EEC7326C60, + BD2692AA96D65949109FEC0E, + 9677914DC46FD106A90CA724, + ); + path = Services; + sourceTree = ""; + }; + B38F908829C30D6B6F967EEA = { + isa = PBXGroup; + children = ( + 968D99A1DA7131447D075F27, + 0BE6905A92E43B24E24CDE29, + 527F9B3FF513B72F175A8429, + E390AA6A998B6C83BD779018, + ); + path = Utils; + sourceTree = ""; + }; + 56714C7161F5C8195EA19816 = { + isa = PBXGroup; + children = ( + 6C251D0198118D6B5D4B7C69, + ); + path = ViewModels; + sourceTree = ""; + }; + 82B5ED67A4830EDA75C6CDE1 = { + isa = PBXGroup; + children = ( + 5DC24ADCEDA5C16DFF9FA6EA, + ); + path = FloatingWidget; + sourceTree = ""; + }; + 24C578F83DCF9CEAFCAF2E73 = { + isa = PBXGroup; + children = ( + 17D10F852D16E2F4FC269FD2, + C3F3DB4764E379E663475EBD, + ); + path = MenuBar; + sourceTree = ""; + }; + 0308B80FA0CF6FBE7D8C7EB3 = { + isa = PBXGroup; + children = ( + E29172EAB6F6B4526BDA23C2, + ); + path = OAuth; + sourceTree = ""; + }; + 3053DFBFF1BD7F53471B36BB = { + isa = PBXGroup; + children = ( + 9F9AD7665552E1BC2E76337C, + ); + path = Settings; + sourceTree = ""; + }; + F4ED3EC6534567974F720F99 = { + isa = PBXGroup; + children = ( + 82B5ED67A4830EDA75C6CDE1, + 24C578F83DCF9CEAFCAF2E73, + 0308B80FA0CF6FBE7D8C7EB3, + 3053DFBFF1BD7F53471B36BB, + ); + path = Views; + sourceTree = ""; + }; +/* 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 */; +} \ No newline at end of file diff --git a/Bugger.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Bugger.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..94b2795 --- /dev/null +++ b/Bugger.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,4 @@ + + + diff --git a/Bugger.xcodeproj/xcshareddata/xcschemes/Bugger.xcscheme b/Bugger.xcodeproj/xcshareddata/xcschemes/Bugger.xcscheme new file mode 100644 index 0000000..3846110 --- /dev/null +++ b/Bugger.xcodeproj/xcshareddata/xcschemes/Bugger.xcscheme @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + diff --git a/Config.xcconfig.example b/Config.xcconfig.example new file mode 100644 index 0000000..a181b9a --- /dev/null +++ b/Config.xcconfig.example @@ -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 diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..3f00db4 --- /dev/null +++ b/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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 + } +} diff --git a/Resources/Assets.xcassets/Contents.json b/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Bugger.entitlements b/Resources/Bugger.entitlements new file mode 100644 index 0000000..91c0ed8 --- /dev/null +++ b/Resources/Bugger.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + keychain-access-groups + + $(AppIdentifierPrefix)com.xorbitlab.bugger + + + diff --git a/Resources/Info.plist b/Resources/Info.plist new file mode 100644 index 0000000..6389d5c --- /dev/null +++ b/Resources/Info.plist @@ -0,0 +1,43 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + LSUIElement + + CFBundleURLTypes + + + CFBundleURLName + com.xorbitlab.bugger + CFBundleURLSchemes + + bugger + + + + FEISHU_APP_ID + $(FEISHU_APP_ID) + FEISHU_APP_SECRET + $(FEISHU_APP_SECRET) + FEISHU_BASE_DOMAIN + $(FEISHU_BASE_DOMAIN) + + diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift new file mode 100644 index 0000000..14c646e --- /dev/null +++ b/Sources/AppDelegate.swift @@ -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) + } +} diff --git a/Sources/BuggerApp.swift b/Sources/BuggerApp.swift new file mode 100644 index 0000000..2a1baa3 --- /dev/null +++ b/Sources/BuggerApp.swift @@ -0,0 +1,13 @@ +import SwiftUI + +@main +struct BuggerApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + + var body: some Scene { + Settings { + SettingsView() + } + .windowResizability(.contentSize) + } +} diff --git a/Sources/Models/AppConfig.swift b/Sources/Models/AppConfig.swift new file mode 100644 index 0000000..70e0479 --- /dev/null +++ b/Sources/Models/AppConfig.swift @@ -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 + } +} diff --git a/Sources/Models/Bug.swift b/Sources/Models/Bug.swift new file mode 100644 index 0000000..ca187ca --- /dev/null +++ b/Sources/Models/Bug.swift @@ -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) + } +} diff --git a/Sources/Models/BugChange.swift b/Sources/Models/BugChange.swift new file mode 100644 index 0000000..8340110 --- /dev/null +++ b/Sources/Models/BugChange.swift @@ -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 + } +} diff --git a/Sources/Models/BugPriority.swift b/Sources/Models/BugPriority.swift new file mode 100644 index 0000000..53a0515 --- /dev/null +++ b/Sources/Models/BugPriority.swift @@ -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 + } + } +} diff --git a/Sources/Models/BugStatus.swift b/Sources/Models/BugStatus.swift new file mode 100644 index 0000000..3587177 --- /dev/null +++ b/Sources/Models/BugStatus.swift @@ -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 + } +} diff --git a/Sources/Services/AppStateService.swift b/Sources/Services/AppStateService.swift new file mode 100644 index 0000000..d2bf583 --- /dev/null +++ b/Sources/Services/AppStateService.swift @@ -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 = [] { + 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.self, from: data) { + self.persistedSeenBugs = ids + } + } + + func saveConfig(_ config: AppConfig) { + self.config = config + } + + func updateSeenBugs(_ ids: Set) { + 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) + } +} diff --git a/Sources/Services/Feishu/FeishuAuthService.swift b/Sources/Services/Feishu/FeishuAuthService.swift new file mode 100644 index 0000000..1b21793 --- /dev/null +++ b/Sources/Services/Feishu/FeishuAuthService.swift @@ -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 = 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 = 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(_ request: URLRequest) async throws -> T { + let response: FeishuAPIResponse = 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(_ 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) + } + } +} diff --git a/Sources/Services/Feishu/FeishuError.swift b/Sources/Services/Feishu/FeishuError.swift new file mode 100644 index 0000000..6ead02c --- /dev/null +++ b/Sources/Services/Feishu/FeishuError.swift @@ -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." + } + } +} diff --git a/Sources/Services/Feishu/FeishuModels.swift b/Sources/Services/Feishu/FeishuModels.swift new file mode 100644 index 0000000..6d98b5a --- /dev/null +++ b/Sources/Services/Feishu/FeishuModels.swift @@ -0,0 +1,163 @@ +import Foundation + +struct FeishuAPIResponse: 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 + } +} diff --git a/Sources/Services/Feishu/FeishuService.swift b/Sources/Services/Feishu/FeishuService.swift new file mode 100644 index 0000000..bbd3471 --- /dev/null +++ b/Sources/Services/Feishu/FeishuService.swift @@ -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 + do { + apiResponse = try decoder.decode(FeishuAPIResponse.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 + } +} diff --git a/Sources/Services/NotificationService.swift b/Sources/Services/NotificationService.swift new file mode 100644 index 0000000..dd3088f --- /dev/null +++ b/Sources/Services/NotificationService.swift @@ -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() + } +} diff --git a/Sources/Services/PollerService.swift b/Sources/Services/PollerService.swift new file mode 100644 index 0000000..39d1fc4 --- /dev/null +++ b/Sources/Services/PollerService.swift @@ -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 + } +} diff --git a/Sources/Services/TokenManager.swift b/Sources/Services/TokenManager.swift new file mode 100644 index 0000000..fa684fc --- /dev/null +++ b/Sources/Services/TokenManager.swift @@ -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." + } + } +} diff --git a/Sources/Utils/Color+Extensions.swift b/Sources/Utils/Color+Extensions.swift new file mode 100644 index 0000000..1654e6d --- /dev/null +++ b/Sources/Utils/Color+Extensions.swift @@ -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) + } + } +} diff --git a/Sources/Utils/DateFormatter+Extensions.swift b/Sources/Utils/DateFormatter+Extensions.swift new file mode 100644 index 0000000..0cf6ccb --- /dev/null +++ b/Sources/Utils/DateFormatter+Extensions.swift @@ -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 + } + } +} diff --git a/Sources/Utils/KeychainHelper.swift b/Sources/Utils/KeychainHelper.swift new file mode 100644 index 0000000..0a7ca79 --- /dev/null +++ b/Sources/Utils/KeychainHelper.swift @@ -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) + } +} diff --git a/Sources/Utils/URL+Feishu.swift b/Sources/Utils/URL+Feishu.swift new file mode 100644 index 0000000..aa2b223 --- /dev/null +++ b/Sources/Utils/URL+Feishu.swift @@ -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)")! + } +} diff --git a/Sources/ViewModels/BugStore.swift b/Sources/ViewModels/BugStore.swift new file mode 100644 index 0000000..043d9be --- /dev/null +++ b/Sources/ViewModels/BugStore.swift @@ -0,0 +1,108 @@ +import Foundation + +@Observable +final class BugStore { + static let shared = BugStore() + + private(set) var bugs: [Bug] = [] + private(set) var unseenBugs: Set = [] + 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 + } +} diff --git a/Sources/Views/FloatingWidget/FloatingWidgetWindow.swift b/Sources/Views/FloatingWidget/FloatingWidgetWindow.swift new file mode 100644 index 0000000..36f1bd3 --- /dev/null +++ b/Sources/Views/FloatingWidget/FloatingWidgetWindow.swift @@ -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) + } +} diff --git a/Sources/Views/MenuBar/BugListPopover.swift b/Sources/Views/MenuBar/BugListPopover.swift new file mode 100644 index 0000000..6b73a2c --- /dev/null +++ b/Sources/Views/MenuBar/BugListPopover.swift @@ -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() + } +} diff --git a/Sources/Views/MenuBar/BugRow.swift b/Sources/Views/MenuBar/BugRow.swift new file mode 100644 index 0000000..17306b7 --- /dev/null +++ b/Sources/Views/MenuBar/BugRow.swift @@ -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)) + } +} diff --git a/Sources/Views/OAuth/OAuthSetupView.swift b/Sources/Views/OAuth/OAuthSetupView.swift new file mode 100644 index 0000000..eb41c21 --- /dev/null +++ b/Sources/Views/OAuth/OAuthSetupView.swift @@ -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) + } + } + } + } +} diff --git a/Sources/Views/Settings/SettingsView.swift b/Sources/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..c2b37d8 --- /dev/null +++ b/Sources/Views/Settings/SettingsView.swift @@ -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 + } + } +} diff --git a/scripts/generate_xcode_project.py b/scripts/generate_xcode_project.py new file mode 100644 index 0000000..131bf56 --- /dev/null +++ b/scripts/generate_xcode_project.py @@ -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 = \"\"; }};") + 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 = \"\"; }};") + continue + lines.append(f"\t\t{ref_id} /* {path.name} */ = {{isa = PBXFileReference; lastKnownFileType = {file_type}; path = {path.name}; sourceTree = \"\"; }};") + 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 = \"\"; }};") + lines.append(f"\t\t{asset_ref} /* Assets.xcassets */ = {{isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = \"\"; }};") + lines.append(f"\t\t{info_ref} /* Info.plist */ = {{isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = \"\"; }};") + lines.append(f"\t\t{ent_ref} /* Bugger.entitlements */ = {{isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Bugger.entitlements; sourceTree = \"\"; }};") + 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 = \"\";\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 = \"\";\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 = \"\";\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 = \"\";\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 = \"\";\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""" + + + + + + + + + + + + + + + + +""") + print(f"Generated {project_dir}") + + +if __name__ == "__main__": + main()