Skip to content
Closed
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
101 changes: 63 additions & 38 deletions Cotabby/App/Core/CotabbyAppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -288,54 +288,79 @@ final class CotabbyAppEnvironment {
}
.store(in: &cancellables)

observePowerSourceModelSwitching(
powerSourceMonitor: powerSourceMonitor,
runtimeModel: runtimeModel,
suggestionSettings: suggestionSettings
)
observePowerSourceProfileSwitching()
}

/// Switches the runtime model when the power source changes, if the user opted into power-based
/// switching. The guards bail early when the feature is off, the target model is unset or no
/// longer installed, or it is already selected, so the only side effect is a deliberate reload on
/// a genuine power transition. Extracted from `init` to keep the initializer's complexity bounded.
private func observePowerSourceModelSwitching(
powerSourceMonitor: PowerSourceMonitor,
runtimeModel: RuntimeBootstrapModel,
suggestionSettings: SuggestionSettingsModel
) {
powerSourceMonitor.$isPluggedIn
.removeDuplicates()
.sink { [weak runtimeModel, weak suggestionSettings] isPluggedIn in
guard let runtimeModel,
let suggestionSettings else {
return
}
/// Applies the user's per-power-source profile (engine + model) whenever anything that could
/// change the right answer changes: the power source, the feature toggle, either profile, or the
/// installed-model list (so a profile referencing a still-loading model is honored once it
/// appears). The apply step is idempotent (`selectEngine`/`selectModel` no-op when already
/// current), so the redundant values `@Published` replays on subscription are harmless.
/// Extracted from `init` to keep the initializer's complexity bounded.
private func observePowerSourceProfileSwitching() {
let triggers: [AnyPublisher<Void, Never>] = [
powerSourceMonitor.$isPluggedIn.map { _ in () }.eraseToAnyPublisher(),
suggestionSettings.$isPowerBasedModelSwitchingEnabled.map { _ in () }.eraseToAnyPublisher(),
suggestionSettings.$batteryEngine.map { _ in () }.eraseToAnyPublisher(),
suggestionSettings.$batteryModelFilename.map { _ in () }.eraseToAnyPublisher(),
suggestionSettings.$pluggedInEngine.map { _ in () }.eraseToAnyPublisher(),
suggestionSettings.$pluggedInModelFilename.map { _ in () }.eraseToAnyPublisher(),
runtimeModel.$availableModels.map { _ in () }.eraseToAnyPublisher()
]

guard suggestionSettings.isPowerBasedModelSwitchingEnabled else {
Publishers.MergeMany(triggers)
.sink { [weak self] _ in
Comment on lines +302 to +312
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 FoundationModelAvailabilityService availability is not observed as a trigger. If refresh() (called in onAppear) completes asynchronously and updates isAvailable to true after the initial subscription fires, applyPowerProfile won't be re-evaluated until another trigger fires — meaning a user with Apple Intelligence configured as their active power-source profile won't have it applied until they plug or unplug the charger. Adding foundationModelAvailabilityService.$isAvailable.map { _ in () }.eraseToAnyPublisher() to the triggers array would ensure the profile is re-applied immediately when availability is resolved.

Fix in Codex Fix in Claude Code

guard let self else {
return
}

let filename = isPluggedIn
? suggestionSettings.pluggedInModelFilename
: suggestionSettings.batteryModelFilename
Self.applyPowerProfile(
isPluggedIn: self.powerSourceMonitor.isPluggedIn,
runtimeModel: self.runtimeModel,
suggestionSettings: self.suggestionSettings,
availability: self.foundationModelAvailabilityService
)
}
.store(in: &cancellables)
}

guard !filename.isEmpty else {
return
}
/// Switches the active engine (and, for Open Source, the model) to the profile configured for the
/// current power source. Does nothing when the feature is off. Apple Intelligence is applied only
/// when actually available, so a configured-but-unavailable profile never strands the user on a
/// dead engine; the Open Source branch reloads the model only when it is installed and not already
/// selected, so the sole side effect is a deliberate reload on a real change.
private static func applyPowerProfile(
isPluggedIn: Bool,
runtimeModel: RuntimeBootstrapModel,
suggestionSettings: SuggestionSettingsModel,
availability: FoundationModelAvailabilityService
) {
guard suggestionSettings.isPowerBasedModelSwitchingEnabled else {
return
}

guard runtimeModel.availableModels.contains(where: { $0.filename == filename }) else {
return
}
let profile = isPluggedIn ? suggestionSettings.pluggedInProfile : suggestionSettings.batteryProfile

guard runtimeModel.selectedModelFilename != filename else {
return
}
switch profile {
case .appleIntelligence:
guard availability.isAvailable else {
return
}

Task {
await runtimeModel.selectModel(filename)
}
suggestionSettings.selectEngine(.appleIntelligence)

case .llama(let filename):
suggestionSettings.selectEngine(.llamaOpenSource)

guard !filename.isEmpty,
runtimeModel.availableModels.contains(where: { $0.filename == filename }),
runtimeModel.selectedModelFilename != filename else {
return
}
.store(in: &cancellables)

Task {
await runtimeModel.selectModel(filename)
}
Comment on lines +352 to +363
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 selectEngine(.llamaOpenSource) is called unconditionally before the filename and model-availability guards. If the llama profile has an empty filename (e.g., all local models were uninstalled, or the profile was never fully seeded) the engine still switches to llamaOpenSource and the change is persisted via store.saveSelectedEngine. The subsequent return only prevents the model load, leaving the engine changed but no model loaded — so a user who was on Apple Intelligence gets silently stranded on a non-functional Open Source engine until another trigger fires and a model becomes available.

Suggested change
case .llama(let filename):
suggestionSettings.selectEngine(.llamaOpenSource)
guard !filename.isEmpty,
runtimeModel.availableModels.contains(where: { $0.filename == filename }),
runtimeModel.selectedModelFilename != filename else {
return
}
.store(in: &cancellables)
Task {
await runtimeModel.selectModel(filename)
}
case .llama(let filename):
guard !filename.isEmpty,
runtimeModel.availableModels.contains(where: { $0.filename == filename }) else {
return
}
suggestionSettings.selectEngine(.llamaOpenSource)
guard runtimeModel.selectedModelFilename != filename else {
return
}
Task {
await runtimeModel.selectModel(filename)
}

Fix in Codex Fix in Claude Code

}
}
}
20 changes: 20 additions & 0 deletions Cotabby/Models/SuggestionEngineModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,26 @@ enum SuggestionEngineKind: String, CaseIterable, Equatable, Hashable, Sendable,

}

/// A per-power-source suggestion configuration: which engine to use and, for the local llama engine,
/// which downloaded model file. Apple Intelligence carries no model file because the OS owns the
/// model. Used by the power-based switching feature to pick an engine + model per power state, and as
/// the single selection tag for the per-state pickers in Settings.
enum PowerProfile: Equatable, Hashable {
case appleIntelligence
case llama(filename: String)

/// The engine this profile selects. Settings persists engine + filename separately, so this is
/// the bridge from those two stored fields to a single picker selection (and back).
var engine: SuggestionEngineKind {
switch self {
case .appleIntelligence:
return .appleIntelligence
case .llama:
return .llamaOpenSource
}
}
}

/// A user-authored app blocklist entry.
///
/// The bundle identifier is the durable identity used by the suggestion pipeline. The display name
Expand Down
2 changes: 2 additions & 0 deletions Cotabby/Models/SuggestionSettingsData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ struct SuggestionSettingsData: Equatable {
var globalToggleKeyLabel: String
var acceptanceGranularity: AcceptanceGranularity
var isPowerBasedModelSwitchingEnabled: Bool
var batteryEngine: SuggestionEngineKind
var batteryModelFilename: String
var pluggedInEngine: SuggestionEngineKind
var pluggedInModelFilename: String
}
70 changes: 59 additions & 11 deletions Cotabby/Models/SuggestionSettingsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@ final class SuggestionSettingsModel: ObservableObject {
@Published private(set) var globalToggleKeyLabel: String
@Published private(set) var acceptanceGranularity: AcceptanceGranularity
@Published private(set) var isPowerBasedModelSwitchingEnabled: Bool
@Published private(set) var batteryEngine: SuggestionEngineKind
@Published private(set) var batteryModelFilename: String
@Published private(set) var pluggedInEngine: SuggestionEngineKind
@Published private(set) var pluggedInModelFilename: String

/// Owns the on-disk keys, defaults, migrations, and per-field writes. The facade holds one and
Expand Down Expand Up @@ -167,7 +169,9 @@ final class SuggestionSettingsModel: ObservableObject {
globalToggleKeyLabel = data.globalToggleKeyLabel
acceptanceGranularity = data.acceptanceGranularity
isPowerBasedModelSwitchingEnabled = data.isPowerBasedModelSwitchingEnabled
batteryEngine = data.batteryEngine
batteryModelFilename = data.batteryModelFilename
pluggedInEngine = data.pluggedInEngine
pluggedInModelFilename = data.pluggedInModelFilename
}

Expand Down Expand Up @@ -222,6 +226,15 @@ final class SuggestionSettingsModel: ObservableObject {
store.savePowerBasedModelSwitchingEnabled(enabled)
}

func setBatteryEngine(_ engine: SuggestionEngineKind) {
guard batteryEngine != engine else {
return
}

batteryEngine = engine
store.saveBatteryEngine(engine)
}

func setBatteryModelFilename(_ filename: String) {
guard batteryModelFilename != filename else {
return
Expand All @@ -231,6 +244,15 @@ final class SuggestionSettingsModel: ObservableObject {
store.saveBatteryModelFilename(filename)
}

func setPluggedInEngine(_ engine: SuggestionEngineKind) {
guard pluggedInEngine != engine else {
return
}

pluggedInEngine = engine
store.savePluggedInEngine(engine)
}

func setPluggedInModelFilename(_ filename: String) {
guard pluggedInModelFilename != filename else {
return
Expand All @@ -240,21 +262,47 @@ final class SuggestionSettingsModel: ObservableObject {
store.savePluggedInModelFilename(filename)
}

/// Seeds the per-power-source model selections from the active model the first time the feature
/// is configured, so both pickers default to something valid instead of an empty selection.
func initializePowerModelSelections(currentModelFilename: String?) {
guard let currentModelFilename else {
return
/// The profile applied while on battery, assembled from the stored engine + model filename.
var batteryProfile: PowerProfile {
batteryEngine == .appleIntelligence ? .appleIntelligence : .llama(filename: batteryModelFilename)
}

/// The profile applied while plugged in, assembled from the stored engine + model filename.
var pluggedInProfile: PowerProfile {
pluggedInEngine == .appleIntelligence ? .appleIntelligence : .llama(filename: pluggedInModelFilename)
}

func setBatteryProfile(_ profile: PowerProfile) {
setBatteryEngine(profile.engine)
if case .llama(let filename) = profile {
setBatteryModelFilename(filename)
}
}

func setPluggedInProfile(_ profile: PowerProfile) {
setPluggedInEngine(profile.engine)
if case .llama(let filename) = profile {
setPluggedInModelFilename(filename)
}
}

if batteryModelFilename.isEmpty {
batteryModelFilename = currentModelFilename
store.saveBatteryModelFilename(currentModelFilename)
/// Seeds each per-power-source profile from the active engine + model the first time the feature
/// is configured, so the pickers default to something valid instead of an empty selection. Only
/// seeds a profile still at its pristine default (Open Source with no model chosen), so an
/// explicit Apple Intelligence or model choice is never overwritten on a later appearance.
func initializePowerProfiles(currentEngine: SuggestionEngineKind, currentModelFilename: String?) {
if batteryEngine == .llamaOpenSource, batteryModelFilename.isEmpty {
setBatteryEngine(currentEngine)
if let currentModelFilename {
setBatteryModelFilename(currentModelFilename)
}
}

if pluggedInModelFilename.isEmpty {
pluggedInModelFilename = currentModelFilename
store.savePluggedInModelFilename(currentModelFilename)
if pluggedInEngine == .llamaOpenSource, pluggedInModelFilename.isEmpty {
setPluggedInEngine(currentEngine)
if let currentModelFilename {
setPluggedInModelFilename(currentModelFilename)
}
}
}

Expand Down
47 changes: 39 additions & 8 deletions Cotabby/Services/Power/PowerSourceMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,24 @@ import Foundation
import IOKit.ps

/// Tracks whether the Mac is currently drawing AC power and publishes changes for power-aware
/// features (such as switching the local model on battery vs. plugged in).
/// features (such as switching the engine/model on battery vs. plugged in).
///
/// Lives on the main actor because `@Published` feeds SwiftUI and the wake observer is delivered on
/// the main queue. State is refreshed at launch and on wake from sleep; live charger plug/unplug
/// during an active session is not yet observed.
/// Lives on the main actor because `@Published` feeds SwiftUI and both the IOKit run-loop callback
/// and the wake observer are delivered on the main run loop / queue. Live charger plug/unplug is
/// detected via an `IOPSNotificationCreateRunLoopSource`; the wake observer is a safety net for
/// power changes that happen while the machine is asleep.
@MainActor
final class PowerSourceMonitor: ObservableObject {
@Published private(set) var isPluggedIn = true

private var observer: NSObjectProtocol?
private var wakeObserver: NSObjectProtocol?
private var runLoopSource: CFRunLoopSource?

init() {
refreshPowerState()
startObservingPowerSourceChanges()

observer = NSWorkspace.shared.notificationCenter.addObserver(
wakeObserver = NSWorkspace.shared.notificationCenter.addObserver(
forName: NSWorkspace.didWakeNotification,
object: nil,
queue: .main
Expand All @@ -28,9 +31,37 @@ final class PowerSourceMonitor: ObservableObject {
}

deinit {
if let observer {
NSWorkspace.shared.notificationCenter.removeObserver(observer)
if let wakeObserver {
NSWorkspace.shared.notificationCenter.removeObserver(wakeObserver)
}

if let runLoopSource {
CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource, .defaultMode)
}
}

/// Registers an IOKit run-loop source that fires whenever the providing power source changes, so
/// charger plug/unplug is picked up live during an active session rather than only at launch and
/// on wake. The C callback cannot capture context, so `self` is threaded through the opaque
/// pointer; the source is added to the main run loop, so the callback runs on the main actor.
private func startObservingPowerSourceChanges() {
let context = Unmanaged.passUnretained(self).toOpaque()

guard let source = IOPSNotificationCreateRunLoopSource({ rawContext in
guard let rawContext else {
return
}

let monitor = Unmanaged<PowerSourceMonitor>.fromOpaque(rawContext).takeUnretainedValue()
MainActor.assumeIsolated {
monitor.refreshPowerState()
}
}, context)?.takeRetainedValue() else {
return
}

runLoopSource = source
CFRunLoopAddSource(CFRunLoopGetMain(), source, .defaultMode)
}

func refreshPowerState() {
Expand Down
18 changes: 18 additions & 0 deletions Cotabby/Support/SuggestionSettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ struct SuggestionSettingsStore {
private static let acceptanceGranularityDefaultsKey = "cotabbyAcceptanceGranularity"

private static let powerModelSwitchingEnabledDefaultsKey = "cotabbyPowerBasedModelSwitchingEnabled"
private static let batteryEngineDefaultsKey = "cotabbyBatteryEngine"
private static let batteryModelFilenameDefaultsKey = "cotabbyBatteryModelFilename"
private static let pluggedInEngineDefaultsKey = "cotabbyPluggedInEngine"
private static let pluggedInModelFilenameDefaultsKey = "cotabbyPluggedInModelFilename"

// MARK: - Load
Expand Down Expand Up @@ -289,7 +291,11 @@ struct SuggestionSettingsStore {

let resolvedPowerBasedModelSwitchingEnabled =
userDefaults.object(forKey: Self.powerModelSwitchingEnabledDefaultsKey) as? Bool ?? false
let resolvedBatteryEngine = userDefaults.string(forKey: Self.batteryEngineDefaultsKey)
.flatMap(SuggestionEngineKind.init(rawValue:)) ?? .llamaOpenSource
let resolvedBatteryModelFilename = userDefaults.string(forKey: Self.batteryModelFilenameDefaultsKey) ?? ""
let resolvedPluggedInEngine = userDefaults.string(forKey: Self.pluggedInEngineDefaultsKey)
.flatMap(SuggestionEngineKind.init(rawValue:)) ?? .llamaOpenSource
let resolvedPluggedInModelFilename = userDefaults.string(forKey: Self.pluggedInModelFilenameDefaultsKey) ?? ""

let data = SuggestionSettingsData(
Expand Down Expand Up @@ -334,7 +340,9 @@ struct SuggestionSettingsStore {
globalToggleKeyLabel: resolvedGlobalToggleKeyLabel,
acceptanceGranularity: resolvedAcceptanceGranularity,
isPowerBasedModelSwitchingEnabled: resolvedPowerBasedModelSwitchingEnabled,
batteryEngine: resolvedBatteryEngine,
batteryModelFilename: resolvedBatteryModelFilename,
pluggedInEngine: resolvedPluggedInEngine,
pluggedInModelFilename: resolvedPluggedInModelFilename
)

Expand Down Expand Up @@ -386,7 +394,9 @@ struct SuggestionSettingsStore {
)
saveAcceptanceGranularity(data.acceptanceGranularity)
savePowerBasedModelSwitchingEnabled(data.isPowerBasedModelSwitchingEnabled)
saveBatteryEngine(data.batteryEngine)
saveBatteryModelFilename(data.batteryModelFilename)
savePluggedInEngine(data.pluggedInEngine)
savePluggedInModelFilename(data.pluggedInModelFilename)

// The custom indicator icon feature was removed; scrub any previously-persisted PNG so
Expand Down Expand Up @@ -443,10 +453,18 @@ struct SuggestionSettingsStore {
userDefaults.set(enabled, forKey: Self.powerModelSwitchingEnabledDefaultsKey)
}

func saveBatteryEngine(_ engine: SuggestionEngineKind) {
userDefaults.set(engine.rawValue, forKey: Self.batteryEngineDefaultsKey)
}

func saveBatteryModelFilename(_ filename: String) {
userDefaults.set(filename, forKey: Self.batteryModelFilenameDefaultsKey)
}

func savePluggedInEngine(_ engine: SuggestionEngineKind) {
userDefaults.set(engine.rawValue, forKey: Self.pluggedInEngineDefaultsKey)
}

func savePluggedInModelFilename(_ filename: String) {
userDefaults.set(filename, forKey: Self.pluggedInModelFilenameDefaultsKey)
}
Expand Down
Loading