Skip to content
Merged
10 changes: 10 additions & 0 deletions Cotabby.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,7 @@
96498E097A5899AFC9F0C853 /* EmojiCatalogMatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 292DC9D4D9D5D26AE882E39B /* EmojiCatalogMatcherTests.swift */; };
96782E57CA26A16409368B69 /* TextDirectionDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 328847A0F494360033366791 /* TextDirectionDetector.swift */; };
96C3128BCB17A05A7C7DEFF7 /* StaticTextRunWalkThrottleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B3179B40A81DF121D1221C6 /* StaticTextRunWalkThrottleTests.swift */; };
96ECE89BD96CF93CC159B437 /* MenuBarRecoveryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F32455AE743D1D0E9E2CB38 /* MenuBarRecoveryPolicy.swift */; };
9706D778FB549E9E7AE05F4F /* EmojiMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1441B2D89DAE6878DAD11F17 /* EmojiMatcher.swift */; };
97ECEF5AA17B26FA6F187461 /* FocusCapabilityFlickerGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A44BEC8C23FF227731DD0CD /* FocusCapabilityFlickerGate.swift */; };
97EF76E6B7A1AFB3FA4879D1 /* LGPL-3.0.txt in Resources */ = {isa = PBXBuildFile; fileRef = 7513810E78F3C94FE972EB07 /* LGPL-3.0.txt */; };
Expand Down Expand Up @@ -570,6 +571,7 @@
CC98B842D10574C5206BEFA7 /* FocusCapabilityResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70367FCC1E0F08EE3B8EB26F /* FocusCapabilityResolver.swift */; };
CCB8D287A5FF3863B9DE9246 /* ChromiumAccessibilityEnabler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8896D976C7F116EBA0F3969F /* ChromiumAccessibilityEnabler.swift */; };
CCC83DC5AE51C17F153D5A6A /* PermissionModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D82FFC568527700EC17C07D /* PermissionModels.swift */; };
CE20BDEA713D21200B8822C7 /* MenuBarRecoveryPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39366D6D7D5946BF9330D038 /* MenuBarRecoveryPolicyTests.swift */; };
CF2EADEEEF5AA63FB9B9EA8E /* SuggestionInserter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A3D1125B962CBE0269EEDDB /* SuggestionInserter.swift */; };
CF39EB76C3ECF8F764C1B4FB /* DeviceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29ED42C4BDD0C521101AF95E /* DeviceInfo.swift */; };
CF4205B85D881B8176590D25 /* FocusSnapshotResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E25414C307A20B6F9F20EC /* FocusSnapshotResolver.swift */; };
Expand Down Expand Up @@ -688,6 +690,7 @@
FEF2CF888D8709D1FB0D2B20 /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 6F27073D2818C0218C3F4370 /* Logging */; };
FF3F7B74B561EF0807D28FD8 /* SystemMetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 807148A920E003DEF8BA6092 /* SystemMetricsStore.swift */; };
FF773F168B20502C68239967 /* SurfaceContextComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C602357DDED5D11C8B4567FB /* SurfaceContextComposer.swift */; };
FFF5215EAC4A931CCD0C3142 /* MenuBarRecoveryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F32455AE743D1D0E9E2CB38 /* MenuBarRecoveryPolicy.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -754,6 +757,7 @@
1D1AC5EC06664F49A6AE2B17 /* SettingsNavigationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsNavigationModel.swift; sourceTree = "<group>"; };
1E0513E3B23937B099A3CFF2 /* WordCountFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordCountFormatterTests.swift; sourceTree = "<group>"; };
1ED1EA9282E0AC7592E60889 /* SuggestionCoordinator+Prediction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+Prediction.swift"; sourceTree = "<group>"; };
1F32455AE743D1D0E9E2CB38 /* MenuBarRecoveryPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarRecoveryPolicy.swift; sourceTree = "<group>"; };
1F761083EA5465023D82B5F4 /* BrowserDomainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserDomainTests.swift; sourceTree = "<group>"; };
1F7ED2D9D71CFAD9A30977E9 /* ArithmeticEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArithmeticEvaluator.swift; sourceTree = "<group>"; };
210F9AD332273FE2EB3A9A01 /* WebContentFieldDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebContentFieldDetectorTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -801,6 +805,7 @@
384FBCF5D7A3A446C5BE2B8D /* SuggestionEngineRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionEngineRouter.swift; sourceTree = "<group>"; };
386C98FFCF76EC1C8C7E82BB /* SuggestionModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionModels.swift; sourceTree = "<group>"; };
38931C165873B50B405CC602 /* SystemMetricsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMetricsStoreTests.swift; sourceTree = "<group>"; };
39366D6D7D5946BF9330D038 /* MenuBarRecoveryPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarRecoveryPolicyTests.swift; sourceTree = "<group>"; };
3BDA36955CCCFA87C1F67268 /* SuggestionEngineRouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionEngineRouterTests.swift; sourceTree = "<group>"; };
3DE1975F3B5F4A70478DBF41 /* DownloadOutcomeClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadOutcomeClassifier.swift; sourceTree = "<group>"; };
41BBD5A4BA08CABE77860886 /* HardwareCapabilityProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardwareCapabilityProbe.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1520,6 +1525,7 @@
D8083D44ABCDCFA68A4CD497 /* MacroEngineTests.swift */,
22BE47D1DBF6C23151458836 /* MacroTriggerStateMachineTests.swift */,
52BAFA2F989C3C4F7FB892B5 /* MarkerSelectionSynthesizerTests.swift */,
39366D6D7D5946BF9330D038 /* MenuBarRecoveryPolicyTests.swift */,
1274F897631B1B3A835D157F /* MidWordContinuationPolicyTests.swift */,
FC83D14A7557BC0196E59007 /* MirrorOverlayLayoutTests.swift */,
03766F6253FF17639230C0F6 /* ModelAndPresentationValueTests.swift */,
Expand Down Expand Up @@ -1757,6 +1763,7 @@
8D610FCA3A97249DCCE7D0B8 /* LLMIOFileHandler.swift */,
1C201A65A6B040F90C528A3B /* MacroTriggerStateMachine.swift */,
A863F41C0C03D7B4AC5DC002 /* MarkerSelectionSynthesizer.swift */,
1F32455AE743D1D0E9E2CB38 /* MenuBarRecoveryPolicy.swift */,
357C18383B047F24A531BDCD /* MidWordContinuationPolicy.swift */,
54150A507B03221F137D539B /* MirrorOverlayLayout.swift */,
B22FDEB3B1DCC9ADE906CC73 /* OCRTextHygiene.swift */,
Expand Down Expand Up @@ -2129,6 +2136,7 @@
057CEA7858012C1501F1785C /* MarkerSelectionSynthesizer.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 */,
Expand Down Expand Up @@ -2376,6 +2384,7 @@
5C119807B84F84B0B1B1C2D5 /* MarkerSelectionSynthesizer.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 */,
Expand Down Expand Up @@ -2578,6 +2587,7 @@
8429B116328C392DCA018D95 /* MacroEngineTests.swift in Sources */,
3F8CBCBCC45E377DF9ADB216 /* MacroTriggerStateMachineTests.swift in Sources */,
87806DE08881D11F2608A13D /* MarkerSelectionSynthesizerTests.swift in Sources */,
CE20BDEA713D21200B8822C7 /* MenuBarRecoveryPolicyTests.swift in Sources */,
7C36DBA762E19C8C31676D44 /* MidWordContinuationPolicyTests.swift in Sources */,
14D77F0B8A195AC2FA8D24A9 /* MirrorOverlayLayoutTests.swift in Sources */,
25D4FC8D191A50F63E6391F9 /* ModelAndPresentationValueTests.swift in Sources */,
Expand Down
10 changes: 9 additions & 1 deletion Cotabby/App/Coordinators/SettingsCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate {

private var settingsWindowController: NSWindowController?

/// Whether this coordinator currently owns an open Settings window. This intentionally means
/// "created and not closed" rather than `isVisible`: AppKit reports miniaturized windows as
/// visible during reopen handling, and should be allowed to restore those normally.
var isSettingsWindowOpen: Bool {
settingsWindowController?.window != nil
}

init(
appUpdateManager: AppUpdateManager,
permissionManager: PermissionManager,
Expand Down Expand Up @@ -82,7 +89,8 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate {
qualityMetricsStore: qualityMetricsStore,
systemMetricsStore: systemMetricsStore,
onShowWelcome: onShowWelcome,
clearEmojiHistory: clearEmojiHistory
clearEmojiHistory: clearEmojiHistory,
onQuit: { NSApplication.shared.terminate(nil) }
)
)
)
Expand Down
41 changes: 37 additions & 4 deletions Cotabby/App/Core/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,14 +141,47 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
didStartServices = true
CotabbyLogger.app.info("All services started")

