From 82c515d0429719b29f0d3ef92d09e34b482778d1 Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:41:18 -0700 Subject: [PATCH] Fix launch compatibility on macOS 14 and 15 --- .github/workflows/macos-compatibility.yml | 88 +++++++ .github/workflows/release.yml | 10 +- Cotabby.xcodeproj/project.pbxproj | 22 +- Cotabby/App/Core/AppDelegate.swift | 41 +++ Cotabby/App/Core/CotabbyApp.swift | 74 ++---- Cotabby/Models/SuggestionSettingsData.swift | 4 +- Cotabby/Services/UI/MenuBarController.swift | 238 ++++++++++++++++++ Cotabby/Support/SuggestionSettingsStore.swift | 4 +- Cotabby/UI/MenuBarPopoverDismisser.swift | 102 -------- Cotabby/UI/MenuBarPresentationObserver.swift | 35 ++- Cotabby/UI/MenuBarStatusLabelView.swift | 44 ---- Cotabby/UI/MenuBarView.swift | 28 +-- scripts/verify_macos_compatibility.py | 154 ++++++++++++ 13 files changed, 601 insertions(+), 243 deletions(-) create mode 100644 .github/workflows/macos-compatibility.yml create mode 100644 Cotabby/Services/UI/MenuBarController.swift delete mode 100644 Cotabby/UI/MenuBarPopoverDismisser.swift delete mode 100644 Cotabby/UI/MenuBarStatusLabelView.swift create mode 100755 scripts/verify_macos_compatibility.py diff --git a/.github/workflows/macos-compatibility.yml b/.github/workflows/macos-compatibility.yml new file mode 100644 index 00000000..1dac6696 --- /dev/null +++ b/.github/workflows/macos-compatibility.yml @@ -0,0 +1,88 @@ +# Builds with the shipping Xcode/macOS generation, then launches that exact artifact on every +# supported older major version available from GitHub-hosted runners. This catches runtime and +# weak-linking failures that a deployment-target compile alone cannot see. + +name: macOS Compatibility + +on: + pull_request: + branches: [main] + push: + branches: [main] + +concurrency: + group: macos-compatibility-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-artifact: + name: Build macOS 14-compatible artifact + runs-on: macos-26 + timeout-minutes: 30 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Select Xcode + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 + with: + xcode-version: latest-stable + + - name: Build universal Release app + run: | + xcodebuild archive \ + -project Cotabby.xcodeproj \ + -scheme Cotabby \ + -configuration Release \ + -archivePath build/CotabbyCompatibility.xcarchive \ + -derivedDataPath build/CompatibilityDerivedData \ + -destination 'generic/platform=macOS' \ + ARCHS='arm64 x86_64' \ + ONLY_ACTIVE_ARCH=NO \ + CODE_SIGNING_ALLOWED=NO + + - name: Verify deployment metadata and weak linking + run: | + python3 scripts/verify_macos_compatibility.py \ + build/CotabbyCompatibility.xcarchive/Products/Applications/Cotabby.app \ + --minimum 14.0 \ + --require-universal + + - name: Package compatibility artifact + run: | + ditto -c -k --keepParent \ + build/CotabbyCompatibility.xcarchive/Products/Applications/Cotabby.app \ + build/CotabbyCompatibility.zip + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cotabby-compatibility-app + path: build/CotabbyCompatibility.zip + retention-days: 1 + + launch-artifact: + name: Launch on macOS ${{ matrix.macos-version }} + needs: build-artifact + runs-on: macos-${{ matrix.macos-version }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + macos-version: ["14", "15"] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: cotabby-compatibility-app + path: build + + - name: Extract and inspect host + run: | + sw_vers + ditto -x -k build/CotabbyCompatibility.zip build/compatibility-app + xattr -cr build/compatibility-app/Cotabby.app + + - name: Launch hosted menu graph + run: | + build/compatibility-app/Cotabby.app/Contents/MacOS/Cotabby \ + -cotabby-compatibility-smoke-test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f640b197..d3b80060 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -319,8 +319,7 @@ jobs: CODE_SIGN_IDENTITY="Developer ID Application" \ OTHER_CODE_SIGN_FLAGS="--timestamp" \ MARKETING_VERSION="${RELEASE_VERSION}" \ - CURRENT_PROJECT_VERSION="${BUILD_NUMBER}" \ - archive + CURRENT_PROJECT_VERSION="${BUILD_NUMBER}" - name: Verify build number in archive env: @@ -337,6 +336,13 @@ jobs: fi echo "Verified CFBundleVersion: ${actual}" + - name: Verify macOS 14 compatibility metadata + run: | + python3 scripts/verify_macos_compatibility.py \ + "build/${APP_NAME}.xcarchive/Products/Applications/${APP_NAME}.app" \ + --minimum 14.0 \ + --require-universal + - name: Re-sign nested code for notarization run: | set -euo pipefail diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index c053d0a3..d3fcc8fe 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -46,7 +46,6 @@ 0B378201616831626DEC3AB6 /* MacroReferenceSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64442042F5B57CB0A701DA85 /* MacroReferenceSheet.swift */; }; 0B6E28D1CBDF657F71548A3C /* EmojiPickerControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62BD2ADED33249F5BA53D0AD /* EmojiPickerControllerTests.swift */; }; 0BD3D1CA74CF6CC53BF85153 /* ScreenFrameReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5484C8A04B9C00CF79D589EB /* ScreenFrameReader.swift */; }; - 0BEBB33EB75B59EE83C6FE44 /* MenuBarPopoverDismisser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44595B534DD7323F0AD60825 /* MenuBarPopoverDismisser.swift */; }; 0C06CAD62975E87B2C852191 /* ScreenTextExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59E299BE2E9D42A33D5D2F5D /* ScreenTextExtractor.swift */; }; 0C98ECB5BCEBA72C693AC1C9 /* SuggestionTextNormalizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B55A4362AB7F0528C661C4C /* SuggestionTextNormalizerTests.swift */; }; 0CD30EF91DFB6D95D2964C3F /* SettingsIconTile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17AAA3C022A8AE39FACAAD5 /* SettingsIconTile.swift */; }; @@ -54,7 +53,6 @@ 0D8241CD31942A25EC4E0EE4 /* CotabbyDebugOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7B2D34A6F3AC9DFD61350F7 /* CotabbyDebugOptions.swift */; }; 0DDC0CFF5558A8F4355836B2 /* OverlayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F308F6E274CC645E27CB651F /* OverlayController.swift */; }; 0E4009CFC86608F055909967 /* PerformanceMetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 979A7867966180A545BB44C4 /* PerformanceMetricsStore.swift */; }; - 0ED2E5A4C1F80E3B5B767596 /* MenuBarStatusLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD42C7E2852F59BEF7972663 /* MenuBarStatusLabelView.swift */; }; 0F3267956257401F39386773 /* SuggestionOverlayStabilityGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2F95847D76893C8A5B504B4 /* SuggestionOverlayStabilityGate.swift */; }; 0FCBF2250722780E46A92EE6 /* InputSuppressionControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F583E92B0A78212B330E6E /* InputSuppressionControllerTests.swift */; }; 0FF600435A2EFA9437E36B6F /* EmojiVariantResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A8414BEB7E34F57607E37FE /* EmojiVariantResolver.swift */; }; @@ -76,6 +74,7 @@ 1671E0A3C8D6DF30C5AD7F9F /* SuggestionFadeInPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 764EE0693994E2E126E7FC77 /* SuggestionFadeInPolicy.swift */; }; 1681C0F22323FB1156579D99 /* AGPL-3.0.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6F0EE728C0B1A7AD6B19CD0C /* AGPL-3.0.txt */; }; 175C4FA56C29DEE58C2D4D7E /* SuggestionSettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86460C747AA883FDE756BDBA /* SuggestionSettingsModel.swift */; }; + 17778ECC847014706A6F220D /* MenuBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F4CB1434ED845578588AD9 /* MenuBarController.swift */; }; 18382D1919D90E3C1EE143C2 /* AppSurfaceClassifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C451E144D220D5C63372A8C0 /* AppSurfaceClassifierTests.swift */; }; 18680D0D66469A2954A50B6C /* SuggestionQualityMetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81718CA62FBC775A6CEBCED1 /* SuggestionQualityMetricsStore.swift */; }; 1899BC5A35DC96B4D04B18A5 /* es.txt in Resources */ = {isa = PBXBuildFile; fileRef = 0B6816DF5D33863F966240B4 /* es.txt */; }; @@ -248,7 +247,6 @@ 4F38CE1C2602CF4F41323032 /* PermissionOverlayTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12DD19BCE610808F1E38702D /* PermissionOverlayTrackerTests.swift */; }; 4F3CE71B97663B8025779F9C /* fr.txt in Resources */ = {isa = PBXBuildFile; fileRef = 5DCA3E46621815A19300365F /* fr.txt */; }; 4F8EBC5FAE109058D3D2722C /* FocusModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C383AE85B971A9605787358 /* FocusModels.swift */; }; - 4FC42808D61BF110425801ED /* MenuBarPopoverDismisser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44595B534DD7323F0AD60825 /* MenuBarPopoverDismisser.swift */; }; 4FC52FB28AFC013F000D8FF9 /* SecureFieldDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 474560E524C1D74BAB1570DA /* SecureFieldDetectorTests.swift */; }; 5009AF59DE8D40A45C0A5C2F /* ControlTokenMarkersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22707E26E2106DF0E826D32D /* ControlTokenMarkersTests.swift */; }; 507E7BCCD189A64C3F8ECB79 /* HomePaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1123AB515110BD0CBA39490 /* HomePaneView.swift */; }; @@ -328,6 +326,7 @@ 66C23A7C2FCDE0266FF425F8 /* ApplicationBundleMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 352AF5B2834FEE1F597394E4 /* ApplicationBundleMetadata.swift */; }; 66D0D9F605AF462F569A5CFD /* SpellingLanguageResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0871985CB1F877EC422E18C /* SpellingLanguageResolverTests.swift */; }; 66D9E37B12A9265D4733E72E /* LlamaRuntimeCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 944065A858D9BC936CB12B23 /* LlamaRuntimeCore.swift */; }; + 67F9E4AD54E17A17FC825D74 /* OllamaPreloadWorkControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4660F0FA8500594314C6D5C /* OllamaPreloadWorkControllerTests.swift */; }; 68DA5F93B7185B4F5E6DB4C3 /* it.txt in Resources */ = {isa = PBXBuildFile; fileRef = 0397F1DACB094A0F6A66BC0E /* it.txt */; }; 6955C3A4D7AB3EEF7FA7C469 /* InputSuppressionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D1F9CEBAB0F330F8E7B61D8 /* InputSuppressionController.swift */; }; 695E431AC3FF79769E2C5EEF /* SuggestionQualityMetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4CC566AC1DE33FD0CD30E1E /* SuggestionQualityMetricsStoreTests.swift */; }; @@ -501,7 +500,6 @@ AD6E005ABE34AB7EBD92A30D /* RuntimeBootstrapModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1E6D9CCC0AA3674FEE57AE0 /* RuntimeBootstrapModelTests.swift */; }; ADBCB725707ED11B19C7F08D /* InsertionStrategySelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0D2FEEA4304C86324BAADAB /* InsertionStrategySelector.swift */; }; ADFBCB4099CF919F3EC5BE7B /* SecureFieldDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1827565F4FAD3E4E61CA65C3 /* SecureFieldDetector.swift */; }; - AECC7289DA796B071B4FE3C0 /* MenuBarStatusLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD42C7E2852F59BEF7972663 /* MenuBarStatusLabelView.swift */; }; AF26E77871200BB1FAAEBE79 /* SelfCaptureGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A5591BEB9EE7B6E9064412 /* SelfCaptureGateTests.swift */; }; AF55F1ABEDEA10C76C307CEC /* CotabbyAppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711293EA57808B9428C7B908 /* CotabbyAppEnvironment.swift */; }; AF86357E50ADC91838446A9A /* AcknowledgementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B7A28471B8526C2693FFF65 /* AcknowledgementsView.swift */; }; @@ -644,6 +642,7 @@ E3CAAEFAAB5BB24CEE16445B /* LLMIOFileHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D610FCA3A97249DCCE7D0B8 /* LLMIOFileHandler.swift */; }; E4382BEA8A8551612E5966B9 /* BaseCompletionPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EF79E6144D6C6AD062B569 /* BaseCompletionPromptRenderer.swift */; }; E46F50AEDA8FE13B02E3FA8D /* AXHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC70775535A3428991025AB8 /* AXHelper.swift */; }; + E50EF4A442A9EFFB7904600D /* MenuBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F4CB1434ED845578588AD9 /* MenuBarController.swift */; }; E51FA12B690428CA431328FC /* WritingPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48B95B6665109B6C6A63B42 /* WritingPaneView.swift */; }; E54F5F03E16859D5A1E3437A /* MacroController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4638C74239D1DE2DC4D87975 /* MacroController.swift */; }; E5CB34ED76BAE87E8A858112 /* WebContentFieldDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 210F9AD332273FE2EB3A9A01 /* WebContentFieldDetectorTests.swift */; }; @@ -836,7 +835,6 @@ 43E37A7E835D3BDE6265843C /* TerminalAppDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAppDetectorTests.swift; sourceTree = ""; }; 442507A5399ACD81D1DB4936 /* SuggestionFadeInPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionFadeInPolicyTests.swift; sourceTree = ""; }; 4451D6673112575DF24C4A48 /* OnboardingTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTemplate.swift; sourceTree = ""; }; - 44595B534DD7323F0AD60825 /* MenuBarPopoverDismisser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarPopoverDismisser.swift; sourceTree = ""; }; 45A896811745673061AF3612 /* SuggestionFocusFreshnessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionFocusFreshnessTests.swift; sourceTree = ""; }; 4638C74239D1DE2DC4D87975 /* MacroController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroController.swift; sourceTree = ""; }; 4696A84D17890B154533A08F /* PromptPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptPolicyTests.swift; sourceTree = ""; }; @@ -1017,6 +1015,7 @@ B3B09064903B760D6DF2DF7D /* DecodeStopPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecodeStopPolicyTests.swift; sourceTree = ""; }; B41F06FEF208B30ECCF23A6F /* MacroModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroModels.swift; sourceTree = ""; }; B424E2AC97C99D335B0D5751 /* SuggestionTextNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionTextNormalizer.swift; sourceTree = ""; }; + B4660F0FA8500594314C6D5C /* OllamaPreloadWorkControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OllamaPreloadWorkControllerTests.swift; sourceTree = ""; }; B4B4A2E2DD6733658EC05BD8 /* DownloadFileRescuer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadFileRescuer.swift; sourceTree = ""; }; B4CC566AC1DE33FD0CD30E1E /* SuggestionQualityMetricsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionQualityMetricsStoreTests.swift; sourceTree = ""; }; B6ACCB12E4DB32D2F2BEA567 /* PermissionHostApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionHostApp.swift; sourceTree = ""; }; @@ -1035,7 +1034,6 @@ BADB38D0160B47637572FC5E /* SettingsSidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSidebarView.swift; sourceTree = ""; }; BB5C2AE9A7E55495D26AD074 /* DownloadableModelCatalogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadableModelCatalogView.swift; sourceTree = ""; }; BC4F887528AE74AC0DD30314 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - BD42C7E2852F59BEF7972663 /* MenuBarStatusLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarStatusLabelView.swift; sourceTree = ""; }; BE04620C905041680116BE80 /* LlamaSuggestionEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaSuggestionEngine.swift; sourceTree = ""; }; BE0A565A2AD007EBE9D70697 /* SettingsQuickLinkCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsQuickLinkCard.swift; sourceTree = ""; }; BE8D7424D350FC7C0685DBEC /* SettingsSearchResultRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSearchResultRow.swift; sourceTree = ""; }; @@ -1136,6 +1134,7 @@ EFD89799BB82AF7A92559AEB /* ClipboardContentDistillerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardContentDistillerTests.swift; sourceTree = ""; }; F050CE655081B840E361899E /* SuggestionSettingsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsStoreTests.swift; sourceTree = ""; }; F0C3E6241B0F35C7A2C85965 /* StaticTextRunWalkThrottle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticTextRunWalkThrottle.swift; sourceTree = ""; }; + F2F4CB1434ED845578588AD9 /* MenuBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarController.swift; sourceTree = ""; }; F308F6E274CC645E27CB651F /* OverlayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayController.swift; sourceTree = ""; }; F36111592745117D04C42405 /* TextLayoutCaretEstimatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextLayoutCaretEstimatorTests.swift; sourceTree = ""; }; F4D9DF8723AF32C058BFACDE /* SpellingDictionaryCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpellingDictionaryCatalog.swift; sourceTree = ""; }; @@ -1565,6 +1564,7 @@ 03766F6253FF17639230C0F6 /* ModelAndPresentationValueTests.swift */, A829F28F01FAE76CA7244BBC /* ModelFileValidatorTests.swift */, 5EED3CD2BC7B48DF35DEE562 /* OCRTextHygieneTests.swift */, + B4660F0FA8500594314C6D5C /* OllamaPreloadWorkControllerTests.swift */, A4B220D9A25174A43E7A258B /* OnboardingFlowStepTests.swift */, D814BBA41CF29E8DD9954651 /* OnboardingTemplateFeatureListTests.swift */, 01B72736E416910878E8E493 /* OnboardingTemplateRecommenderTests.swift */, @@ -1680,10 +1680,8 @@ 5A567677424A82D9EEF47495 /* KeyRecorderView.swift */, 9A7CDA90E128350BFF1A9D66 /* LanguageTagsEditor.swift */, 64442042F5B57CB0A701DA85 /* MacroReferenceSheet.swift */, - 44595B534DD7323F0AD60825 /* MenuBarPopoverDismisser.swift */, 00824BDD8D0E9B3063827C78 /* MenuBarPresentationObserver.swift */, 83A810F9D28A18BA6F2066C7 /* MenuBarSections.swift */, - BD42C7E2852F59BEF7972663 /* MenuBarStatusLabelView.swift */, 9AA0117B322C625F6D4BBEAB /* MenuBarView.swift */, 565AB9A1265315C500FE5BDF /* PopupChrome.swift */, 5484C8A04B9C00CF79D589EB /* ScreenFrameReader.swift */, @@ -1725,6 +1723,7 @@ 764659D09C3F0E8FBD267102 /* EmojiPickerPanelController.swift */, 0F5E263AB69029D5E13D5EE8 /* FocusDebugOverlayController.swift */, AAEFF8BAEC9168CADEDD4757 /* InlinePreviewPanelController.swift */, + F2F4CB1434ED845578588AD9 /* MenuBarController.swift */, F308F6E274CC645E27CB651F /* OverlayController.swift */, ); path = UI; @@ -2177,11 +2176,10 @@ 323500F336AF70C520926383 /* MacroReferenceSheet.swift in Sources */, 6503E1585D0CDE8CD852144B /* MacroTriggerStateMachine.swift in Sources */, 057CEA7858012C1501F1785C /* MarkerSelectionSynthesizer.swift in Sources */, - 4FC42808D61BF110425801ED /* MenuBarPopoverDismisser.swift in Sources */, + E50EF4A442A9EFFB7904600D /* MenuBarController.swift in Sources */, 783BEC91DBC86AF75CEDB269 /* MenuBarPresentationObserver.swift in Sources */, 96ECE89BD96CF93CC159B437 /* MenuBarRecoveryPolicy.swift in Sources */, C0537A515AED443F6C61DB2A /* MenuBarSections.swift in Sources */, - 0ED2E5A4C1F80E3B5B767596 /* MenuBarStatusLabelView.swift in Sources */, F04D9470439699DB1F016000 /* MenuBarView.swift in Sources */, 29ABB5488251FD8089D74F51 /* MidWordContinuationPolicy.swift in Sources */, A3913860E04446A86833D39B /* MirrorOverlayLayout.swift in Sources */, @@ -2433,11 +2431,10 @@ 0B378201616831626DEC3AB6 /* MacroReferenceSheet.swift in Sources */, 3FF6B7DE34A01C4AB7FA54E3 /* MacroTriggerStateMachine.swift in Sources */, 5C119807B84F84B0B1B1C2D5 /* MarkerSelectionSynthesizer.swift in Sources */, - 0BEBB33EB75B59EE83C6FE44 /* MenuBarPopoverDismisser.swift in Sources */, + 17778ECC847014706A6F220D /* MenuBarController.swift in Sources */, F08C139B246C1EC7BB435455 /* MenuBarPresentationObserver.swift in Sources */, FFF5215EAC4A931CCD0C3142 /* MenuBarRecoveryPolicy.swift in Sources */, 0333B3CE8F189DD1BEC4AD26 /* MenuBarSections.swift in Sources */, - AECC7289DA796B071B4FE3C0 /* MenuBarStatusLabelView.swift in Sources */, 5E92E3C1EB41D482FC06BC52 /* MenuBarView.swift in Sources */, 9CEBD6AF4405F1BBE0E3D16C /* MidWordContinuationPolicy.swift in Sources */, 31515DDD173535C4AC777853 /* MirrorOverlayLayout.swift in Sources */, @@ -2651,6 +2648,7 @@ 25D4FC8D191A50F63E6391F9 /* ModelAndPresentationValueTests.swift in Sources */, 65478B0DABF5460C32D4C458 /* ModelFileValidatorTests.swift in Sources */, 3F5630CFB7BA40B900E832A1 /* OCRTextHygieneTests.swift in Sources */, + 67F9E4AD54E17A17FC825D74 /* OllamaPreloadWorkControllerTests.swift in Sources */, 8D9F33F49E880956D0571574 /* OnboardingFlowStepTests.swift in Sources */, DA23422A2CF77CFD3B1283C8 /* OnboardingTemplateFeatureListTests.swift in Sources */, D648DD70AD847F67B77CE052 /* OnboardingTemplateRecommenderTests.swift in Sources */, diff --git a/Cotabby/App/Core/AppDelegate.swift b/Cotabby/App/Core/AppDelegate.swift index 4e755b4a..a2db20aa 100644 --- a/Cotabby/App/Core/AppDelegate.swift +++ b/Cotabby/App/Core/AppDelegate.swift @@ -32,6 +32,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private let activationIndicatorController: ActivationIndicatorController private let focusDebugOverlayController: FocusDebugOverlayController? + /// AppKit owns the menu-bar lifecycle so launch never depends on SwiftUI scene construction. + /// Lazy creation lets callbacks capture this delegate only after `super.init()` has completed. + private lazy var menuBarController = MenuBarController( + permissionManager: permissionManager, + runtimeModel: runtimeModel, + modelDownloadManager: modelDownloadManager, + focusModel: focusModel, + permissionGuidanceController: permissionGuidanceController, + suggestionSettings: suggestionSettings, + foundationModelAvailabilityService: foundationModelAvailabilityService, + powerSourceMonitor: powerSourceMonitor, + suggestionCoordinator: suggestionCoordinator, + appUpdateManager: appUpdateManager, + onOpenSettings: { [weak self] in + self?.settingsCoordinator.showSettings() + }, + onReportFeedback: { + guard let baseURL = URL(string: "https://www.cotabby.app/feedback") else { return } + let url = DeviceInfo.snapshot().appending(to: baseURL) + NSWorkspace.shared.open(url) + } + ) /// Retained for the app's lifetime because the environment owns its own `cancellables` (the only /// subscriptions wiring the focus-poll-interval setting and the global-toggle hotkey rebind to the /// runtime). If the environment deallocated when `init` returned, those subscriptions would be @@ -131,11 +153,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// Starts runtime and polling services once AppKit reports that app launch finished. func applicationDidFinishLaunching(_ notification: Notification) { + if Self.isRunningCompatibilitySmokeTest { + menuBarController.prepareForCompatibilitySmokeTest() + CotabbyLogger.app.info("macOS compatibility launch smoke test passed") + // Leave the launch callback before terminating so AppKit finishes its notification + // bookkeeping; this produces a normal zero exit status instead of an artificial kill. + DispatchQueue.main.async { + NSApplication.shared.terminate(nil) + } + return + } + guard !Self.isRunningUnderXCTest else { CotabbyLogger.app.info("Unit test host detected; skipping production service startup") return } + menuBarController.start() let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?" let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?" CotabbyLogger.app.info("Cotabby \(version) (build \(build)) launching on macOS \(ProcessInfo.processInfo.operatingSystemVersionString)") @@ -228,6 +262,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { focusDebugOverlayController?.hide() suggestionCoordinator.stop() inlineCommandCoordinator.stop() + menuBarController.stop() inputMonitor.stop() focusModel.stop() @@ -285,4 +320,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private static var isRunningUnderXCTest: Bool { ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil } + + /// CI launches the built application itself on older macOS runners. This argument exercises + /// AppKit startup plus the complete hosted menu graph without touching global input or TCC. + private static var isRunningCompatibilitySmokeTest: Bool { + ProcessInfo.processInfo.arguments.contains("-cotabby-compatibility-smoke-test") + } } diff --git a/Cotabby/App/Core/CotabbyApp.swift b/Cotabby/App/Core/CotabbyApp.swift index 9fc9303c..d5c6f8a3 100644 --- a/Cotabby/App/Core/CotabbyApp.swift +++ b/Cotabby/App/Core/CotabbyApp.swift @@ -1,64 +1,24 @@ -import SwiftUI +import AppKit /// File overview: -/// Declares the SwiftUI app entry point and hosts the optional menu-bar scene that renders -/// Cotabby's compact status UI. Shared services are injected through `AppDelegate`. +/// Declares Cotabby's AppKit entry point. `AppDelegate` owns the long-lived dependency graph and +/// `MenuBarController` owns the status item/popover, so launch never has to instantiate SwiftUI's +/// `App`/`Scene` graph. That boundary matters for macOS 14 and early macOS 15 releases, where an +/// Xcode 26-built `MenuBarExtra(.window)` can abort inside AttributeGraph before AppKit delivers a +/// lifecycle callback (issue #767). /// -/// `@main` marks the single process entry point for a Swift app. +/// The enum has no instances. Its one static delegate reference keeps the weak `NSApplication` +/// delegate alive until the run loop exits. @main -struct CotabbyApp: App { - /// Bridges old-style AppKit lifecycle callbacks into a SwiftUI app. - @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate - /// Scene declarations cannot directly observe the delegate's nested settings object. This - /// projection watches the same durable key so SwiftUI re-evaluates status-item insertion as - /// soon as Settings changes it; the settings model remains the app-facing preference API. - @AppStorage(SuggestionSettingsStore.menuBarIconVisibleDefaultsKey) - private var isMenuBarIconVisible = true +enum CotabbyApp { + private static var appDelegate: AppDelegate? - /// Defines the menu bar extra that surfaces Cotabby's runtime, focus, and suggestion state. - var body: some Scene { - MenuBarExtra(isInserted: menuBarIconVisibilityBinding) { - MenuBarView( - permissionManager: appDelegate.permissionManager, - runtimeModel: appDelegate.runtimeModel, - modelDownloadManager: appDelegate.modelDownloadManager, - focusModel: appDelegate.focusModel, - permissionGuidanceController: appDelegate.permissionGuidanceController, - suggestionSettings: appDelegate.suggestionSettings, - foundationModelAvailabilityService: appDelegate.foundationModelAvailabilityService, - powerSourceMonitor: appDelegate.powerSourceMonitor, - appUpdateManager: appDelegate.appUpdateManager, - onOpenSettings: { - appDelegate.settingsCoordinator.showSettings() - }, - onReportFeedback: { - guard let baseURL = URL(string: "https://www.cotabby.app/feedback") else { - return - } - // Attach host details so the landing form can pre-fill the Environment block - // (Cotabby + macOS + hardware) and the user only has to write the actual report. - let url = DeviceInfo.snapshot().appending(to: baseURL) - NSWorkspace.shared.open(url) - } - ) - } label: { - MenuBarStatusLabelView( - suggestionCoordinator: appDelegate.suggestionCoordinator, - suggestionSettings: appDelegate.suggestionSettings - ) - } - .menuBarExtraStyle(.window) - } - - /// SwiftUI owns insertion/removal of the status item, while the settings model remains the - /// single durable source of truth. The setter writes only through the model; `@AppStorage` - /// observes the same `UserDefaults` key, so the getter and the scene re-evaluate off that one - /// write instead of persisting the value twice (which could drift if model-side validation is - /// ever added). - private var menuBarIconVisibilityBinding: Binding { - Binding( - get: { isMenuBarIconVisible }, - set: { appDelegate.suggestionSettings.setMenuBarIconVisible($0) } - ) + @MainActor + static func main() { + let application = NSApplication.shared + let delegate = AppDelegate() + appDelegate = delegate + application.delegate = delegate + application.run() } } diff --git a/Cotabby/Models/SuggestionSettingsData.swift b/Cotabby/Models/SuggestionSettingsData.swift index 3d86138c..b95671da 100644 --- a/Cotabby/Models/SuggestionSettingsData.swift +++ b/Cotabby/Models/SuggestionSettingsData.swift @@ -57,8 +57,8 @@ struct SuggestionSettingsData: Equatable { /// green preview while typing, disable it, or use both behaviors together. var automaticallyFixTypos: Bool var isPerformanceTrackingEnabled: Bool - /// Controls whether SwiftUI inserts Cotabby's `MenuBarExtra` into the system menu bar. The app - /// keeps running when this is false; reopening Cotabby provides the recovery path to Settings. + /// Controls whether AppKit inserts Cotabby's status item into the system menu bar. The app keeps + /// running when this is false; reopening Cotabby provides the recovery path to Settings. var isMenuBarIconVisible: Bool var isMenuBarWordCountVisible: Bool var mirrorPreference: MirrorPreference diff --git a/Cotabby/Services/UI/MenuBarController.swift b/Cotabby/Services/UI/MenuBarController.swift new file mode 100644 index 00000000..5cfe67c3 --- /dev/null +++ b/Cotabby/Services/UI/MenuBarController.swift @@ -0,0 +1,238 @@ +import AppKit +import Combine +import SwiftUI + +/// Owns Cotabby's menu-bar status item and popover through stable AppKit APIs. +/// +/// SwiftUI still renders `MenuBarView`, but only as the popover's hosted content. AppKit owns the +/// application and window lifecycle, which keeps older macOS releases out of SwiftUI's scene-level +/// `MenuBarExtra` AttributeGraph path. `AppDelegate` creates one controller lazily, starts it after +/// launch, and retains it for the process lifetime. +@MainActor +final class MenuBarController: NSObject, NSPopoverDelegate { + private let permissionManager: PermissionManager + private let runtimeModel: RuntimeBootstrapModel + private let modelDownloadManager: ModelDownloadManager + private let focusModel: FocusTrackingModel + private let permissionGuidanceController: PermissionGuidanceController + private let suggestionSettings: SuggestionSettingsModel + private let foundationModelAvailabilityService: FoundationModelAvailabilityService + private let powerSourceMonitor: PowerSourceMonitor + private let suggestionCoordinator: SuggestionCoordinator + private let appUpdateManager: AppUpdateManager + private let onOpenSettings: () -> Void + private let onReportFeedback: () -> Void + + private var statusItem: NSStatusItem? + private var cancellables = Set() + private var isStarted = false + + /// The hosted SwiftUI graph is built lazily after AppKit has finished application launch. It + /// lives as long as the controller so menu state is preserved between popover openings. + private lazy var hostingController: NSHostingController = { + let controller = NSHostingController(rootView: makeMenuBarView()) + controller.sizingOptions = [.preferredContentSize] + return controller + }() + + private lazy var popover: NSPopover = { + let popover = NSPopover() + popover.behavior = .transient + popover.animates = true + popover.delegate = self + popover.contentViewController = hostingController + return popover + }() + + init( + permissionManager: PermissionManager, + runtimeModel: RuntimeBootstrapModel, + modelDownloadManager: ModelDownloadManager, + focusModel: FocusTrackingModel, + permissionGuidanceController: PermissionGuidanceController, + suggestionSettings: SuggestionSettingsModel, + foundationModelAvailabilityService: FoundationModelAvailabilityService, + powerSourceMonitor: PowerSourceMonitor, + suggestionCoordinator: SuggestionCoordinator, + appUpdateManager: AppUpdateManager, + onOpenSettings: @escaping () -> Void, + onReportFeedback: @escaping () -> Void + ) { + self.permissionManager = permissionManager + self.runtimeModel = runtimeModel + self.modelDownloadManager = modelDownloadManager + self.focusModel = focusModel + self.permissionGuidanceController = permissionGuidanceController + self.suggestionSettings = suggestionSettings + self.foundationModelAvailabilityService = foundationModelAvailabilityService + self.powerSourceMonitor = powerSourceMonitor + self.suggestionCoordinator = suggestionCoordinator + self.appUpdateManager = appUpdateManager + self.onOpenSettings = onOpenSettings + self.onReportFeedback = onReportFeedback + super.init() + } + + /// Starts the two subscriptions that drive status-item presence and label content. Repeated + /// calls are harmless, which lets the compatibility smoke path share production setup. + func start() { + guard !isStarted else { return } + isStarted = true + + suggestionSettings.$isMenuBarIconVisible + .removeDuplicates() + .sink { [weak self] isVisible in + self?.setStatusItemVisible(isVisible) + } + .store(in: &cancellables) + + Publishers.CombineLatest4( + suggestionCoordinator.$totalTabAcceptedWordCount, + suggestionSettings.$isMenuBarWordCountVisible, + suggestionSettings.$pauseState, + suggestionSettings.$isGloballyEnabled + ) + .sink { [weak self] _, _, _, _ in + self?.updateStatusItemLabel() + } + .store(in: &cancellables) + } + + /// Removes AppKit resources during orderly shutdown. The settings model remains untouched so + /// the next launch reconstructs the same visible/hidden state. + func stop() { + guard isStarted else { return } + dismissPopover() + if let statusItem { + NSStatusBar.system.removeStatusItem(statusItem) + } + statusItem = nil + cancellables.removeAll() + isStarted = false + } + + /// Forces construction and layout of the status item plus hosted menu without starting global + /// input, Accessibility, model, or update services. CI uses this on macOS 14 and 15 to catch + /// launch-time framework/runtime incompatibilities in the actual Xcode 26-built app artifact. + func prepareForCompatibilitySmokeTest() { + start() + // A developer may have hidden the icon in persistent defaults. CI and manual smoke runs + // must still exercise the actual status-item/popover path regardless of that preference. + createStatusItemIfNeeded() + hostingController.loadView() + hostingController.view.layoutSubtreeIfNeeded() + _ = hostingController.view.fittingSize + + guard let button = statusItem?.button else { + preconditionFailure("Compatibility smoke test could not create the status item") + } + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + popover.performClose(nil) + } + + func popoverDidClose(_ notification: Notification) { + statusItem?.button?.highlight(false) + } + + private func makeMenuBarView() -> MenuBarView { + MenuBarView( + permissionManager: permissionManager, + runtimeModel: runtimeModel, + modelDownloadManager: modelDownloadManager, + focusModel: focusModel, + permissionGuidanceController: permissionGuidanceController, + suggestionSettings: suggestionSettings, + foundationModelAvailabilityService: foundationModelAvailabilityService, + powerSourceMonitor: powerSourceMonitor, + appUpdateManager: appUpdateManager, + onDismiss: { [weak self] in self?.dismissPopover() }, + onOpenSettings: onOpenSettings, + onReportFeedback: onReportFeedback + ) + } + + private func setStatusItemVisible(_ isVisible: Bool) { + if isVisible { + createStatusItemIfNeeded() + } else { + dismissPopover() + if let statusItem { + NSStatusBar.system.removeStatusItem(statusItem) + } + statusItem = nil + } + } + + private func createStatusItemIfNeeded() { + guard statusItem == nil else { return } + + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + guard let button = item.button else { + NSStatusBar.system.removeStatusItem(item) + return + } + + let image = NSImage(named: "MenuBarCatIcon") + ?? NSImage(systemSymbolName: "text.bubble", accessibilityDescription: "Cotabby") + image?.isTemplate = true + image?.size = NSSize(width: 18, height: 18) + button.image = image + button.imagePosition = .imageLeading + button.imageHugsTitle = true + button.target = self + button.action = #selector(togglePopover(_:)) + button.toolTip = "Cotabby" + button.setAccessibilityLabel("Cotabby") + + statusItem = item + updateStatusItemLabel() + } + + private func updateStatusItemLabel() { + guard let button = statusItem?.button else { return } + + var components: [String] = [] + let isInactive = suggestionSettings.isTemporarilyPaused + || !suggestionSettings.isGloballyEnabled + if isInactive { + components.append("Ⅱ") + } + if suggestionSettings.isMenuBarWordCountVisible, + let count = WordCountFormatter.compactLabel( + for: suggestionCoordinator.totalTabAcceptedWordCount + ) { + components.append(count) + } + + button.title = components.isEmpty ? "" : " " + components.joined(separator: " ") + let stateDescription: String + if !suggestionSettings.isGloballyEnabled { + stateDescription = "disabled" + } else if suggestionSettings.isTemporarilyPaused { + stateDescription = "paused" + } else { + stateDescription = "active" + } + button.setAccessibilityValue("Cotabby \(stateDescription)") + } + + @objc + private func togglePopover(_ sender: NSStatusBarButton) { + if popover.isShown { + dismissPopover() + return + } + + sender.highlight(true) + popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY) + popover.contentViewController?.view.window?.makeKey() + } + + private func dismissPopover() { + guard popover.isShown else { + statusItem?.button?.highlight(false) + return + } + popover.performClose(nil) + } +} diff --git a/Cotabby/Support/SuggestionSettingsStore.swift b/Cotabby/Support/SuggestionSettingsStore.swift index 045e0e48..a59bda1d 100644 --- a/Cotabby/Support/SuggestionSettingsStore.swift +++ b/Cotabby/Support/SuggestionSettingsStore.swift @@ -107,8 +107,8 @@ struct SuggestionSettingsStore { private static let spellingDictionaryCodesDefaultsKey = "cotabbyEnabledSpellingDictionaryCodes" private static let automaticallyFixTyposDefaultsKey = "cotabbyAutomaticallyFixTypos" private static let performanceTrackingEnabledDefaultsKey = "cotabbyPerformanceTrackingEnabled" - /// Shared with `CotabbyApp` because SwiftUI's scene-level `@AppStorage` is what invalidates the - /// `MenuBarExtra` insertion binding when the settings model writes this preference. + /// Observed by `MenuBarController`, which inserts or removes the AppKit status item when the + /// settings model writes this preference. static let menuBarIconVisibleDefaultsKey = "cotabbyMenuBarIconVisible" private static let menuBarWordCountVisibleDefaultsKey = "cotabbyMenuBarWordCountVisible" private static let mirrorPreferenceDefaultsKey = "cotabbyMirrorPreference" diff --git a/Cotabby/UI/MenuBarPopoverDismisser.swift b/Cotabby/UI/MenuBarPopoverDismisser.swift deleted file mode 100644 index db45d74f..00000000 --- a/Cotabby/UI/MenuBarPopoverDismisser.swift +++ /dev/null @@ -1,102 +0,0 @@ -import AppKit -import Combine -import SwiftUI - -/// Captures the `NSWindow` that backs a `MenuBarExtra(.window)` popover so SwiftUI buttons can -/// programmatically dismiss the popover the same way a `Link` would. -/// -/// Why this exists: -/// `MenuBarExtra` with `.window` style does not surface its host window through SwiftUI's -/// `@Environment(\.dismiss)` action, so a `Button` action stays open after firing. `Link` gets -/// dismissal for free because `NSWorkspace.shared.open` resigns key from the popover. Every other -/// button that triggers an app-window transition (Settings, eventually Onboarding/Welcome) needs -/// to dismiss the popover itself or it sits on top of the just-opened window. -/// -/// The dismisser hooks into the same `viewDidMoveToWindow` lifecycle that -/// `MenuBarPresentationObserver` uses to grab the popover's real `NSWindow`, then stores a weak -/// reference so the SwiftUI side can call `dismiss()` on demand. -@MainActor -final class MenuBarPopoverDismisser: ObservableObject { - /// Weak so it dies with the popover; the SwiftUI view never owns the AppKit window. - fileprivate weak var hostWindow: NSWindow? - - /// Closes the captured popover and clears the status bar button's pressed-state highlight. - /// Safe to call when the popover isn't visible — `orderOut` is a no-op on a hidden window. - func dismiss() { - // `resignKey` first so any responder-chain bookkeeping (e.g. a focused text field) flushes - // before the window goes off-screen; `orderOut` then matches the system's own popover - // dismissal animation path. - hostWindow?.resignKey() - hostWindow?.orderOut(nil) - // The status bar button keeps its highlighted/pressed appearance after a programmatic - // `orderOut` because AppKit only clears that state when the popover dismisses through its - // own click-toggle path. Walk our own windows for the `NSStatusBarButton` and reset the - // highlight so the menu bar icon doesn't look stuck in the "open" state once Settings is up. - Self.unhighlightStatusBarButton() - } - - private static func unhighlightStatusBarButton() { - for window in NSApp.windows { - guard let contentView = window.contentView else { continue } - if let button = findStatusBarButton(in: contentView) { - button.highlight(false) - return - } - } - } - - private static func findStatusBarButton(in view: NSView) -> NSStatusBarButton? { - if let button = view as? NSStatusBarButton { - return button - } - for subview in view.subviews { - if let button = findStatusBarButton(in: subview) { - return button - } - } - return nil - } -} - -/// Invisible `NSView` whose only job is to forward its host `NSWindow` to a -/// `MenuBarPopoverDismisser`. Attached as a `.background` modifier so it inherits the popover's -/// real backing window without affecting layout. -struct MenuBarPopoverDismisserBinder: NSViewRepresentable { - let dismisser: MenuBarPopoverDismisser - let onWindowBind: (NSWindow) -> Void - - init( - dismisser: MenuBarPopoverDismisser, - onWindowBind: @escaping (NSWindow) -> Void = { _ in } - ) { - self.dismisser = dismisser - self.onWindowBind = onWindowBind - } - - func makeNSView(context: Context) -> WindowBindingView { - let view = WindowBindingView() - view.dismisser = dismisser - view.onWindowBind = onWindowBind - return view - } - - func updateNSView(_ nsView: WindowBindingView, context: Context) { - nsView.dismisser = dismisser - nsView.onWindowBind = onWindowBind - } - - final class WindowBindingView: NSView { - weak var dismisser: MenuBarPopoverDismisser? - var onWindowBind: ((NSWindow) -> Void)? - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - // `window` is nil while the popover is being torn down. Skipping the update in that - // case keeps a stale reference from outliving the actual popover instance. - if let window { - dismisser?.hostWindow = window - onWindowBind?(window) - } - } - } -} diff --git a/Cotabby/UI/MenuBarPresentationObserver.swift b/Cotabby/UI/MenuBarPresentationObserver.swift index 6f91d57a..18f3ae9a 100644 --- a/Cotabby/UI/MenuBarPresentationObserver.swift +++ b/Cotabby/UI/MenuBarPresentationObserver.swift @@ -1,11 +1,11 @@ import AppKit import SwiftUI -/// Observes the AppKit window that backs SwiftUI's menu-bar panel. +/// Observes the AppKit window that backs the hosted SwiftUI menu-bar panel. /// -/// `MenuBarExtra` may keep its SwiftUI content alive between openings, which means `onAppear` is -/// not a perfect "the user opened the menu" signal. This tiny bridge watches the real `NSWindow` -/// instead and calls `onPresent` whenever the panel becomes key or visible again. +/// `MenuBarController` keeps its SwiftUI content alive between popover openings, which means +/// `onAppear` is not a perfect "the user opened the menu" signal. This tiny bridge watches the real +/// `NSWindow` instead and calls `onPresent` whenever the panel becomes key or visible again. struct MenuBarPresentationObserver: NSViewRepresentable { let onPresent: () -> Void @@ -86,3 +86,30 @@ final class MenuBarPresentationTrackingView: NSView { observedWindow = nil } } + +/// Invisible bridge used for one-time native window configuration once `NSHostingController` has +/// attached the menu content to its popover. It does not own either object. +struct MenuBarWindowBinder: NSViewRepresentable { + let onWindowBind: (NSWindow) -> Void + + func makeNSView(context: Context) -> WindowBindingView { + let view = WindowBindingView() + view.onWindowBind = onWindowBind + return view + } + + func updateNSView(_ nsView: WindowBindingView, context: Context) { + nsView.onWindowBind = onWindowBind + } + + final class WindowBindingView: NSView { + var onWindowBind: ((NSWindow) -> Void)? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if let window { + onWindowBind?(window) + } + } + } +} diff --git a/Cotabby/UI/MenuBarStatusLabelView.swift b/Cotabby/UI/MenuBarStatusLabelView.swift deleted file mode 100644 index 250b352a..00000000 --- a/Cotabby/UI/MenuBarStatusLabelView.swift +++ /dev/null @@ -1,44 +0,0 @@ -import SwiftUI - -/// File overview: -/// Renders the tiny always-visible menu-bar label. This view stays intentionally separate from -/// the larger menu content so the menu-bar extra can stay minimal even as the panel layout evolves. -/// -/// This label lives in its own view because `MenuBarExtra` does not automatically observe -/// plain properties hanging off `AppDelegate`. By observing the coordinator directly here, -/// SwiftUI knows when to redraw the menu bar item as the accepted word count changes. -struct MenuBarStatusLabelView: View { - @ObservedObject var suggestionCoordinator: SuggestionCoordinator - @ObservedObject var suggestionSettings: SuggestionSettingsModel - - var body: some View { - HStack(spacing: 2) { - Image("MenuBarCatIcon") - .renderingMode(.template) - .resizable() - .scaledToFit() - .frame(height: 16) - - if suggestionSettings.isTemporarilyPaused || !suggestionSettings.isGloballyEnabled { - Image(systemName: "pause.fill") - .font(.system(size: 8, weight: .bold)) - .accessibilityLabel(inactiveAccessibilityLabel) - } - - if suggestionSettings.isMenuBarWordCountVisible, - let label = WordCountFormatter.compactLabel( - for: suggestionCoordinator.totalTabAcceptedWordCount - ) { - Text(label) - .font(.system(size: 10, weight: .medium).monospacedDigit()) - } - } - } - - /// VoiceOver needs the persistent global disable state distinguished from a temporary pause. - /// Global disable takes precedence when both states are present because it remains in effect - /// after the temporary pause is cleared. - private var inactiveAccessibilityLabel: String { - suggestionSettings.isGloballyEnabled ? "Cotabby paused" : "Cotabby disabled" - } -} diff --git a/Cotabby/UI/MenuBarView.swift b/Cotabby/UI/MenuBarView.swift index 9b306fe0..65f92652 100644 --- a/Cotabby/UI/MenuBarView.swift +++ b/Cotabby/UI/MenuBarView.swift @@ -23,13 +23,10 @@ struct MenuBarView: View { @ObservedObject var foundationModelAvailabilityService: FoundationModelAvailabilityService @ObservedObject var powerSourceMonitor: PowerSourceMonitor let appUpdateManager: AppUpdateManager + let onDismiss: () -> Void let onOpenSettings: () -> Void let onReportFeedback: () -> Void - /// Captures the popover's host window so `Button` actions that open another window can dismiss - /// the popover behind them. SwiftUI's `\.dismiss` does not work for `MenuBarExtra(.window)`. - @StateObject private var popoverDismisser = MenuBarPopoverDismisser() - var body: some View { VStack(alignment: .leading, spacing: 0) { headerSection @@ -47,12 +44,7 @@ struct MenuBarView: View { runtimeModel.refreshAvailableModels() } ) - .background( - MenuBarPopoverDismisserBinder( - dismisser: popoverDismisser, - onWindowBind: configureMenuBarWindowIfNeeded - ) - ) + .background(MenuBarWindowBinder(onWindowBind: configureMenuBarWindowIfNeeded)) .onAppear { permissionManager.refresh() runtimeModel.refreshAvailableModels() @@ -320,7 +312,7 @@ struct MenuBarView: View { Button("Settings") { // Dismiss the popover before opening the Settings window so the popover doesn't // remain on top of (and obscure) the Settings pane. See issue #455. - popoverDismisser.dismiss() + onDismiss() onOpenSettings() } .buttonStyle(.borderless) @@ -505,9 +497,9 @@ struct MenuBarView: View { /// Applies the menu panel's fill at the native window-container level when the OS supports it. /// -/// `MenuBarView` owns the menu contents, but SwiftUI owns the actual `NSWindow` created by -/// `MenuBarExtra`. Keeping this as a dedicated modifier gives the UI a narrow boundary for one -/// platform-specific presentation rule without mixing availability checks into the main view body. +/// `MenuBarView` owns the menu contents, while `MenuBarController` owns its `NSPopover` window. +/// Keeping this as a dedicated modifier gives the UI a narrow boundary for one platform-specific +/// presentation rule without mixing availability checks into the main view body. private struct MenuBarWindowBackgroundModifier: ViewModifier { private static let macOS26PopoverCornerRadius: CGFloat = 16 @@ -537,9 +529,9 @@ private struct MenuBarWindowBackgroundModifier: ViewModifier { .stroke(Color(nsColor: .separatorColor).opacity(0.7), lineWidth: 1) } } else if #available(macOS 15.0, *) { - // MenuBarExtra's `.window` style already gives us native rounded window chrome. Place - // the fill at the hosting window instead of this view's local bounds so it reaches the - // native rounded frame as one surface (avoids the double-border look fixed in #403). + // NSPopover gives us native rounded window chrome. Place the fill at the hosting window + // instead of this view's local bounds so it reaches the native rounded frame as one + // surface (avoids the double-border look fixed in #403). // The `.windowBackground` material renders correctly on macOS 15 through pre-26, so // keep it there to preserve the vibrant appearance and only patch the 26 regression. content.containerBackground(.windowBackground, for: .window) @@ -549,7 +541,7 @@ private struct MenuBarWindowBackgroundModifier: ViewModifier { } } -/// Configures the AppKit window behind `MenuBarExtra(.window)` on macOS 26. +/// Configures the AppKit window behind the menu-bar `NSPopover` on macOS 26. /// /// SwiftUI owns the menu contents, but the double-outline regression lives one layer above SwiftUI: /// macOS 26 gives the menu popover a larger non-opaque host window than the SwiftUI root view. A diff --git a/scripts/verify_macos_compatibility.py b/scripts/verify_macos_compatibility.py new file mode 100755 index 00000000..bce27d45 --- /dev/null +++ b/scripts/verify_macos_compatibility.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Verify that a built Cotabby bundle can load on the declared macOS baseline.""" + +from __future__ import annotations + +import argparse +import os +import plistlib +import re +import subprocess +import sys +from pathlib import Path + + +def run(*arguments: str) -> str: + result = subprocess.run(arguments, check=True, capture_output=True, text=True) + return result.stdout + + +def version_tuple(value: str) -> tuple[int, ...]: + return tuple(int(piece) for piece in value.split(".")) + + +def architectures(binary: Path) -> list[str]: + return run("/usr/bin/lipo", "-archs", str(binary)).strip().split() + + +def minimum_version(binary: Path, architecture: str) -> str: + output = run("/usr/bin/vtool", "-arch", architecture, "-show-build", str(binary)) + active_command: str | None = None + for raw_line in output.splitlines(): + line = raw_line.strip() + if line.startswith("cmd "): + active_command = line.removeprefix("cmd ") + continue + if active_command == "LC_BUILD_VERSION" and line.startswith("minos "): + return line.removeprefix("minos ").strip() + if active_command == "LC_VERSION_MIN_MACOSX" and line.startswith("version "): + return line.removeprefix("version ").strip() + raise RuntimeError(f"No macOS minimum-version load command in {binary} ({architecture})") + + +def is_macho(path: Path) -> bool: + if path.is_symlink() or not path.is_file(): + return False + output = run("/usr/bin/file", "-b", str(path)) + return "Mach-O" in output + + +def bundled_binaries(app_path: Path, main_binary: Path) -> list[Path]: + binaries = [main_binary] + frameworks = app_path / "Contents" / "Frameworks" + if not frameworks.exists(): + return binaries + + seen = {main_binary.resolve()} + for root, _, filenames in os.walk(frameworks): + for filename in filenames: + candidate = Path(root) / filename + if not is_macho(candidate): + continue + resolved = candidate.resolve() + if resolved in seen: + continue + seen.add(resolved) + binaries.append(candidate) + return binaries + + +def foundation_models_load_commands(binary: Path, architecture: str) -> list[str]: + output = run("/usr/bin/otool", "-l", "-arch", architecture, str(binary)) + active_command: str | None = None + commands: list[str] = [] + for raw_line in output.splitlines(): + line = raw_line.strip() + if line.startswith("cmd "): + active_command = line.removeprefix("cmd ") + continue + if line.startswith("name ") and "FoundationModels.framework" in line: + commands.append(active_command or "unknown") + return commands + + +def verify(app_path: Path, expected_minimum: str, require_universal: bool) -> None: + info_path = app_path / "Contents" / "Info.plist" + if not info_path.is_file(): + raise RuntimeError(f"Info.plist not found: {info_path}") + with info_path.open("rb") as handle: + info = plistlib.load(handle) + + plist_minimum = str(info.get("LSMinimumSystemVersion", "")) + if plist_minimum != expected_minimum: + raise RuntimeError( + f"LSMinimumSystemVersion is {plist_minimum!r}, expected {expected_minimum!r}" + ) + + executable = info.get("CFBundleExecutable") + if not executable: + raise RuntimeError("CFBundleExecutable is missing from Info.plist") + main_binary = app_path / "Contents" / "MacOS" / str(executable) + if not main_binary.is_file(): + raise RuntimeError(f"Main executable not found: {main_binary}") + + main_architectures = architectures(main_binary) + if require_universal and not {"arm64", "x86_64"}.issubset(main_architectures): + raise RuntimeError( + "Release executable must contain arm64 and x86_64; found " + + ", ".join(main_architectures) + ) + + expected_tuple = version_tuple(expected_minimum) + checked = 0 + for binary in bundled_binaries(app_path, main_binary): + for architecture in architectures(binary): + minimum = minimum_version(binary, architecture) + checked += 1 + if version_tuple(minimum) > expected_tuple: + relative = binary.relative_to(app_path) + raise RuntimeError( + f"{relative} ({architecture}) requires macOS {minimum}, " + f"above the supported {expected_minimum} baseline" + ) + + for architecture in main_architectures: + for command in foundation_models_load_commands(main_binary, architecture): + if command != "LC_LOAD_WEAK_DYLIB": + raise RuntimeError( + "FoundationModels must be weak-linked for macOS 14: " + f"{architecture} uses {command}" + ) + + print( + f"Verified {app_path}: LSMinimumSystemVersion={expected_minimum}, " + f"architectures={','.join(main_architectures)}, Mach-O slices checked={checked}" + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("app_path", type=Path) + parser.add_argument("--minimum", default="14.0") + parser.add_argument("--require-universal", action="store_true") + arguments = parser.parse_args() + + try: + verify(arguments.app_path, arguments.minimum, arguments.require_universal) + except (RuntimeError, subprocess.CalledProcessError, ValueError) as error: + print(f"macOS compatibility verification failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())