From e29b046baa27c51d989e6c14e65959e949f483ae Mon Sep 17 00:00:00 2001 From: Tomio Ueda Date: Sat, 14 Mar 2026 18:19:55 -0700 Subject: [PATCH] Add in-app update checker via GitHub Releases API Checks for new PluginUpdater releases on launch and during scans. Shows update banner in menu bar popover and Settings > General with "Check Now" button and "Up to date" feedback. Uses injectable URLSessionProtocol for testability with 19 tests covering positive, negative, and error cases. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../PluginUpdater.xcodeproj/project.pbxproj | 48 +++ .../PluginUpdater/App/AppState.swift | 9 + .../PluginUpdater/App/PluginUpdaterApp.swift | 6 + .../Services/Updates/AppUpdateChecker.swift | 107 ++++++ .../PluginUpdater/Utilities/Constants.swift | 7 + .../Views/MenuBar/MenuBarPopoverView.swift | 18 + .../Views/Settings/SettingsView.swift | 42 +++ .../Services/AppUpdateCheckerTests.swift | 311 ++++++++++++++++++ 8 files changed, 548 insertions(+) create mode 100644 PluginUpdater/PluginUpdater/Services/Updates/AppUpdateChecker.swift create mode 100644 PluginUpdater/PluginUpdaterTests/Services/AppUpdateCheckerTests.swift diff --git a/PluginUpdater/PluginUpdater.xcodeproj/project.pbxproj b/PluginUpdater/PluginUpdater.xcodeproj/project.pbxproj index c33d7d4..c014fd0 100644 --- a/PluginUpdater/PluginUpdater.xcodeproj/project.pbxproj +++ b/PluginUpdater/PluginUpdater.xcodeproj/project.pbxproj @@ -9,19 +9,27 @@ /* Begin PBXBuildFile section */ 001056A58D6A044FE878C816 /* CPUArchitecture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E9F0D88FC395A0E299CABE /* CPUArchitecture.swift */; }; 0A69C6CDB779E830D34E4E81 /* PluginHideTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54993856C5106C87AEBC386E /* PluginHideTests.swift */; }; + 0C2B1176C0E94B366EEA9719 /* AppUpdateChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FC140E85F6B430D9D28E88F /* AppUpdateChecker.swift */; }; 0D301D58EFA2BE8EE6C3DCF5 /* UpdateManifestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A837F2C3F147FE3B2378743E /* UpdateManifestTests.swift */; }; 0D59BE745E8A11095705C990 /* VendorResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DBDCA9E8EEA702794CA532A /* VendorResolverTests.swift */; }; 1426A98A13DBF5C5D184993D /* VendorResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3A07860952E18E0DEC046B /* VendorResolver.swift */; }; + 158504F9C4BF11B961B54251 /* PluginMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26C4FEC3DC6C7D6191981965 /* PluginMatcher.swift */; }; 1AD3F653DF2ED4EE9DE9FA48 /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 696F41E6C13E22B96BB66267 /* DashboardView.swift */; }; 2399380FC71A4C0E4A1393F6 /* BundleMetadataExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 197B81B66CD75BA67B4741F0 /* BundleMetadataExtractor.swift */; }; 2638FD8F14CECB04E4FA2257 /* VendorInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 613B2B7BA5C37C8E758A0BF9 /* VendorInfo.swift */; }; + 26EC09E92BD8EB4594B0581B /* AbletonProjectParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BDE5668FF5EA5B0845DD8F3 /* AbletonProjectParser.swift */; }; + 2B88331B0AB8407AE5B3B5F9 /* AppUpdateCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F000674EA4300F88278E2F37 /* AppUpdateCheckerTests.swift */; }; 2EF730C86C416F28B22BEC98 /* PluginVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC4908F51CDC729F0645FC6 /* PluginVersion.swift */; }; 300120473D5D58C1510785F3 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8507964F3E9247BFC5BB580 /* PersistenceController.swift */; }; 309026F4A5FE45F2A6B979CC /* VendorURLResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 058703EFBA8361D1A9CFE0E8 /* VendorURLResolverTests.swift */; }; 3B77607C97AEA365C179293C /* PluginFormatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02063DE3C8D3333A309332EC /* PluginFormatTests.swift */; }; 424E33F6164C2AC0F13DC754 /* PluginScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0075A029C426D72E02843DB /* PluginScanner.swift */; }; + 431B5E454463F9CA9C3AF02E /* AbletonProjectScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C88F3F96F4096F64078A348 /* AbletonProjectScanner.swift */; }; + 4B7C03DCD25941E0E9D4CE59 /* ProjectListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB7F42BE5E5DA6496192F356 /* ProjectListView.swift */; }; + 4C33669D95444617E54A5D63 /* AbletonProject.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB541C2C1BBAE25F36584A8F /* AbletonProject.swift */; }; 4D3ED583D835CC072BA442F7 /* ContextMenuActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE222F13CA653E287E98D190 /* ContextMenuActionTests.swift */; }; 5AC8E361BC1A63D3672BA750 /* vendor_urls.json in Resources */ = {isa = PBXBuildFile; fileRef = BE724FF345F7369F4059BB6E /* vendor_urls.json */; }; + 5E74265A9645B66EB4C21E84 /* ProjectDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77BFAC4857A07DF1A9660DB8 /* ProjectDetailView.swift */; }; 5EC118195278CECA9A597B86 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC93F8EF57BA9A0FD9879280 /* AppState.swift */; }; 672E54C5CE4EBBA486DA3D1A /* PluginImageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C33FFFC7417CE4C6FC7FF12 /* PluginImageService.swift */; }; 676BD62C9C7651D4D040103F /* AvailableVersionCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C11DF638C0C66FA77CD533E /* AvailableVersionCell.swift */; }; @@ -60,6 +68,7 @@ E1065AF7BEAEE6C7B28F1A6E /* ArchitectureDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66AD28DDDD2ED9703BCD41B /* ArchitectureDetector.swift */; }; E36F62F52B778306706E170D /* PluginUpdaterApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE5E64D484AEBF8F443CC747 /* PluginUpdaterApp.swift */; }; ECA8707A7773A23B9E80059F /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E9D30986AADC09D953814BE /* Constants.swift */; }; + EDC66A04A27FBAE6D80470EC /* ProjectReconciler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859839BCCB9C43D08AF95F7D /* ProjectReconciler.swift */; }; EDF5385E9626F393378D3790 /* ScanPathsEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DC3971E9045D641781CF69E /* ScanPathsEditor.swift */; }; F1190AAC2028E4067E12D151 /* ScanLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A588CB8DFDCA92B11B7ECDC7 /* ScanLocation.swift */; }; F3C6BA58672FB61A3F52CA11 /* BundleMetadataExtractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBA6B4E9B3F58F0C720500CB /* BundleMetadataExtractorTests.swift */; }; @@ -67,6 +76,7 @@ F65525A9AA9BCA5A0249F223 /* FileSystemMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6374A2122F3079A1BD7E318D /* FileSystemMonitor.swift */; }; F720A9F3DF5B4C2CB1F06D77 /* VersionChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5DF70C3C105617C5ED85FF7 /* VersionChecker.swift */; }; FA9F5B3B76F71F73DA81D765 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E455535FE74D6F17BDEBF9E /* SettingsView.swift */; }; + FC5247E53239629726DAC9D6 /* AbletonProjectPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBF560CA07A0EBF86AD46F17 /* AbletonProjectPlugin.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -88,9 +98,12 @@ 17D4C0D1A9C8114B07C93E84 /* PluginScannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginScannerTests.swift; sourceTree = ""; }; 17DFB4D8B2C016D078A49A68 /* PluginFormatBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginFormatBadge.swift; sourceTree = ""; }; 197B81B66CD75BA67B4741F0 /* BundleMetadataExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleMetadataExtractor.swift; sourceTree = ""; }; + 1C88F3F96F4096F64078A348 /* AbletonProjectScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbletonProjectScanner.swift; sourceTree = ""; }; + 26C4FEC3DC6C7D6191981965 /* PluginMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginMatcher.swift; sourceTree = ""; }; 26FAE2FD3A9E736C649A8D80 /* PluginUpdater.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PluginUpdater.app; sourceTree = BUILT_PRODUCTS_DIR; }; 27AD453D9B6930C4A3717BED /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; 2A275AFC90E301499BCBB80A /* default_manifest.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = default_manifest.json; sourceTree = ""; }; + 2FC140E85F6B430D9D28E88F /* AppUpdateChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateChecker.swift; sourceTree = ""; }; 3B2A40B95918D92D1FAEB0BC /* UpdateStatusIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateStatusIndicator.swift; sourceTree = ""; }; 3C4F86DC778100292C275751 /* UpdateStatusIndicatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateStatusIndicatorTests.swift; sourceTree = ""; }; 3E9D30986AADC09D953814BE /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; @@ -102,6 +115,7 @@ 54993856C5106C87AEBC386E /* PluginHideTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginHideTests.swift; sourceTree = ""; }; 5642C14BB4CA023226BC86AA /* AssetNamesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetNamesTests.swift; sourceTree = ""; }; 567E1F6ED699C9EAFA295ED1 /* AppLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogger.swift; sourceTree = ""; }; + 5BDE5668FF5EA5B0845DD8F3 /* AbletonProjectParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbletonProjectParser.swift; sourceTree = ""; }; 5C11DF638C0C66FA77CD533E /* AvailableVersionCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvailableVersionCell.swift; sourceTree = ""; }; 5C33FFFC7417CE4C6FC7FF12 /* PluginImageService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginImageService.swift; sourceTree = ""; }; 5CE6380DB266015098CA2F47 /* URLExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLExtensionTests.swift; sourceTree = ""; }; @@ -111,10 +125,12 @@ 68BBAABB4C7CF313F0C5E8E0 /* VendorLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VendorLink.swift; sourceTree = ""; }; 696F41E6C13E22B96BB66267 /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; 6BC8E0BFE748332634B82821 /* Plugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Plugin.swift; sourceTree = ""; }; + 77BFAC4857A07DF1A9660DB8 /* ProjectDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectDetailView.swift; sourceTree = ""; }; 77DEF28C969390E7D1827F3D /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = ""; }; 7A598E6367944C87FE41AEE0 /* PluginReconcilerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginReconcilerTests.swift; sourceTree = ""; }; 7E455535FE74D6F17BDEBF9E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 7FE3723F80F115C503F6117F /* MenuBarPopoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarPopoverView.swift; sourceTree = ""; }; + 859839BCCB9C43D08AF95F7D /* ProjectReconciler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectReconciler.swift; sourceTree = ""; }; 8A76A6D01E0000F16BC1BA92 /* PluginUpdater.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PluginUpdater.entitlements; sourceTree = ""; }; 8BC4908F51CDC729F0645FC6 /* PluginVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginVersion.swift; sourceTree = ""; }; 8FD735DCF3B89D813DB2D8CF /* UpdateManifestEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateManifestEntry.swift; sourceTree = ""; }; @@ -127,7 +143,10 @@ A837F2C3F147FE3B2378743E /* UpdateManifestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateManifestTests.swift; sourceTree = ""; }; A8507964F3E9247BFC5BB580 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; AB464ABB49A24F29E6CF5654 /* ManifestManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManifestManager.swift; sourceTree = ""; }; + AB541C2C1BBAE25F36584A8F /* AbletonProject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbletonProject.swift; sourceTree = ""; }; B66AD28DDDD2ED9703BCD41B /* ArchitectureDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchitectureDetector.swift; sourceTree = ""; }; + BB7F42BE5E5DA6496192F356 /* ProjectListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectListView.swift; sourceTree = ""; }; + BBF560CA07A0EBF86AD46F17 /* AbletonProjectPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbletonProjectPlugin.swift; sourceTree = ""; }; BE724FF345F7369F4059BB6E /* vendor_urls.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = vendor_urls.json; sourceTree = ""; }; C0075A029C426D72E02843DB /* PluginScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginScanner.swift; sourceTree = ""; }; C419BEFFE80A25F8F0CCC4E2 /* VersionCheckerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionCheckerTests.swift; sourceTree = ""; }; @@ -141,6 +160,7 @@ EC93F8EF57BA9A0FD9879280 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; EE5E64D484AEBF8F443CC747 /* PluginUpdaterApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginUpdaterApp.swift; sourceTree = ""; }; EF0DAA61F5C1D3CA587AF319 /* VersionHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionHistoryView.swift; sourceTree = ""; }; + F000674EA4300F88278E2F37 /* AppUpdateCheckerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateCheckerTests.swift; sourceTree = ""; }; F5DF70C3C105617C5ED85FF7 /* VersionChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionChecker.swift; sourceTree = ""; }; FE222F13CA653E287E98D190 /* ContextMenuActionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuActionTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -219,6 +239,7 @@ 4B79FF31AD704FCEE3410494 /* Services */ = { isa = PBXGroup; children = ( + F000674EA4300F88278E2F37 /* AppUpdateCheckerTests.swift */, CB60DE5B1E64F45E37BD8A1C /* ArchitectureDetectorTests.swift */, CBA6B4E9B3F58F0C720500CB /* BundleMetadataExtractorTests.swift */, 0F4C374ECB160B55AF5974F3 /* PersistenceControllerTests.swift */, @@ -238,6 +259,7 @@ children = ( A8507964F3E9247BFC5BB580 /* PersistenceController.swift */, 48945FF5154578D9FA574F84 /* PluginReconciler.swift */, + 859839BCCB9C43D08AF95F7D /* ProjectReconciler.swift */, ); path = Persistence; sourceTree = ""; @@ -251,6 +273,15 @@ path = Extensions; sourceTree = ""; }; + 5B721B1076B50F3E409B029C /* Projects */ = { + isa = PBXGroup; + children = ( + 77BFAC4857A07DF1A9660DB8 /* ProjectDetailView.swift */, + BB7F42BE5E5DA6496192F356 /* ProjectListView.swift */, + ); + path = Projects; + sourceTree = ""; + }; 66EF6A892836BCFE1C1A5AC1 /* Dashboard */ = { isa = PBXGroup; children = ( @@ -266,6 +297,7 @@ 66EF6A892836BCFE1C1A5AC1 /* Dashboard */, 01634FF47B66356B3293C6C9 /* Detail */, 81BF4D2789431D9FE51BA91C /* MenuBar */, + 5B721B1076B50F3E409B029C /* Projects */, 48217EE8A0F52F6229BA7415 /* Settings */, ); path = Views; @@ -346,6 +378,8 @@ C5F74C4848B26F5B4EE7FB8D /* Models */ = { isa = PBXGroup; children = ( + AB541C2C1BBAE25F36584A8F /* AbletonProject.swift */, + BBF560CA07A0EBF86AD46F17 /* AbletonProjectPlugin.swift */, 40E9F0D88FC395A0E299CABE /* CPUArchitecture.swift */, 6BC8E0BFE748332634B82821 /* Plugin.swift */, 50B2F733E2F4A6FA4BFC9282 /* PluginFormat.swift */, @@ -369,8 +403,11 @@ DC38C4483E3C50FF1022E16F /* Scanner */ = { isa = PBXGroup; children = ( + 5BDE5668FF5EA5B0845DD8F3 /* AbletonProjectParser.swift */, + 1C88F3F96F4096F64078A348 /* AbletonProjectScanner.swift */, B66AD28DDDD2ED9703BCD41B /* ArchitectureDetector.swift */, 197B81B66CD75BA67B4741F0 /* BundleMetadataExtractor.swift */, + 26C4FEC3DC6C7D6191981965 /* PluginMatcher.swift */, C0075A029C426D72E02843DB /* PluginScanner.swift */, DA3A07860952E18E0DEC046B /* VendorResolver.swift */, ); @@ -397,6 +434,7 @@ FBF64783BEC6E3E1450771B9 /* Updates */ = { isa = PBXGroup; children = ( + 2FC140E85F6B430D9D28E88F /* AppUpdateChecker.swift */, AB464ABB49A24F29E6CF5654 /* ManifestManager.swift */, CE00D48FF579CB2C859A32CF /* VendorURLResolver.swift */, F5DF70C3C105617C5ED85FF7 /* VersionChecker.swift */, @@ -530,8 +568,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4C33669D95444617E54A5D63 /* AbletonProject.swift in Sources */, + 26EC09E92BD8EB4594B0581B /* AbletonProjectParser.swift in Sources */, + FC5247E53239629726DAC9D6 /* AbletonProjectPlugin.swift in Sources */, + 431B5E454463F9CA9C3AF02E /* AbletonProjectScanner.swift in Sources */, BD97DA43C9B1536C8035B73E /* AppLogger.swift in Sources */, 5EC118195278CECA9A597B86 /* AppState.swift in Sources */, + 0C2B1176C0E94B366EEA9719 /* AppUpdateChecker.swift in Sources */, 899B613AB284F8F9D528EC0B /* AppVersion.swift in Sources */, E1065AF7BEAEE6C7B28F1A6E /* ArchitectureDetector.swift in Sources */, 676BD62C9C7651D4D040103F /* AvailableVersionCell.swift in Sources */, @@ -551,10 +594,14 @@ 9E34A902CB91B58410878005 /* PluginFormat.swift in Sources */, C3C4D4DD4EDB0D265BF124A2 /* PluginFormatBadge.swift in Sources */, 672E54C5CE4EBBA486DA3D1A /* PluginImageService.swift in Sources */, + 158504F9C4BF11B961B54251 /* PluginMatcher.swift in Sources */, DE50686CF90D72FE5067B057 /* PluginReconciler.swift in Sources */, 424E33F6164C2AC0F13DC754 /* PluginScanner.swift in Sources */, E36F62F52B778306706E170D /* PluginUpdaterApp.swift in Sources */, 2EF730C86C416F28B22BEC98 /* PluginVersion.swift in Sources */, + 5E74265A9645B66EB4C21E84 /* ProjectDetailView.swift in Sources */, + 4B7C03DCD25941E0E9D4CE59 /* ProjectListView.swift in Sources */, + EDC66A04A27FBAE6D80470EC /* ProjectReconciler.swift in Sources */, F1190AAC2028E4067E12D151 /* ScanLocation.swift in Sources */, EDF5385E9626F393378D3790 /* ScanPathsEditor.swift in Sources */, FA9F5B3B76F71F73DA81D765 /* SettingsView.swift in Sources */, @@ -575,6 +622,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 2B88331B0AB8407AE5B3B5F9 /* AppUpdateCheckerTests.swift in Sources */, A83F2BF484741B576EBFD528 /* ArchitectureDetectorTests.swift in Sources */, A4F965356550E03BE2D1254D /* AssetNamesTests.swift in Sources */, F3C6BA58672FB61A3F52CA11 /* BundleMetadataExtractorTests.swift in Sources */, diff --git a/PluginUpdater/PluginUpdater/App/AppState.swift b/PluginUpdater/PluginUpdater/App/AppState.swift index 7cc5794..0f7f7dd 100644 --- a/PluginUpdater/PluginUpdater/App/AppState.swift +++ b/PluginUpdater/PluginUpdater/App/AppState.swift @@ -12,6 +12,7 @@ final class AppState { var errorMessage: String? var manifestEntries: [String: UpdateManifestEntry] = [:] var updatesAvailableCount = 0 + var availableAppUpdate: AppUpdateChecker.AppUpdate? private(set) var modelContainer: ModelContainer private var fileMonitor: FileSystemMonitor? @@ -20,6 +21,7 @@ final class AppState { private let manifestManager = ManifestManager() private let versionChecker = VersionChecker() private let vendorURLResolver = VendorURLResolver() + private let appUpdateChecker = AppUpdateChecker() private var prefetchTask: Task? /// Plist fields from most recent scan, keyed by bundleID. @@ -85,6 +87,13 @@ final class AppState { AppLogger.shared.info("Update check complete — \(updatesAvailableCount) updates available", category: "updates") } + /// Checks the GitHub Releases API for a newer version of PluginUpdater. + func checkForAppUpdate() async { + availableAppUpdate = await appUpdateChecker.checkForUpdate( + currentVersion: AppVersion.version + ) + } + /// Uses VendorURLResolver to find URLs for plugins without download links. /// Tries: hardcoded overrides → plist URLs → reverse-domain → search fallback. /// Deduplicates by vendor prefix and resolves in parallel batches. diff --git a/PluginUpdater/PluginUpdater/App/PluginUpdaterApp.swift b/PluginUpdater/PluginUpdater/App/PluginUpdaterApp.swift index 80062f5..dba6753 100644 --- a/PluginUpdater/PluginUpdater/App/PluginUpdaterApp.swift +++ b/PluginUpdater/PluginUpdater/App/PluginUpdaterApp.swift @@ -89,6 +89,7 @@ struct PluginUpdaterApp: App { Constants.UserDefaultsKeys.notifyNewPlugins: true, Constants.UserDefaultsKeys.notifyUpdatedPlugins: true, Constants.UserDefaultsKeys.notifyRemovedPlugins: true, + Constants.UserDefaultsKeys.checkForAppUpdates: true, ]) AppLogger.shared.info( @@ -114,6 +115,11 @@ struct PluginUpdaterApp: App { await appState.loadManifest() await appState.performScan() + // Check for app updates + if UserDefaults.standard.bool(forKey: Constants.UserDefaultsKeys.checkForAppUpdates) { + await appState.checkForAppUpdate() + } + // Start auto-scan timer appState.startAutoScanTimer() } diff --git a/PluginUpdater/PluginUpdater/Services/Updates/AppUpdateChecker.swift b/PluginUpdater/PluginUpdater/Services/Updates/AppUpdateChecker.swift new file mode 100644 index 0000000..26dfa49 --- /dev/null +++ b/PluginUpdater/PluginUpdater/Services/Updates/AppUpdateChecker.swift @@ -0,0 +1,107 @@ +import Foundation + +/// Checks for new releases of PluginUpdater itself via the GitHub Releases API. +actor AppUpdateChecker { + + struct GitHubRelease: Codable { + let tagName: String + let htmlUrl: String + let body: String? + let publishedAt: String? + let assets: [Asset] + + struct Asset: Codable { + let name: String + let browserDownloadUrl: String + + enum CodingKeys: String, CodingKey { + case name + case browserDownloadUrl = "browser_download_url" + } + } + + enum CodingKeys: String, CodingKey { + case tagName = "tag_name" + case htmlUrl = "html_url" + case body + case publishedAt = "published_at" + case assets + } + } + + struct AppUpdate { + let version: String + let releaseNotes: String? + let releasePageURL: URL + let downloadURL: URL? + let publishedAt: String? + } + + /// Abstraction over URLSession for testability. + protocol URLSessionProtocol: Sendable { + func data(for request: URLRequest) async throws -> (Data, URLResponse) + } + + private let session: URLSessionProtocol + private let apiBaseURL: String + + init(session: URLSessionProtocol = URLSession.shared, apiBaseURL: String? = nil) { + self.session = session + self.apiBaseURL = apiBaseURL ?? Constants.AppUpdateConfig.githubAPIBase + } + + /// Queries GitHub for the latest release and returns an `AppUpdate` if a newer version is available. + func checkForUpdate(currentVersion: String) async -> AppUpdate? { + let urlString = "\(apiBaseURL)/repos/\(Constants.AppUpdateConfig.repoOwner)/\(Constants.AppUpdateConfig.repoName)/releases/latest" + + guard let url = URL(string: urlString) else { + AppLogger.shared.error("Invalid GitHub API URL", category: "appUpdate") + return nil + } + + var request = URLRequest(url: url) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.timeoutInterval = 15 + + do { + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + let code = (response as? HTTPURLResponse)?.statusCode ?? -1 + AppLogger.shared.error("GitHub API returned status \(code)", category: "appUpdate") + return nil + } + + let decoder = JSONDecoder() + let release = try decoder.decode(GitHubRelease.self, from: data) + + // Strip "v" prefix from tag name for version comparison + let remoteVersion = release.tagName.normalizedVersion + + guard remoteVersion.isNewerVersion(than: currentVersion) else { + AppLogger.shared.info("App is up to date (current: \(currentVersion), latest: \(remoteVersion))", category: "appUpdate") + return nil + } + + let releasePageURL = URL(string: release.htmlUrl)! + let pkgAsset = release.assets.first { $0.name.hasSuffix(".pkg") } + let downloadURL = pkgAsset.flatMap { URL(string: $0.browserDownloadUrl) } + + AppLogger.shared.info("App update available: \(remoteVersion) (current: \(currentVersion))", category: "appUpdate") + + return AppUpdate( + version: remoteVersion, + releaseNotes: release.body, + releasePageURL: releasePageURL, + downloadURL: downloadURL, + publishedAt: release.publishedAt + ) + } catch { + AppLogger.shared.error("Failed to check for app update: \(error.localizedDescription)", category: "appUpdate") + return nil + } + } +} + +extension URLSession: AppUpdateChecker.URLSessionProtocol {} diff --git a/PluginUpdater/PluginUpdater/Utilities/Constants.swift b/PluginUpdater/PluginUpdater/Utilities/Constants.swift index d39937d..77de541 100644 --- a/PluginUpdater/PluginUpdater/Utilities/Constants.swift +++ b/PluginUpdater/PluginUpdater/Utilities/Constants.swift @@ -18,6 +18,13 @@ enum Constants { static let notifyNewPlugins = "notifyNewPlugins" static let notifyUpdatedPlugins = "notifyUpdatedPlugins" static let notifyRemovedPlugins = "notifyRemovedPlugins" + static let checkForAppUpdates = "checkForAppUpdates" + } + + enum AppUpdateConfig { + static let repoOwner = "bounceconnection" + static let repoName = "plugin_updater" + static let githubAPIBase = "https://api.github.com" } enum NotificationIdentifiers { diff --git a/PluginUpdater/PluginUpdater/Views/MenuBar/MenuBarPopoverView.swift b/PluginUpdater/PluginUpdater/Views/MenuBar/MenuBarPopoverView.swift index 2209cfc..70e15f0 100644 --- a/PluginUpdater/PluginUpdater/Views/MenuBar/MenuBarPopoverView.swift +++ b/PluginUpdater/PluginUpdater/Views/MenuBar/MenuBarPopoverView.swift @@ -10,6 +10,24 @@ struct MenuBarPopoverView: View { .font(.headline) Divider() + // App update banner + if let update = appState.availableAppUpdate { + HStack(spacing: 6) { + Image(systemName: "arrow.down.circle.fill") + .foregroundStyle(.blue) + Text("Update Available: v\(update.version)") + .font(.subheadline.bold()) + Spacer() + Button("View Release") { + NSWorkspace.shared.open(update.releasePageURL) + } + .controlSize(.small) + } + .padding(8) + .background(.blue.opacity(0.1), in: RoundedRectangle(cornerRadius: 6)) + Divider() + } + // Stats HStack { Label("\(appState.totalPluginCount) plugins", systemImage: "puzzlepiece.extension") diff --git a/PluginUpdater/PluginUpdater/Views/Settings/SettingsView.swift b/PluginUpdater/PluginUpdater/Views/Settings/SettingsView.swift index 6c06182..99e0b63 100644 --- a/PluginUpdater/PluginUpdater/Views/Settings/SettingsView.swift +++ b/PluginUpdater/PluginUpdater/Views/Settings/SettingsView.swift @@ -6,8 +6,11 @@ struct SettingsView: View { @Environment(AppState.self) private var appState @AppStorage(Constants.UserDefaultsKeys.manifestURL) private var manifestURL = "" @AppStorage(Constants.UserDefaultsKeys.scanFrequency) private var scanFrequencyMinutes = Constants.Defaults.scanFrequencyMinutes + @AppStorage(Constants.UserDefaultsKeys.checkForAppUpdates) private var checkForAppUpdates = true @State private var launchAtLogin = false @State private var didClearImageCache = false + @State private var isCheckingForAppUpdate = false + @State private var didCheckForAppUpdate = false private let frequencyOptions: [(label: String, minutes: Int)] = [ ("Every 15 minutes", 15), @@ -80,6 +83,45 @@ struct SettingsView: View { .foregroundStyle(.secondary) } } + + Section("App Updates") { + Toggle("Automatically check for app updates", isOn: $checkForAppUpdates) + + HStack { + Text("Current version:") + .foregroundStyle(.secondary) + Text(AppVersion.displayVersion) + .font(.body.monospaced()) + } + + HStack { + Button(isCheckingForAppUpdate ? "Checking…" : "Check Now") { + isCheckingForAppUpdate = true + didCheckForAppUpdate = false + Task { + await appState.checkForAppUpdate() + isCheckingForAppUpdate = false + didCheckForAppUpdate = true + } + } + .disabled(isCheckingForAppUpdate) + + if let update = appState.availableAppUpdate { + Spacer() + Text("v\(update.version) available") + .foregroundStyle(.blue) + Button("View Release") { + NSWorkspace.shared.open(update.releasePageURL) + } + .controlSize(.small) + } else if didCheckForAppUpdate { + Spacer() + Label("Up to date", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.subheadline) + } + } + } } .tabItem { Label("General", systemImage: "gearshape") } } diff --git a/PluginUpdater/PluginUpdaterTests/Services/AppUpdateCheckerTests.swift b/PluginUpdater/PluginUpdaterTests/Services/AppUpdateCheckerTests.swift new file mode 100644 index 0000000..987f90b --- /dev/null +++ b/PluginUpdater/PluginUpdaterTests/Services/AppUpdateCheckerTests.swift @@ -0,0 +1,311 @@ +import Testing +import Foundation +@testable import PluginUpdater + +// MARK: - Mock URLSession + +private final class MockURLSession: AppUpdateChecker.URLSessionProtocol, @unchecked Sendable { + var data: Data = Data() + var response: URLResponse = HTTPURLResponse( + url: URL(string: "https://api.github.com")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + var error: Error? + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + if let error { throw error } + return (data, response) + } +} + +// MARK: - Test Helpers + +private func makeReleaseJSON( + tagName: String = "v2.0.0", + htmlUrl: String = "https://github.com/bounceconnection/plugin_updater/releases/tag/v2.0.0", + body: String? = "Bug fixes and improvements", + publishedAt: String? = "2026-03-14T00:00:00Z", + assets: [[String: String]] = [] +) -> Data { + var json: [String: Any] = [ + "tag_name": tagName, + "html_url": htmlUrl, + "assets": assets.map { asset in + [ + "name": asset["name"] ?? "", + "browser_download_url": asset["browser_download_url"] ?? "", + ] + }, + ] + if let body { json["body"] = body } + if let publishedAt { json["published_at"] = publishedAt } + // swiftlint:disable:next force_try + return try! JSONSerialization.data(withJSONObject: json) +} + +private func makeHTTPResponse(statusCode: Int) -> HTTPURLResponse { + HTTPURLResponse( + url: URL(string: "https://api.github.com")!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + )! +} + +// MARK: - Tests + +@Suite("AppUpdateChecker Tests") +struct AppUpdateCheckerTests { + + // MARK: - Positive: Update available + + @Test("Returns update when remote version is newer") + func returnsUpdateWhenNewer() async { + let session = MockURLSession() + session.data = makeReleaseJSON(tagName: "v2.0.0") + let checker = AppUpdateChecker(session: session) + + let result = await checker.checkForUpdate(currentVersion: "1.0.8") + + #expect(result != nil) + #expect(result?.version == "2.0.0") + #expect(result?.releaseNotes == "Bug fixes and improvements") + #expect(result?.releasePageURL.absoluteString == "https://github.com/bounceconnection/plugin_updater/releases/tag/v2.0.0") + } + + @Test("Parses .pkg download URL from assets") + func parsesPkgAsset() async { + let session = MockURLSession() + session.data = makeReleaseJSON( + tagName: "v2.0.0", + assets: [ + [ + "name": "PluginUpdater-2.0.0.pkg", + "browser_download_url": "https://github.com/bounceconnection/plugin_updater/releases/download/v2.0.0/PluginUpdater-2.0.0.pkg", + ], + [ + "name": "checksums.txt", + "browser_download_url": "https://github.com/bounceconnection/plugin_updater/releases/download/v2.0.0/checksums.txt", + ], + ] + ) + let checker = AppUpdateChecker(session: session) + + let result = await checker.checkForUpdate(currentVersion: "1.0.0") + + #expect(result != nil) + #expect(result?.downloadURL?.absoluteString.hasSuffix(".pkg") == true) + } + + @Test("Handles tag without v prefix") + func handlesTagWithoutVPrefix() async { + let session = MockURLSession() + session.data = makeReleaseJSON(tagName: "2.0.0") + let checker = AppUpdateChecker(session: session) + + let result = await checker.checkForUpdate(currentVersion: "1.0.0") + + #expect(result != nil) + #expect(result?.version == "2.0.0") + } + + @Test("Returns update for minor version bump") + func minorVersionBump() async { + let session = MockURLSession() + session.data = makeReleaseJSON(tagName: "v1.1.0") + let checker = AppUpdateChecker(session: session) + + let result = await checker.checkForUpdate(currentVersion: "1.0.8") + + #expect(result != nil) + #expect(result?.version == "1.1.0") + } + + @Test("Returns update for patch version bump") + func patchVersionBump() async { + let session = MockURLSession() + session.data = makeReleaseJSON(tagName: "v1.0.9") + let checker = AppUpdateChecker(session: session) + + let result = await checker.checkForUpdate(currentVersion: "1.0.8") + + #expect(result != nil) + #expect(result?.version == "1.0.9") + } + + @Test("Includes publishedAt in result") + func includesPublishedAt() async { + let session = MockURLSession() + session.data = makeReleaseJSON(tagName: "v2.0.0", publishedAt: "2026-03-14T12:00:00Z") + let checker = AppUpdateChecker(session: session) + + let result = await checker.checkForUpdate(currentVersion: "1.0.0") + + #expect(result?.publishedAt == "2026-03-14T12:00:00Z") + } + + // MARK: - Negative: No update + + @Test("Returns nil when current version matches remote") + func returnsNilWhenSameVersion() async { + let session = MockURLSession() + session.data = makeReleaseJSON(tagName: "v1.0.8") + let checker = AppUpdateChecker(session: session) + + let result = await checker.checkForUpdate(currentVersion: "1.0.8") + + #expect(result == nil) + } + + @Test("Returns nil when current version is newer than remote") + func returnsNilWhenCurrentIsNewer() async { + let session = MockURLSession() + session.data = makeReleaseJSON(tagName: "v1.0.0") + let checker = AppUpdateChecker(session: session) + + let result = await checker.checkForUpdate(currentVersion: "1.0.8") + + #expect(result == nil) + } + + @Test("Returns nil when current is dev version ahead of release") + func returnsNilForDevVersionAhead() async { + let session = MockURLSession() + session.data = makeReleaseJSON(tagName: "v1.0.8") + let checker = AppUpdateChecker(session: session) + + // User on 1.1.0-dev which is ahead of latest release + let result = await checker.checkForUpdate(currentVersion: "1.1.0") + + #expect(result == nil) + } + + // MARK: - Negative: Error handling + + @Test("Returns nil on network error") + func returnsNilOnNetworkError() async { + let session = MockURLSession() + session.error = URLError(.notConnectedToInternet) + let checker = AppUpdateChecker(session: session) + + let result = await checker.checkForUpdate(currentVersion: "1.0.0") + + #expect(result == nil) + } + + @Test("Returns nil on HTTP 404") + func returnsNilOnNotFound() async { + let session = MockURLSession() + session.data = Data() + session.response = makeHTTPResponse(statusCode: 404) + let checker = AppUpdateChecker(session: session) + + let result = await checker.checkForUpdate(currentVersion: "1.0.0") + + #expect(result == nil) + } + + @Test("Returns nil on HTTP 403 (rate limited)") + func returnsNilOnRateLimited() async { + let session = MockURLSession() + session.data = Data("{\"message\":\"API rate limit exceeded\"}".utf8) + session.response = makeHTTPResponse(statusCode: 403) + let checker = AppUpdateChecker(session: session) + + let result = await checker.checkForUpdate(currentVersion: "1.0.0") + + #expect(result == nil) + } + + @Test("Returns nil on HTTP 500") + func returnsNilOnServerError() async { + let session = MockURLSession() + session.data = Data() + session.response = makeHTTPResponse(statusCode: 500) + let checker = AppUpdateChecker(session: session) + + let result = await checker.checkForUpdate(currentVersion: "1.0.0") + + #expect(result == nil) + } + + @Test("Returns nil on malformed JSON") + func returnsNilOnMalformedJSON() async { + let session = MockURLSession() + session.data = Data("not valid json".utf8) + let checker = AppUpdateChecker(session: session) + + let result = await checker.checkForUpdate(currentVersion: "1.0.0") + + #expect(result == nil) + } + + @Test("Returns nil on JSON missing required fields") + func returnsNilOnIncompleteJSON() async { + let session = MockURLSession() + // Valid JSON but missing tag_name + session.data = Data("{\"html_url\":\"https://example.com\"}".utf8) + let checker = AppUpdateChecker(session: session) + + let result = await checker.checkForUpdate(currentVersion: "1.0.0") + + #expect(result == nil) + } + + // MARK: - Edge cases + + @Test("downloadURL is nil when no .pkg asset exists") + func noPkgAsset() async { + let session = MockURLSession() + session.data = makeReleaseJSON( + tagName: "v2.0.0", + assets: [ + ["name": "source.tar.gz", "browser_download_url": "https://example.com/source.tar.gz"], + ] + ) + let checker = AppUpdateChecker(session: session) + + let result = await checker.checkForUpdate(currentVersion: "1.0.0") + + #expect(result != nil) + #expect(result?.downloadURL == nil) + } + + @Test("downloadURL is nil when assets array is empty") + func emptyAssets() async { + let session = MockURLSession() + session.data = makeReleaseJSON(tagName: "v2.0.0", assets: []) + let checker = AppUpdateChecker(session: session) + + let result = await checker.checkForUpdate(currentVersion: "1.0.0") + + #expect(result != nil) + #expect(result?.downloadURL == nil) + } + + @Test("Handles nil body in release") + func nilBody() async { + let session = MockURLSession() + session.data = makeReleaseJSON(tagName: "v2.0.0", body: nil) + let checker = AppUpdateChecker(session: session) + + let result = await checker.checkForUpdate(currentVersion: "1.0.0") + + #expect(result != nil) + #expect(result?.releaseNotes == nil) + } + + @Test("Handles nil publishedAt in release") + func nilPublishedAt() async { + let session = MockURLSession() + session.data = makeReleaseJSON(tagName: "v2.0.0", publishedAt: nil) + let checker = AppUpdateChecker(session: session) + + let result = await checker.checkForUpdate(currentVersion: "1.0.0") + + #expect(result != nil) + #expect(result?.publishedAt == nil) + } +}