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
4 changes: 4 additions & 0 deletions Cotabby.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -288,6 +289,7 @@
54BC85605541E913EE57B258 /* ExtendedContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedContextTests.swift; sourceTree = "<group>"; };
54EF3C7F5D9D6F3FA50FD51C /* ContextBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextBuffer.swift; sourceTree = "<group>"; };
5664E34B23FBDF69292FEF43 /* FoundationModelSuggestionEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelSuggestionEngine.swift; sourceTree = "<group>"; };
5807E8508D9355D0271A00C5 /* LaunchAtLoginStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginStateTests.swift; sourceTree = "<group>"; };
58C0F017699EE44C81C095CA /* TagsInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsInputView.swift; sourceTree = "<group>"; };
5976600F428C1265121D4C0C /* SystemSettingsWindowLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemSettingsWindowLocator.swift; sourceTree = "<group>"; };
59E299BE2E9D42A33D5D2F5D /* ScreenTextExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenTextExtractor.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
23 changes: 18 additions & 5 deletions Cotabby/Services/Utilities/LaunchAtLoginService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -78,7 +91,7 @@ final class LaunchAtLoginService: ObservableObject {
lastErrorMessage = error.localizedDescription
}

refresh()
reloadState()
}

private static func map(_ status: SMAppService.Status) -> LaunchAtLoginState {
Expand Down
31 changes: 31 additions & 0 deletions Cotabby/UI/Settings/Panes/GeneralPaneView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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") {
Expand Down Expand Up @@ -274,6 +285,26 @@ struct GeneralPaneView: View {
)
}

private var launchAtLoginBinding: Binding<Bool> {
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<Bool> {
Binding(
get: { suggestionSettings.isMenuBarWordCountVisible },
Expand Down
1 change: 1 addition & 0 deletions Cotabby/UI/Settings/SettingsContainerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ struct SettingsContainerView: View {
case .general:
GeneralPaneView(
suggestionSettings: suggestionSettings,
launchAtLoginService: launchAtLoginService,
onShowWelcome: onShowWelcome
)
case .engineAndModel:
Expand Down
28 changes: 28 additions & 0 deletions CotabbyTests/LaunchAtLoginStateTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}