Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions .github/workflows/macos-compatibility.yml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 8 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
22 changes: 10 additions & 12 deletions Cotabby.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

41 changes: 41 additions & 0 deletions Cotabby/App/Core/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -228,6 +262,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
focusDebugOverlayController?.hide()
suggestionCoordinator.stop()
inlineCommandCoordinator.stop()
menuBarController.stop()
inputMonitor.stop()
focusModel.stop()

Expand Down Expand Up @@ -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")
}
}
74 changes: 17 additions & 57 deletions Cotabby/App/Core/CotabbyApp.swift
Original file line number Diff line number Diff line change
@@ -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<Bool> {
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()
}
}
4 changes: 2 additions & 2 deletions Cotabby/Models/SuggestionSettingsData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading