From 10654690c7fe3a5c9624c63451f22d9a58e94508 Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:51:47 -0700 Subject: [PATCH] Revert "Fix launch compatibility on macOS 14 and 15 (#785)" This reverts commit 41323acaabf2cb482ac1a298be0d6efe0145b54c. --- .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, 243 insertions(+), 601 deletions(-) delete mode 100644 .github/workflows/macos-compatibility.yml delete mode 100644 Cotabby/Services/UI/MenuBarController.swift create mode 100644 Cotabby/UI/MenuBarPopoverDismisser.swift create mode 100644 Cotabby/UI/MenuBarStatusLabelView.swift delete mode 100755 scripts/verify_macos_compatibility.py diff --git a/.github/workflows/macos-compatibility.yml b/.github/workflows/macos-compatibility.yml deleted file mode 100644 index 1dac6696..00000000 --- a/.github/workflows/macos-compatibility.yml +++ /dev/null @@ -1,88 +0,0 @@ -# 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 d3b80060..f640b197 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -319,7 +319,8 @@ jobs: CODE_SIGN_IDENTITY="Developer ID Application" \ OTHER_CODE_SIGN_FLAGS="--timestamp" \ MARKETING_VERSION="${RELEASE_VERSION}" \ - CURRENT_PROJECT_VERSION="${BUILD_NUMBER}" + CURRENT_PROJECT_VERSION="${BUILD_NUMBER}" \ + archive - name: Verify build number in archive env: @@ -336,13 +337,6 @@ 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 d3fcc8fe..c053d0a3 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -46,6 +46,7 @@ 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 */; }; @@ -53,6 +54,7 @@ 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 */; }; @@ -74,7 +76,6 @@ 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 */; }; @@ -247,6 +248,7 @@ 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 */; }; @@ -326,7 +328,6 @@ 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 */; }; @@ -500,6 +501,7 @@ 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 */; }; @@ -642,7 +644,6 @@ 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 */; }; @@ -835,6 +836,7 @@ 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 = ""; }; @@ -1015,7 +1017,6 @@ 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 = ""; }; @@ -1034,6 +1035,7 @@ 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 = ""; }; @@ -1134,7 +1136,6 @@ 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 = ""; }; @@ -1564,7 +1565,6 @@ 03766F6253FF17639230C0F6 /* ModelAndPresentationValueTests.swift */, A829F28F01FAE76CA7244BBC /* ModelFileValidatorTests.swift */, 5EED3CD2BC7B48DF35DEE562 /* OCRTextHygieneTests.swift */, - B4660F0FA8500594314C6D5C /* OllamaPreloadWorkControllerTests.swift */, A4B220D9A25174A43E7A258B /* OnboardingFlowStepTests.swift */, D814BBA41CF29E8DD9954651 /* OnboardingTemplateFeatureListTests.swift */, 01B72736E416910878E8E493 /* OnboardingTemplateRecommenderTests.swift */, @@ -1680,8 +1680,10 @@ 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 */, @@ -1723,7 +1725,6 @@ 764659D09C3F0E8FBD267102 /* EmojiPickerPanelController.swift */, 0F5E263AB69029D5E13D5EE8 /* FocusDebugOverlayController.swift */, AAEFF8BAEC9168CADEDD4757 /* InlinePreviewPanelController.swift */, - F2F4CB1434ED845578588AD9 /* MenuBarController.swift */, F308F6E274CC645E27CB651F /* OverlayController.swift */, ); path = UI; @@ -2176,10 +2177,11 @@ 323500F336AF70C520926383 /* MacroReferenceSheet.swift in Sources */, 6503E1585D0CDE8CD852144B /* MacroTriggerStateMachine.swift in Sources */, 057CEA7858012C1501F1785C /* MarkerSelectionSynthesizer.swift in Sources */, - E50EF4A442A9EFFB7904600D /* MenuBarController.swift in Sources */, + 4FC42808D61BF110425801ED /* MenuBarPopoverDismisser.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 */, @@ -2431,10 +2433,11 @@ 0B378201616831626DEC3AB6 /* MacroReferenceSheet.swift in Sources */, 3FF6B7DE34A01C4AB7FA54E3 /* MacroTriggerStateMachine.swift in Sources */, 5C119807B84F84B0B1B1C2D5 /* MarkerSelectionSynthesizer.swift in Sources */, - 17778ECC847014706A6F220D /* MenuBarController.swift in Sources */, + 0BEBB33EB75B59EE83C6FE44 /* MenuBarPopoverDismisser.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 */, @@ -2648,7 +2651,6 @@ 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 a2db20aa..4e755b4a 100644 --- a/Cotabby/App/Core/AppDelegate.swift +++ b/Cotabby/App/Core/AppDelegate.swift @@ -32,28 +32,6 @@ 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 @@ -153,23 +131,11 @@ 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)") @@ -262,7 +228,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { focusDebugOverlayController?.hide() suggestionCoordinator.stop() inlineCommandCoordinator.stop() - menuBarController.stop() inputMonitor.stop() focusModel.stop() @@ -320,10 +285,4 @@ 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 d5c6f8a3..9fc9303c 100644 --- a/Cotabby/App/Core/CotabbyApp.swift +++ b/Cotabby/App/Core/CotabbyApp.swift @@ -1,24 +1,64 @@ -import AppKit +import SwiftUI /// File overview: -/// 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). +/// 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`. /// -/// The enum has no instances. Its one static delegate reference keeps the weak `NSApplication` -/// delegate alive until the run loop exits. +/// `@main` marks the single process entry point for a Swift app. @main -enum CotabbyApp { - private static var appDelegate: AppDelegate? +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 - @MainActor - static func main() { - let application = NSApplication.shared - let delegate = AppDelegate() - appDelegate = delegate - application.delegate = delegate - application.run() + /// 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) } + ) } } diff --git a/Cotabby/Models/SuggestionSettingsData.swift b/Cotabby/Models/SuggestionSettingsData.swift index b95671da..3d86138c 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 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. + /// 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. var isMenuBarIconVisible: Bool var isMenuBarWordCountVisible: Bool var mirrorPreference: MirrorPreference diff --git a/Cotabby/Services/UI/MenuBarController.swift b/Cotabby/Services/UI/MenuBarController.swift deleted file mode 100644 index 5cfe67c3..00000000 --- a/Cotabby/Services/UI/MenuBarController.swift +++ /dev/null @@ -1,238 +0,0 @@ -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 a59bda1d..045e0e48 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" - /// Observed by `MenuBarController`, which inserts or removes the AppKit status item when the - /// settings model writes this preference. + /// Shared with `CotabbyApp` because SwiftUI's scene-level `@AppStorage` is what invalidates the + /// `MenuBarExtra` insertion binding 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 new file mode 100644 index 00000000..db45d74f --- /dev/null +++ b/Cotabby/UI/MenuBarPopoverDismisser.swift @@ -0,0 +1,102 @@ +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 18f3ae9a..6f91d57a 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 the hosted SwiftUI menu-bar panel. +/// Observes the AppKit window that backs SwiftUI's menu-bar panel. /// -/// `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. +/// `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. struct MenuBarPresentationObserver: NSViewRepresentable { let onPresent: () -> Void @@ -86,30 +86,3 @@ 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 new file mode 100644 index 00000000..250b352a --- /dev/null +++ b/Cotabby/UI/MenuBarStatusLabelView.swift @@ -0,0 +1,44 @@ +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 65f92652..9b306fe0 100644 --- a/Cotabby/UI/MenuBarView.swift +++ b/Cotabby/UI/MenuBarView.swift @@ -23,10 +23,13 @@ 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 @@ -44,7 +47,12 @@ struct MenuBarView: View { runtimeModel.refreshAvailableModels() } ) - .background(MenuBarWindowBinder(onWindowBind: configureMenuBarWindowIfNeeded)) + .background( + MenuBarPopoverDismisserBinder( + dismisser: popoverDismisser, + onWindowBind: configureMenuBarWindowIfNeeded + ) + ) .onAppear { permissionManager.refresh() runtimeModel.refreshAvailableModels() @@ -312,7 +320,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. - onDismiss() + popoverDismisser.dismiss() onOpenSettings() } .buttonStyle(.borderless) @@ -497,9 +505,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, 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. +/// `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. private struct MenuBarWindowBackgroundModifier: ViewModifier { private static let macOS26PopoverCornerRadius: CGFloat = 16 @@ -529,9 +537,9 @@ private struct MenuBarWindowBackgroundModifier: ViewModifier { .stroke(Color(nsColor: .separatorColor).opacity(0.7), lineWidth: 1) } } else if #available(macOS 15.0, *) { - // 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). + // 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). // 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) @@ -541,7 +549,7 @@ private struct MenuBarWindowBackgroundModifier: ViewModifier { } } -/// Configures the AppKit window behind the menu-bar `NSPopover` on macOS 26. +/// Configures the AppKit window behind `MenuBarExtra(.window)` 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 deleted file mode 100755 index bce27d45..00000000 --- a/scripts/verify_macos_compatibility.py +++ /dev/null @@ -1,154 +0,0 @@ -#!/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())