diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 1a5991fc..7864c7a9 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -189,6 +189,7 @@ DE236C9285635C686D66A2F6 /* TerminalAppDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43E37A7E835D3BDE6265843C /* TerminalAppDetectorTests.swift */; }; DF8794793110A8ED234CBA96 /* OnboardingTemplateRecommender.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA878B447441BB4F3E327CC8 /* OnboardingTemplateRecommender.swift */; }; E17CAA453B1F534D284F0D89 /* PermissionHostApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6ACCB12E4DB32D2F2BEA567 /* PermissionHostApp.swift */; }; + E27E6377D36D4981301568DD /* LaunchAtLoginStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5807E8508D9355D0271A00C5 /* LaunchAtLoginStateTests.swift */; }; E313639E71AE1374D2B9A956 /* SuggestionWorkController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B2D97BAA3618A7D0357AC44 /* SuggestionWorkController.swift */; }; E51FA12B690428CA431328FC /* WritingPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48B95B6665109B6C6A63B42 /* WritingPaneView.swift */; }; E6EE3C13FA31F261CD734C69 /* DownloadOutcomeClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE1975F3B5F4A70478DBF41 /* DownloadOutcomeClassifier.swift */; }; @@ -288,6 +289,7 @@ 54BC85605541E913EE57B258 /* ExtendedContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedContextTests.swift; sourceTree = ""; }; 54EF3C7F5D9D6F3FA50FD51C /* ContextBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextBuffer.swift; sourceTree = ""; }; 5664E34B23FBDF69292FEF43 /* FoundationModelSuggestionEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelSuggestionEngine.swift; sourceTree = ""; }; + 5807E8508D9355D0271A00C5 /* LaunchAtLoginStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginStateTests.swift; sourceTree = ""; }; 58C0F017699EE44C81C095CA /* TagsInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsInputView.swift; sourceTree = ""; }; 5976600F428C1265121D4C0C /* SystemSettingsWindowLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemSettingsWindowLocator.swift; sourceTree = ""; }; 59E299BE2E9D42A33D5D2F5D /* ScreenTextExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenTextExtractor.swift; sourceTree = ""; }; @@ -664,6 +666,7 @@ 5AD3F4F9FBE82007E4E15F58 /* GhostSuggestionLayoutTests.swift */, BAC01317B0B68E3C4125E421 /* InputMonitorTests.swift */, 4793D4EA5D36D7E5CC216C27 /* LanguageSupportTests.swift */, + 5807E8508D9355D0271A00C5 /* LaunchAtLoginStateTests.swift */, 3009812A35A1CDEF16295AB7 /* LlamaPromptRendererTests.swift */, 52BAFA2F989C3C4F7FB892B5 /* MarkerSelectionSynthesizerTests.swift */, FC83D14A7557BC0196E59007 /* MirrorOverlayLayoutTests.swift */, @@ -1126,6 +1129,7 @@ A0BB87E3665EF6C209034798 /* GhostSuggestionLayoutTests.swift in Sources */, 07D046D406411ED85AC5758A /* InputMonitorTests.swift in Sources */, E912D4617AE1376061DF1F00 /* LanguageSupportTests.swift in Sources */, + E27E6377D36D4981301568DD /* LaunchAtLoginStateTests.swift in Sources */, 190C571B3CDFE117F4D15484 /* LlamaPromptRendererTests.swift in Sources */, 87806DE08881D11F2608A13D /* MarkerSelectionSynthesizerTests.swift in Sources */, 14D77F0B8A195AC2FA8D24A9 /* MirrorOverlayLayoutTests.swift in Sources */, diff --git a/Cotabby/Services/Utilities/LaunchAtLoginService.swift b/Cotabby/Services/Utilities/LaunchAtLoginService.swift index f228bf9c..a08933b4 100644 --- a/Cotabby/Services/Utilities/LaunchAtLoginService.swift +++ b/Cotabby/Services/Utilities/LaunchAtLoginService.swift @@ -56,15 +56,28 @@ final class LaunchAtLoginService: ObservableObject { state = Self.map(appService.status) } - /// Re-reads the current login-item status from macOS. - /// This lets the Settings UI reflect out-of-band changes made in System Settings. + /// Re-reads the login-item status from macOS for an out-of-band refresh (e.g. the Settings + /// window reopened after the user changed the item in System Settings). + /// + /// Clears any prior `lastErrorMessage` first: that message described an *earlier* toggle attempt, + /// and the freshly read status is now authoritative. Without this, a one-time failure (such as + /// registering while outside /Applications) would keep showing as the row's subtext even after + /// the user fixes the cause and macOS reports the item enabled or cleanly disabled. func refresh() { + lastErrorMessage = nil + reloadState() + } + + /// Re-reads status from macOS *without* touching `lastErrorMessage`. `setEnabled` uses this so a + /// failure it just captured survives the post-mutation re-read — routing through `refresh()` + /// would wipe the error before the UI could explain why the toggle did not take effect. + private func reloadState() { state = Self.map(appService.status) } /// Registers or unregisters the main app as a login item. - /// We immediately refresh after mutation so the UI reflects the actual OS state rather than - /// assuming the request succeeded. + /// We re-read OS state after mutation (rather than assuming the request succeeded) via + /// `reloadState()` — deliberately not `refresh()`, so a freshly captured error is preserved. func setEnabled(_ enabled: Bool) { do { if enabled { @@ -78,7 +91,7 @@ final class LaunchAtLoginService: ObservableObject { lastErrorMessage = error.localizedDescription } - refresh() + reloadState() } private static func map(_ status: SMAppService.Status) -> LaunchAtLoginState { diff --git a/Cotabby/UI/Settings/Panes/GeneralPaneView.swift b/Cotabby/UI/Settings/Panes/GeneralPaneView.swift index 206edd67..0bdbf638 100644 --- a/Cotabby/UI/Settings/Panes/GeneralPaneView.swift +++ b/Cotabby/UI/Settings/Panes/GeneralPaneView.swift @@ -8,6 +8,7 @@ import SwiftUI /// menu-bar quick control so users can connect the two. struct GeneralPaneView: View { @ObservedObject var suggestionSettings: SuggestionSettingsModel + @ObservedObject var launchAtLoginService: LaunchAtLoginService let onShowWelcome: () -> Void @Environment(\.colorScheme) private var colorScheme @@ -29,6 +30,16 @@ struct GeneralPaneView: View { "Suggestions rely only on the text you've typed." ) } + + Toggle(isOn: launchAtLoginBinding) { + SettingsRowLabel( + title: "Open at Login", + description: launchAtLoginDescription + ) + } + // Disabled only when macOS reports the login item as unavailable (e.g. the app isn't + // in /Applications); the description then explains how to fix it. + .disabled(!launchAtLoginService.state.canToggle) } Section("Behavior") { @@ -274,6 +285,26 @@ struct GeneralPaneView: View { ) } + private var launchAtLoginBinding: Binding { + Binding( + get: { launchAtLoginService.state.isEnabled }, + set: { launchAtLoginService.setEnabled($0) } + ) + } + + /// An enabled login item has nothing to explain, so never surface a leftover detail/error string + /// under a working toggle. Otherwise prefer the OS-provided status detail (approval needed / move + /// to Applications), then any registration error from the most recent toggle attempt, falling back + /// to the plain explanation — so the row reflects real login-item state, not just the switch. + private var launchAtLoginDescription: String { + if launchAtLoginService.state.isEnabled { + return "Start Cotabby automatically when you log in to your Mac." + } + return launchAtLoginService.state.detail + ?? launchAtLoginService.lastErrorMessage + ?? "Start Cotabby automatically when you log in to your Mac." + } + private var menuBarWordCountVisibleBinding: Binding { Binding( get: { suggestionSettings.isMenuBarWordCountVisible }, diff --git a/Cotabby/UI/Settings/SettingsContainerView.swift b/Cotabby/UI/Settings/SettingsContainerView.swift index ae31c518..24d077c6 100644 --- a/Cotabby/UI/Settings/SettingsContainerView.swift +++ b/Cotabby/UI/Settings/SettingsContainerView.swift @@ -97,6 +97,7 @@ struct SettingsContainerView: View { case .general: GeneralPaneView( suggestionSettings: suggestionSettings, + launchAtLoginService: launchAtLoginService, onShowWelcome: onShowWelcome ) case .engineAndModel: diff --git a/CotabbyTests/LaunchAtLoginStateTests.swift b/CotabbyTests/LaunchAtLoginStateTests.swift new file mode 100644 index 00000000..036aee6d --- /dev/null +++ b/CotabbyTests/LaunchAtLoginStateTests.swift @@ -0,0 +1,28 @@ +import XCTest +@testable import Cotabby + +/// Tests the pure presentation logic of `LaunchAtLoginState` — the flags the General settings row +/// uses to decide the toggle position, whether it is interactive, and what explanatory copy to show. +/// The OS registration itself (`SMAppService`) is not exercised here; only the state mapping is. +final class LaunchAtLoginStateTests: XCTestCase { + func testIsEnabledOnlyForEnabled() { + XCTAssertTrue(LaunchAtLoginState.enabled.isEnabled) + XCTAssertFalse(LaunchAtLoginState.disabled.isEnabled) + XCTAssertFalse(LaunchAtLoginState.requiresApproval.isEnabled) + XCTAssertFalse(LaunchAtLoginState.unavailable("x").isEnabled) + } + + func testCanToggleForEveryStateExceptUnavailable() { + XCTAssertTrue(LaunchAtLoginState.enabled.canToggle) + XCTAssertTrue(LaunchAtLoginState.disabled.canToggle) + XCTAssertTrue(LaunchAtLoginState.requiresApproval.canToggle) + XCTAssertFalse(LaunchAtLoginState.unavailable("x").canToggle) + } + + func testDetailOnlyForNonTrivialStates() { + XCTAssertNil(LaunchAtLoginState.enabled.detail) + XCTAssertNil(LaunchAtLoginState.disabled.detail) + XCTAssertNotNil(LaunchAtLoginState.requiresApproval.detail) + XCTAssertEqual(LaunchAtLoginState.unavailable("Move me").detail, "Move me") + } +}