// Dev affordance in the spirit of `-cotabby-debug`: a menu-bar-only app has no scriptable
// path to its Settings window (the status item is unreachable from AppleScript), so UI
// work on Settings cannot be exercised by tooling without this. No-op unless passed.
if ProcessInfo.processInfo.arguments.contains("-cotabby-open-settings") {
// A manual cold launch must remain recoverable after the user hides the only status item.
// Login-item launches stay silent so hiding the icon does not make Settings appear after
// every sign-in. The development argument deliberately overrides both policies.
let wasSettingsExplicitlyRequested =
ProcessInfo.processInfo.arguments.contains("-cotabby-open-settings")
let shouldShowSettings = MenuBarRecoveryPolicy.shouldShowSettingsOnColdLaunch(
isMenuBarIconVisible: suggestionSettings.isMenuBarIconVisible,
wasLaunchedAtLogin: LaunchAtLogin.wasLaunchedAtLogin,
wasSettingsExplicitlyRequested: wasSettingsExplicitlyRequested
)
if shouldShowSettings {
if !suggestionSettings.isMenuBarIconVisible && !wasSettingsExplicitlyRequested {
CotabbyLogger.app.info(
"Opening Settings because Cotabby launched with its menu bar icon hidden"
)
}
settingsCoordinator.showSettings()
}
}

/// Launch Services sends a reopen event when the user opens Cotabby while this accessory app is
/// already running. When the status item is hidden, Settings is the app's only visible recovery
/// surface, so reopening must reveal it instead of leaving the user with no apparent response.
func applicationShouldHandleReopen(
_ sender: NSApplication,
Comment thread
akramj13 marked this conversation as resolved.
hasVisibleWindows flag: Bool
) -> Bool {
if MenuBarRecoveryPolicy.shouldLetAppKitHandleReopen(
isMenuBarIconVisible: suggestionSettings.isMenuBarIconVisible,
hasVisibleWindows: flag,
isSettingsWindowOpen: settingsCoordinator.isSettingsWindowOpen
) {
// Let AppKit activate the app and bring the existing Settings window forward.
return true
}

CotabbyLogger.app.info("Opening Settings because Cotabby was reopened with its menu bar icon hidden")
settingsCoordinator.showSettings()
return false
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

/// One-time default: enable Open at Login for every user (new and existing) the first time this
/// build runs. The applied-flag persists the decision so any later opt-out the user makes is
/// respected on subsequent launches and we only ever flip the toggle once per user.
Expand Down
21 changes: 19 additions & 2 deletions Cotabby/App/Core/CotabbyApp.swift
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import SwiftUI

/// File overview:
/// Declares the SwiftUI app entry point and hosts the single menu-bar scene that renders
/// 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`.
///
/// `@main` marks the single process entry point for a Swift app.
@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

/// Defines the menu bar extra that surfaces Cotabby's runtime, focus, and suggestion state.
var body: some Scene {
MenuBarExtra {
MenuBarExtra(isInserted: menuBarIconVisibilityBinding) {
MenuBarView(
permissionManager: appDelegate.permissionManager,
runtimeModel: appDelegate.runtimeModel,
Expand Down Expand Up @@ -44,4 +49,16 @@ struct CotabbyApp: App {
}
.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) }
)
}
}
3 changes: 3 additions & 0 deletions Cotabby/Models/SuggestionSettingsData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ 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.
var isMenuBarIconVisible: Bool
var isMenuBarWordCountVisible: Bool
var mirrorPreference: MirrorPreference
var userName: String
Expand Down
16 changes: 16 additions & 0 deletions Cotabby/Models/SuggestionSettingsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ final class SuggestionSettingsModel: ObservableObject {
/// default user never pays any extra storage or write cost — recording only kicks in once the
/// user opts in from Settings.
@Published private(set) var isPerformanceTrackingEnabled: Bool
/// Whether Cotabby's status item is inserted into the menu bar. The process and suggestion
/// pipeline remain active when hidden; launching the app again opens Settings as the recovery path.
@Published private(set) var isMenuBarIconVisible: Bool
/// Whether the accepted-word counter is drawn next to the menu bar icon. Off hides the badge
/// entirely; the count itself keeps accruing so toggling it back on restores the running total.
@Published private(set) var isMenuBarWordCountVisible: Bool
Expand Down Expand Up @@ -190,6 +193,7 @@ final class SuggestionSettingsModel: ObservableObject {
enabledSpellingDictionaryCodes = data.enabledSpellingDictionaryCodes
automaticallyFixTypos = data.automaticallyFixTypos
isPerformanceTrackingEnabled = data.isPerformanceTrackingEnabled
isMenuBarIconVisible = data.isMenuBarIconVisible
isMenuBarWordCountVisible = data.isMenuBarWordCountVisible
mirrorPreference = data.mirrorPreference
userName = data.userName
Expand All @@ -206,6 +210,8 @@ final class SuggestionSettingsModel: ObservableObject {
autoAcceptTrailingPunctuation = data.autoAcceptTrailingPunctuation
addSpaceAfterAccept = data.addSpaceAfterAccept
streamSuggestionsWhileGenerating = data.streamSuggestionsWhileGenerating
fadeInSuggestions = data.fadeInSuggestions
fadeInDurationSeconds = data.fadeInDurationSeconds
acceptanceKeyCode = data.acceptanceKeyCode
acceptanceKeyModifiers = data.acceptanceKeyModifiers
acceptanceKeyLabel = data.acceptanceKeyLabel
Expand Down Expand Up @@ -255,6 +261,7 @@ final class SuggestionSettingsModel: ObservableObject {
enabledSpellingDictionaryCodes = data.enabledSpellingDictionaryCodes
automaticallyFixTypos = data.automaticallyFixTypos
isPerformanceTrackingEnabled = data.isPerformanceTrackingEnabled
isMenuBarIconVisible = data.isMenuBarIconVisible
isMenuBarWordCountVisible = data.isMenuBarWordCountVisible
mirrorPreference = data.mirrorPreference
userName = data.userName
Expand Down Expand Up @@ -544,6 +551,15 @@ final class SuggestionSettingsModel: ObservableObject {
store.savePerformanceTrackingEnabled(enabled)
}

func setMenuBarIconVisible(_ visible: Bool) {
guard isMenuBarIconVisible != visible else {
return
}

isMenuBarIconVisible = visible
store.saveMenuBarIconVisible(visible)
}

func setMenuBarWordCountVisible(_ visible: Bool) {
guard isMenuBarWordCountVisible != visible else {
return
Expand Down
29 changes: 29 additions & 0 deletions Cotabby/Support/MenuBarRecoveryPolicy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Foundation

/// Decides whether a cold launch needs to reveal Settings as a recovery surface.
///
/// This pure policy lives in `Support/` so AppKit launch details stay in `AppDelegate`, while the
/// product rule remains deterministic and unit-testable. Manual launches recover users who hid the
/// status item; login-item launches remain background-only; the development override always wins.
enum MenuBarRecoveryPolicy {
static func shouldShowSettingsOnColdLaunch(
isMenuBarIconVisible: Bool,
wasLaunchedAtLogin: Bool,
wasSettingsExplicitlyRequested: Bool
) -> Bool {
wasSettingsExplicitlyRequested
|| (!isMenuBarIconVisible && !wasLaunchedAtLogin)
}

/// Lets AppKit perform its normal reopen work when the status item is available, or when the app
/// already has visible windows and the Settings window exists (so AppKit can activate/restore it).
/// If only a non-Settings window is visible, Cotabby still opens Settings as the recovery surface.
static func shouldLetAppKitHandleReopen(
isMenuBarIconVisible: Bool,
hasVisibleWindows: Bool,
isSettingsWindowOpen: Bool
) -> Bool {
isMenuBarIconVisible
|| (hasVisibleWindows && isSettingsWindowOpen)
}
}
Loading
Loading