diff --git a/Makefile b/Makefile index d3909ece..9893eb0d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: app package-bundled-schemas release-contract-ruby-tests installer-lifecycle-ruby-tests release-facts run-app install install-public-release install-dev-adhoc repair-helper uninstall-helper pkg validation-evidence validation-evidence-current-build validation-evidence-review manual-smoke-readiness manual-smoke-readiness-current-build agent-cooling-evidence agent-cooling-evidence-review agent-run-smoke-readiness agent-run-smoke-readiness-current-build agent-run-smoke-evidence agent-run-smoke-evidence-current-build ui-review-build-products ui-review-initialize-ledger ui-review-start-session ui-review-ruby-tests ui-review-verify-automated ui-review-write-checkpoint ui-review-verify source-first-release-notes unsigned-dev-artifact source-first-readiness clean-app clean-pkg test test-fast test-full verify verify-full help clean +.PHONY: app check-toolchain package-bundled-schemas release-contract-ruby-tests installer-lifecycle-ruby-tests release-facts run-app install install-public-release install-dev-adhoc repair-helper uninstall-helper pkg validation-evidence validation-evidence-current-build validation-evidence-review manual-smoke-readiness manual-smoke-readiness-current-build agent-cooling-evidence agent-cooling-evidence-review agent-run-smoke-readiness agent-run-smoke-readiness-current-build agent-run-smoke-evidence agent-run-smoke-evidence-current-build ui-review-build-products ui-review-initialize-ledger ui-review-start-session ui-review-ruby-tests ui-review-verify-automated ui-review-write-checkpoint ui-review-verify source-first-release-notes unsigned-dev-artifact source-first-readiness clean-app clean-pkg test test-fast test-full verify verify-full help clean CONFIGURATION ?= debug SIGNING_IDENTITY ?= - @@ -115,7 +115,10 @@ installer-lifecycle-ruby-tests: ## Run portable installer lifecycle transaction /usr/bin/ruby Tests/Ruby/InstallerLifecycleTrustContractTests.rb /usr/bin/ruby Tests/Ruby/HelperLifecycleReplacementFixtureTests.rb -app: release-facts ## Build the release app bundle +check-toolchain: ## Fail with actionable guidance when the active developer directory cannot build SwiftUI macros + ./scripts/check-toolchain.sh + +app: check-toolchain release-facts ## Build the release app bundle VIFTY_XPC_ADHOC_DEVELOPMENT="$(VIFTY_XPC_ADHOC_DEVELOPMENT)" VIFTY_XPC_ADHOC_ALLOWED_UID="$(VIFTY_XPC_ADHOC_ALLOWED_UID)" VIFTY_XPC_ADHOC_APP_PATH="$(VIFTY_XPC_ADHOC_APP_PATH)" VIFTY_XPC_ADHOC_CTL_PATH="$(VIFTY_XPC_ADHOC_CTL_PATH)" VIFTY_XPC_ADHOC_HELPER_PATH="$(VIFTY_XPC_ADHOC_HELPER_PATH)" ./scripts/configure-daemon-plist.sh --configuration "$(CONFIGURATION)" --team-id "$(VIFTY_XPC_ALLOWED_TEAM_ID)" --validate-only swift build $(SWIFT_BUILD_ARGS) -c $(CONFIGURATION) rm -rf "$(APP_DIR)" @@ -155,17 +158,17 @@ app: release-facts ## Build the release app bundle @test -z "$$(find "$(CONTENTS)" -name 'ViftyAXCollector*' -o -name 'ViftyAXEvidenceCore*' -o -name 'AXReader.swift' -o -name 'AXTraversal.swift' -o -name 'AXEvidenceModels.swift' -o -name 'AXPredicateCatalog.swift' -o -name 'ui-review-ax-*.schema.json')" || { echo "AX evidence tooling must not be bundled in Vifty.app" >&2; exit 1; } @echo "Built $(APP_DIR)" -run-app: ## Build and open the local app bundle +run-app: check-toolchain ## Build and open the local app bundle ./scripts/build-and-run-vifty.sh -install: ## Build and install to /Applications +install: check-toolchain ## Build and install to /Applications CONFIGURATION="$(CONFIGURATION)" ./scripts/install-vifty.sh install-public-release: ## Verify and install the exact current published release archive @if [ -z "$(PUBLIC_RELEASE_ARCHIVE)" ]; then echo "PUBLIC_RELEASE_ARCHIVE is required and must be an absolute path to the canonical public release zip" >&2; exit 64; fi CONFIGURATION=release ./scripts/install-vifty.sh --public-release-archive "$(PUBLIC_RELEASE_ARCHIVE)" -install-dev-adhoc: ## Explicit debug-only install with exact UID/path XPC allowlist +install-dev-adhoc: check-toolchain ## Explicit debug-only install with exact UID/path XPC allowlist CONFIGURATION=debug VIFTY_ENABLE_ADHOC_XPC=1 ./scripts/install-vifty.sh repair-helper: ## Explicitly repair the installed privileged helper @@ -174,7 +177,7 @@ repair-helper: ## Explicitly repair the installed privileged helper uninstall-helper: ## Safely remove the installed privileged helper ./scripts/uninstall-vifty.sh --app "$(UNINSTALL_HELPER_APP)" -pkg: ## Build an unsigned installer .pkg +pkg: check-toolchain ## Build an unsigned installer .pkg CONFIGURATION="$(CONFIGURATION)" ./scripts/build-installer-pkg.sh validation-evidence: ## Collect read-only release/hardware validation evidence @@ -257,13 +260,13 @@ source-first-readiness: ## Check published source-first release readiness test: test-full ## Run the full XCTest suite -test-fast: ## Run the fast local XCTest suite +test-fast: check-toolchain ## Run the fast local XCTest suite swift test $(SWIFT_BUILD_ARGS) $(SLOW_TEST_SKIP_ARGS) -test-full: ## Run the full XCTest suite, including slow evidence/release script tests +test-full: check-toolchain ## Run the full XCTest suite, including slow evidence/release script tests swift test $(SWIFT_BUILD_ARGS) -verify: ## Run fast local trust gates without installing +verify: check-toolchain ## Run fast local trust gates without installing /bin/bash -n scripts/*.sh scripts/lib/*.sh examples/viftyctl/*.sh $(MAKE) release-facts scripts/check-community-standards.sh diff --git a/Sources/Vifty/AppModel+Control.swift b/Sources/Vifty/AppModel+Control.swift new file mode 100644 index 00000000..b679e344 --- /dev/null +++ b/Sources/Vifty/AppModel+Control.swift @@ -0,0 +1,529 @@ +import Foundation +import SwiftUI +import ViftyCore + +@MainActor +extension AppModel { + func applyModeSelection() { + if selectedMode == .auto { + restoreAuto() + } else { + markFanControlDraftPending() + } + } + + func performModeSelectionAction() { + Task { await performModeSelectionActionNow() } + } + + func performModeSelectionActionNow() async { + switch controlSessionPresentation.primaryAction { + case .restoreAuto: + await restoreAutoNow() + case .apply: + _ = await applyCurrentModeSelection() + case .none, .repairHelper, .copyDiagnostics: + break + } + } + + func restoreAuto() { + let generation = beginAutoRestoreOperation() + Task { await performAutoRestore(generation: generation) } + } + + var canRequestRestoreAuto: Bool { + FanControlOwnershipPresentation + .resolve(fanControlOwnershipStatus) + .canRequestRestoreAuto + } + + func markFanControlDraftPending() { + fanControlApplyState = fanControlSessionController.draftPendingApplyState( + currentDraft: currentFanControlDraft, + selectedMode: selectedMode, + controlMode: controlState.mode, + applyState: fanControlApplyState + ) + } + + func applyPendingFanControl() { + Task { _ = await applyCurrentModeSelection() } + } + + @discardableResult + func applyCurrentModeSelection() async -> FanControlApplyResult { + if selectedMode == .auto { + await restoreAutoNow() + if case .failed(let message) = fanControlApplyState { + return .failed(message: message) + } + return selectedMode == .auto && controlState.mode == .auto ? .applied : .superseded + } + + if usePerFanFixedRPM, let fans = snapshot?.fans { + ensureFixedFanTargets(for: fans) + } + let manualOperation = fanControlSessionController.beginManualOperation( + currentSessionExpiresAt: manualSessionExpiresAt + ) + let operation = manualOperation.operation + let draft = currentFanControlDraft + let mode = fanMode(for: draft) + + await refreshManualControlPreflight() + guard manualFanControlOperationIsCurrent(operation) else { + return .superseded + } + if let blockedReason = manualFanControlBlockedReason { + lastError = "Manual fan control blocked: \(blockedReason)" + fanControlApplyState = .blocked(reason: blockedReason) + await syncState() + guard manualFanControlOperationIsCurrent(operation) else { + return .superseded + } + return .blocked(reason: blockedReason) + } + + fanControlApplyState = .applying + lastError = nil + let provisionalSessionExpiresAt = fanControlSessionController.registerManualApply( + manualOperation, + draft: draft, + mode: mode + ) + manualSessionExpiresAt = provisionalSessionExpiresAt + + guard manualFanControlOperationIsCurrent(operation) else { + return .superseded + } + await coordinator.setFixedFanTargets(fixedFanTargetMap(for: draft)) + guard manualFanControlOperationIsCurrent(operation) else { + return .superseded + } + await coordinator.setFanOverrides(draft.usePerFanOverrides ? draft.fanOverrides : []) + guard manualFanControlOperationIsCurrent(operation) else { + return .superseded + } + await coordinator.setMode(mode) + guard manualFanControlOperationIsCurrent(operation) else { + return .superseded + } + fanControlSessionController.markCoordinatorConfigured(operation) + + guard manualFanControlOperationIsCurrent(operation) else { + return .superseded + } + // A successful hardware poll is necessary but not sufficient to commit + // the draft or its run limit. The fresh daemon ownership read below is + // the final Apply boundary. + await pollingController.freshPollOnce { [weak self] pollingOperation in + await self?.performPollOnce( + operation: pollingOperation, + reconcileManualApply: false + ) + } + guard manualFanControlOperationIsCurrent(operation) else { + return .superseded + } + if let lastError { + restorePreviousManualDeadline(for: manualOperation) + fanControlApplyState = .failed(message: lastError) + return .failed(message: lastError) + } + do { + let confirmedOwnership = try await coordinator.confirmCurrentManualOwnership() + guard manualFanControlOperationIsCurrent(operation) else { + return .superseded + } + fanControlOwnershipStatus = confirmedOwnership + fanControlOwnershipStatusError = nil + } catch { + guard manualFanControlOperationIsCurrent(operation) else { + return .superseded + } + let message = "Manual fan control could not be confirmed after Apply: \(error.localizedDescription)" + rejectUnconfirmedManualApply( + manualOperation, + provisionalSessionExpiresAt: provisionalSessionExpiresAt + ) + lastError = message + fanControlOwnershipStatus = nil + fanControlOwnershipStatusError = error.localizedDescription + fanControlApplyState = .failed(message: message) + return .failed(message: message) + } + guard controlState.mode == mode, controlState.manualControlActive else { + let message = "Manual fan control could not be confirmed after Apply." + rejectUnconfirmedManualApply( + manualOperation, + provisionalSessionExpiresAt: provisionalSessionExpiresAt + ) + lastError = message + fanControlApplyState = .failed(message: message) + return .failed(message: message) + } + reconcileManualApplyAttemptAfterSuccessfulPoll() + return .applied + } + + func restoreAutoNow() async { + let generation = beginAutoRestoreOperation() + await performAutoRestore(generation: generation) + } + + func performAutoRestore(generation: FanControlSessionOperation) async { + guard fanControlOperationIsCurrent(generation) else { return } + await coordinator.setMode( + .auto, + unreadableJournalRecoveryAuthority: .explicitOperator + ) + guard fanControlOperationIsCurrent(generation) else { return } + + // Auto is a safety-priority operation. Do not coalesce it behind an + // in-flight manual poll: invalidate that poll's publication token and + // start a concurrent Auto tick so the daemon can preempt at the next + // physical fan boundary. + pollingController.supersedeActivePoll() + await pollOnce() + guard fanControlOperationIsCurrent(generation) else { return } + // The coordinator's protocol-v2 full-Auto transaction routes through + // AgentControlService in the daemon and clears the lease atomically. + // Refresh visibility only; never issue a second lease-clear restore. + await refreshAgentControlStatus() + guard fanControlOperationIsCurrent(generation) else { return } + if let lastError { + fanControlApplyState = .failed(message: lastError) + await notifyAutoRestoreFailure(lastError) + } else if controlState.mode != .auto || controlState.manualControlActive { + let message = "Auto restore could not be confirmed." + lastError = message + fanControlApplyState = .failed(message: message) + await notifyAutoRestoreFailure(message) + } else { + recordAutoRestorationApplied() + } + } + + func updateManualTargetDriftStability() { + let driftedIDs = Set(currentManualTargetDriftFans.map(\.id)) + guard !driftedIDs.isEmpty else { + manualTargetDriftSampleCounts = [:] + return + } + + manualTargetDriftSampleCounts = driftedIDs.reduce(into: [:]) { countsByFanID, fanID in + let count = (manualTargetDriftSampleCounts[fanID] ?? 0) + 1 + countsByFanID[fanID] = min(count, Self.manualTargetDriftAttentionSampleCount) + } + } + + var manualResponseAttentionIsWarranted: Bool { + if thermalPressure == .serious || thermalPressure == .critical { + return true + } + guard let sensor = snapshot?.highestTemperature else { return false } + return sensor.celsius >= Self.highTemperatureAttentionThreshold + } + + func fixedModeTargetSummary(fallbackRPM: Int) -> String { + guard perFanFixedRPMApplies else { return "\(fallbackRPM) RPM" } + + let controllableFans = snapshot?.fans.filter(\.controllable) ?? [] + if !controllableFans.isEmpty { + return controllableFans.map { fan in + let rpm = expectedManualTargetRPM(for: fan) + ?? FanCurve.clamp(fallbackRPM, fan.minimumRPM, fan.maximumRPM) + return "\(fan.name) \(rpm) RPM" + } + .joined(separator: ", ") + } + + let targets = fixedFanTargets.sorted { $0.fanID < $1.fanID } + guard !targets.isEmpty else { return "per-fan RPM" } + return targets + .map { target in "F\(target.fanID) \(target.rpm) RPM" } + .joined(separator: ", ") + } + + func expectedManualTargetRPM(for fan: Fan) -> Int? { + if let lastAppliedRPM = controlState.lastAppliedRPM[fan.id] { + return lastAppliedRPM + } + + switch controlState.mode { + case .auto: + return nil + case .fixedRPM(let rpm): + if perFanFixedRPMApplies, let target = fixedFanTarget(for: fan.id)?.rpm { + return FanCurve.clamp(target, fan.minimumRPM, fan.maximumRPM) + } + return FanCurve.clamp(rpm, fan.minimumRPM, fan.maximumRPM) + case .temperatureCurve(let curve): + guard let snapshot, + let sensor = snapshot.temperatureSensors.first(where: { $0.id == (curve.sensorID ?? controlState.selectedSensorID) }) + ?? selectedSensor else { + return nil + } + return curve.targetRPM( + for: sensor.celsius, + minimumRPM: fan.minimumRPM, + maximumRPM: fan.maximumRPM + ) + } + } + + var autoSystemModeFans: [Fan] { + guard controlState.mode == .auto else { return [] } + return snapshot?.fans.filter { $0.hardwareMode == .system } ?? [] + } + + var autoForcedModeFans: [Fan] { + guard controlState.mode == .auto else { return [] } + return snapshot?.fans.filter { $0.hardwareMode == .forced } ?? [] + } + + var autoUnknownModeFans: [Fan] { + guard controlState.mode == .auto else { return [] } + return snapshot?.fans.filter { fan in + if case .unknown = fan.hardwareMode { + return true + } + return false + } ?? [] + } + + var autoMissingModeFans: [Fan] { + guard controlState.mode == .auto else { return [] } + return snapshot?.fans.filter { $0.hardwareMode == nil } ?? [] + } + + func fanIDList(_ fans: [Fan]) -> String { + fans.map { "F\($0.id)" }.joined(separator: ", ") + } + + var fanRange: ClosedRange { + let bounds = fixedRPMBaseBounds + return Double(bounds.minimumRPM)...Double(bounds.maximumRPM) + } + + func rpm(forPercent percent: Int) -> Int { + let bounds = fixedRPMBaseBounds + let span = bounds.maximumRPM - bounds.minimumRPM + let rpm = bounds.minimumRPM + Int((Double(span) * Double(percent) / 100.0).rounded()) + return FanCurve.clamp(rpm, bounds.minimumRPM, bounds.maximumRPM) + } + + func currentCurve() -> FanCurve { + FanCurve(sensorID: resolvedCurveSensorID, points: [ + CurvePoint(temperatureCelsius: curveStartTemp, rpm: Int(curveStartRPM.rounded())), + CurvePoint(temperatureCelsius: curveMidTemp, rpm: Int(curveMidRPM.rounded())), + CurvePoint(temperatureCelsius: curveMaxTemp, rpm: Int(curveMaxRPM.rounded())) + ]) + } + + func fixedFanTargetMap(for draft: FanControlDraft) -> [Int: Int] { + guard draft.mode == .fixed, draft.usePerFanFixedRPM else { return [:] } + if let fans = snapshot?.fans { + guard fans.filter(\.controllable).count > 1 else { return [:] } + } else { + guard draft.fixedFanTargets.count > 1 else { return [:] } + } + return draft.fixedFanTargets.reduce(into: [Int: Int]()) { targetsByID, target in + targetsByID[target.fanID] = target.rpm + } + } + + var perFanFixedRPMApplies: Bool { + guard usePerFanFixedRPM else { return false } + if let fans = snapshot?.fans { + return fans.filter(\.controllable).count > 1 + } + return fixedFanTargets.count > 1 + } + + func selectedFanMode() -> FanMode { + fanMode(for: currentFanControlDraft) + } + + func fanMode(for draft: FanControlDraft) -> FanMode { + fanControlSessionController.fanMode(for: draft) + } + + func applyCurveOverrides() { + markFanControlDraftPending() + } + + func restoreAutoIfManualSessionExpired() async -> FanControlSessionOperation? { + guard fanControlSessionController.shouldRestoreExpiredManualSession( + selectedMode: selectedMode, + manualSessionExpiresAt: manualSessionExpiresAt + ) else { + return nil + } + + let generation = beginAutoRestoreOperation() + guard fanControlOperationIsCurrent(generation) else { return nil } + await coordinator.setMode(.auto) + guard fanControlOperationIsCurrent(generation) else { return nil } + return generation + } + + func recordAutoRestorationApplied() { + fanControlSessionController.recordAutoRestorationApplied(currentDraft: currentFanControlDraft) + fanControlApplyState = .applied + } + +#if DEBUG + func configureReviewFixtureAppliedFanControlDraft() { + fanControlSessionController.recordAutoRestorationApplied(currentDraft: currentFanControlDraft) + fanControlApplyState = .applied + } +#endif + + func beginAutoRestoreOperation() -> FanControlSessionOperation { + let operation = fanControlSessionController.beginAutoOperation() + fanControlApplyState = .applying + lastError = nil + selectedMode = .auto + manualSessionExpiresAt = nil + return operation + } + + func fanControlOperationIsCurrent(_ operation: FanControlSessionOperation) -> Bool { + fanControlSessionController.isCurrent(operation) + } + + func manualFanControlOperationIsCurrent(_ operation: FanControlSessionOperation) -> Bool { + fanControlSessionController.isCurrentManual(operation, selectedMode: selectedMode) + } + + func canCommitAutoRestoration(generation: FanControlSessionOperation) -> Bool { + fanControlSessionController.canCommitAutoRestoration( + operation: generation, + selectedMode: selectedMode, + controlState: controlState + ) + } + + func reconcileManualApplyAttemptAfterSuccessfulPoll() { + guard let reconciliation = fanControlSessionController.reconcileManualApplyAfterSuccessfulPoll( + operationSelectedMode: selectedMode, + controlState: controlState, + currentDraft: currentFanControlDraft + ) else { return } + manualSessionExpiresAt = reconciliation.manualSessionExpiresAt + fanControlApplyState = reconciliation.applyState + } + + func restorePreviousManualDeadline(for operation: FanControlManualOperation) { + // Use the operation's immutable capture: the polling path may have + // advanced controller reconciliation state before a later check fails. + manualSessionExpiresAt = operation.previousSessionExpiresAt + } + + func rejectUnconfirmedManualApply( + _ operation: FanControlManualOperation, + provisionalSessionExpiresAt: Date? + ) { + fanControlSessionController.rejectManualApply(operation.operation) + // If this was the first finite Apply there is no previous deadline to + // restore. Keep the provisional bound because the hardware transaction + // completed before its final ownership read failed; never turn that + // bounded user request into an indefinite manual session. + manualSessionExpiresAt = operation.previousSessionExpiresAt + ?? provisionalSessionExpiresAt + } + + func shouldPreserveManualIntent(afterTickFailure error: Error, attemptedMode: FanMode) -> Bool { + guard attemptedMode != .auto else { return false } + guard let viftyError = error as? ViftyError else { return true } + + switch viftyError { + case .helperRejected, .smcUnavailable, .smcOpenFailed, .smcCallFailed, .smcKeyUnavailable, .smcWriteRejected: + return true + case .unsupportedHardware: + return false + case .noTemperatureSensors: + return true + case .noControllableFans: + return true + } + } + + func refreshAgentControlStatus() async { + do { + agentControlStatus = try await agentStatusReader() + agentCoolingEnabled = agentControlStatus?.policy?.enabled + agentControlStatusError = nil + } catch { + agentControlStatusError = error.localizedDescription + } + } + + func setAgentCoolingEnabled(_ enabled: Bool) async { + do { + guard let status = try await agentPolicySetter(enabled) else { return } + agentControlStatus = status + agentCoolingEnabled = status.policy?.enabled + agentControlStatusError = nil + } catch { + agentControlStatusError = error.localizedDescription + } + } + + func refreshFanControlOwnershipStatus() async { + do { + fanControlOwnershipStatus = try await coordinator.fanControlOwnershipStatus() + fanControlOwnershipStatusError = nil + } catch { + fanControlOwnershipStatus = nil + fanControlOwnershipStatusError = error.localizedDescription + } + } + + func refreshManualControlPreflight() async { + daemonResponding = await daemonPing() + if let snapshot { + daemonReachable = daemonResponding || !snapshot.fans.isEmpty + } else { + daemonReachable = daemonResponding + } + + // A draft must never infer ownership from its selected mode or from + // stale fan telemetry. Refresh the daemon's transaction status as part + // of the same preflight that authorizes every manual Apply attempt. + await refreshFanControlOwnershipStatus() + + guard agentControlStatus?.activeLease == nil || agentControlStatusError != nil else { + return + } + + do { + guard let status = try await agentStatusReader() else { return } + agentControlStatus = status + agentCoolingEnabled = status.policy?.enabled + agentControlStatusError = nil + } catch { + agentControlStatusError = error.localizedDescription + } + } + + func syncCurveDefaultsIfNeeded(from snapshot: HardwareSnapshot) { + guard !curveDefaultsSynced, + let fan = snapshot.fans.first(where: \.controllable) else { return } + curveStartRPM = Double(fan.minimumRPM) + curveMaxRPM = Double(fan.maximumRPM) + if selectedSensorID == nil { + setProgrammaticSelectedSensorID(selectedSensor?.id) + } + curveDefaultsSynced = true + } + + func setProgrammaticSelectedSensorID(_ sensorID: String?) { + isSettingSelectedSensorProgrammatically = true + selectedSensorID = sensorID + isSettingSelectedSensorProgrammatically = false + } + +} diff --git a/Sources/Vifty/AppModel+Fans.swift b/Sources/Vifty/AppModel+Fans.swift new file mode 100644 index 00000000..47436d95 --- /dev/null +++ b/Sources/Vifty/AppModel+Fans.swift @@ -0,0 +1,231 @@ +import Foundation +import SwiftUI +import ViftyCore + +@MainActor +extension AppModel { + func targetRPMPreview(for fan: Fan) -> Int? { + guard fan.controllable else { return nil } + switch selectedMode { + case .auto: + return nil + case .fixed: + if perFanFixedRPMApplies { + return fixedFanTargetRPM(for: fan) + } + return FanCurve.clamp(Int(fixedRPM.rounded()), fan.minimumRPM, fan.maximumRPM) + case .curve: + guard let sensor = selectedSensor else { return nil } + return FanCurveTargetResolver.targetRPM( + baseCurve: currentCurve(), + fan: fan, + temperature: sensor.celsius, + overrides: usePerFanOverrides ? fanOverrides : [] + ) + } + } + + func appliedTargetRPM(for fan: Fan) -> Int? { + let targetRPM = fan.targetRPM ?? (controlState.manualControlActive ? controlState.lastAppliedRPM[fan.id] : nil) + return targetRPM.map { FanCurve.clamp($0, fan.minimumRPM, fan.maximumRPM) } + } + + func draftTargetRPMPreview(for fan: Fan) -> Int? { + guard selectedMode != .auto, hasPendingFanControlChanges else { return nil } + let draftTargetRPM = targetRPMPreview(for: fan) + return draftTargetRPM == appliedTargetRPM(for: fan) ? nil : draftTargetRPM + } + + func publishTelemetrySession() { + assignIfChanged(\.telemetryOverviewSummary, telemetrySession.overviewSummary) + assignIfChanged(\.compactTelemetryOverviewSummary, telemetrySession.compactSummary) + assignIfChanged(\.recentTelemetryTrendSummary, telemetrySession.recentTrendSummary) + } + + func ensureFixedFanTargets(for fans: [Fan]) { + let controllableFans = fans.filter(\.controllable) + let baseRatio = fixedRPMBaseRangeRatio(for: fixedRPMBaseBounds(for: controllableFans)) + let existingByFanID = fixedFanTargets.reduce(into: [Int: FixedFanTarget]()) { targetsByID, target in + targetsByID[target.fanID] = target + } + let nextTargets = controllableFans.map { fan in + if let existing = existingByFanID[fan.id] { + return FixedFanTarget( + fanID: fan.id, + rpm: FanCurve.clamp(existing.rpm, fan.minimumRPM, fan.maximumRPM) + ) + } + return defaultFixedFanTarget(for: fan, baseRatio: baseRatio) + } + guard nextTargets != fixedFanTargets else { return } + fixedFanTargets = nextTargets + persistAppPreferences() + } + + func fixedFanTarget(for fanID: Int) -> FixedFanTarget? { + fixedFanTargets.first { $0.fanID == fanID } + } + + func fixedFanTargetRPM(for fan: Fan) -> Int { + fixedFanTarget(for: fan.id)?.rpm ?? defaultFixedFanTargetRPM(for: fan) + } + + func fixedFanSliderRPM(for fan: Fan) -> Int { + fixedFanTargetRPM(for: fan) + } + + func fixedFanTargetPercent(for fan: Fan) -> Int { + rpmPercent(fixedFanTargetRPM(for: fan), for: fan) + } + + func setFixedFanRPM(_ rpm: Int, for fan: Fan, persist: Bool = true) { + updateFixedFanTarget(for: fan) { target in + target.rpm = FanCurve.clamp(rpm, fan.minimumRPM, fan.maximumRPM) + } + if persist { + persistAppPreferences() + } + } + + func commitFixedFanTargetsAndApply() { + persistAppPreferences() + markFanControlDraftPending() + } + + func commitFixedFanTargetsAndApplyNow() async { + persistAppPreferences() + markFanControlDraftPending() + } + + func ensureFanOverrides(for fans: [Fan]) { + let existingByFanID = fanOverrides.reduce(into: [Int: FanCurveOverride]()) { overridesByID, override in + overridesByID[override.fanID] = override + } + fanOverrides = fans.filter(\.controllable).map { fan in + existingByFanID[fan.id] ?? defaultFanOverride(for: fan) + } + } + + func fanOverride(for fanID: Int) -> FanCurveOverride? { + fanOverrides.last { $0.fanID == fanID } + } + + func setOverrideStartRPM(_ rpm: Int, for fan: Fan) { + guard fan.controllable else { return } + updateFanOverride(for: fan) { override in + override.startRPM = FanCurve.clamp(rpm, fan.minimumRPM, fan.maximumRPM) + } + } + + func setOverrideMidRPM(_ rpm: Int, for fan: Fan) { + guard fan.controllable else { return } + updateFanOverride(for: fan) { override in + override.midRPM = FanCurve.clamp(rpm, fan.minimumRPM, fan.maximumRPM) + } + } + + func setOverrideMaxRPM(_ rpm: Int, for fan: Fan) { + guard fan.controllable else { return } + updateFanOverride(for: fan) { override in + override.maxRPM = FanCurve.clamp(rpm, fan.minimumRPM, fan.maximumRPM) + } + } + + func defaultFanOverride(for fan: Fan) -> FanCurveOverride { + FanCurveOverride( + fanID: fan.id, + startRPM: FanCurve.clamp(Int(curveStartRPM.rounded()), fan.minimumRPM, fan.maximumRPM), + midRPM: FanCurve.clamp(Int(curveMidRPM.rounded()), fan.minimumRPM, fan.maximumRPM), + maxRPM: FanCurve.clamp(Int(curveMaxRPM.rounded()), fan.minimumRPM, fan.maximumRPM) + ) + } + + func defaultFixedFanTarget(for fan: Fan) -> FixedFanTarget { + FixedFanTarget( + fanID: fan.id, + rpm: defaultFixedFanTargetRPM(for: fan) + ) + } + + var fixedRPMBaseBounds: (minimumRPM: Int, maximumRPM: Int) { + fixedRPMBaseBounds(for: snapshot?.fans ?? []) + } + + func fixedRPMBaseBounds(for fans: [Fan]) -> (minimumRPM: Int, maximumRPM: Int) { + let fan = fans.first(where: \.controllable) ?? fans.first + return ( + minimumRPM: fan?.minimumRPM ?? 1200, + maximumRPM: fan?.maximumRPM ?? 6500 + ) + } + + var fixedRPMBaseRangeRatio: Double { + fixedRPMBaseRangeRatio(for: fixedRPMBaseBounds) + } + + func fixedRPMBaseRangeRatio(for bounds: (minimumRPM: Int, maximumRPM: Int)) -> Double { + guard bounds.maximumRPM > bounds.minimumRPM else { return 0 } + let clamped = FanCurve.clamp(Int(fixedRPM.rounded()), bounds.minimumRPM, bounds.maximumRPM) + return Double(clamped - bounds.minimumRPM) / Double(bounds.maximumRPM - bounds.minimumRPM) + } + + func defaultFixedFanTargetRPM(for fan: Fan) -> Int { + defaultFixedFanTarget(for: fan, baseRatio: fixedRPMBaseRangeRatio).rpm + } + + func defaultFixedFanTarget(for fan: Fan, baseRatio: Double) -> FixedFanTarget { + guard fan.maximumRPM > fan.minimumRPM else { + return FixedFanTarget( + fanID: fan.id, + rpm: FanCurve.clamp(Int(fixedRPM.rounded()), fan.minimumRPM, fan.maximumRPM) + ) + } + let rpm = Double(fan.minimumRPM) + Double(fan.maximumRPM - fan.minimumRPM) * baseRatio + return FixedFanTarget( + fanID: fan.id, + rpm: FanCurve.clamp(Int(rpm.rounded()), fan.minimumRPM, fan.maximumRPM) + ) + } + + func rpmPercent(_ rpm: Int, for fan: Fan) -> Int { + guard fan.maximumRPM > fan.minimumRPM else { return 0 } + let clamped = FanCurve.clamp(rpm, fan.minimumRPM, fan.maximumRPM) + let ratio = Double(clamped - fan.minimumRPM) / Double(fan.maximumRPM - fan.minimumRPM) + return min(100, max(0, Int((ratio * 100).rounded()))) + } + + func updateFixedFanTarget(for fan: Fan, mutate: (inout FixedFanTarget) -> Void) { + if let index = fixedFanTargets.firstIndex(where: { $0.fanID == fan.id }) { + mutate(&fixedFanTargets[index]) + } else { + var target = defaultFixedFanTarget(for: fan) + mutate(&target) + fixedFanTargets.append(target) + } + fixedFanTargets = fixedFanTargets.reduce(into: [Int: FixedFanTarget]()) { targetsByID, target in + targetsByID[target.fanID] = target + } + .sorted { $0.key < $1.key } + .map(\.value) + } + + func updateFanOverride(for fan: Fan, mutate: (inout FanCurveOverride) -> Void) { + // Fan-curve resolution and presentation are deliberately last-wins for + // duplicate persisted records. Mutate that same effective record before + // canonicalizing, otherwise the untouched final duplicate would erase + // the user's edit during the reduction below. + if let index = fanOverrides.lastIndex(where: { $0.fanID == fan.id }) { + mutate(&fanOverrides[index]) + } else { + var override = defaultFanOverride(for: fan) + mutate(&override) + fanOverrides.append(override) + } + fanOverrides = fanOverrides.reduce(into: [Int: FanCurveOverride]()) { overridesByID, override in + overridesByID[override.fanID] = override + } + .sorted { $0.key < $1.key } + .map(\.value) + } + +} diff --git a/Sources/Vifty/AppModel+Lifecycle.swift b/Sources/Vifty/AppModel+Lifecycle.swift new file mode 100644 index 00000000..bf846423 --- /dev/null +++ b/Sources/Vifty/AppModel+Lifecycle.swift @@ -0,0 +1,170 @@ +import Foundation +import SwiftUI +import ViftyCore + +@MainActor +extension AppModel { + var launchAtLoginEnabled: Bool { + launchAtLoginStatus.isToggleOn + } + + var launchAtLoginStatusMessage: String? { + launchAtLoginError ?? launchAtLoginStatus.message + } + + func refreshLaunchAtLoginStatus() { + launchAtLoginStatus = launchAtLoginManager.status + if launchAtLoginStatus == .enabled || launchAtLoginStatus == .disabled { + launchAtLoginError = nil + } + } + + func setLaunchAtLoginEnabled(_ enabled: Bool) { + do { + try launchAtLoginManager.setEnabled(enabled) + launchAtLoginStatus = launchAtLoginManager.status + launchAtLoginError = nil + if launchAtLoginStatus == .requiresApproval { + launchAtLoginManager.openLoginItemsSettings() + } + } catch { + launchAtLoginStatus = launchAtLoginManager.status + launchAtLoginError = "Could not update startup item: \(error.localizedDescription)" + } + } + + func openLaunchAtLoginSettings() { + launchAtLoginManager.openLoginItemsSettings() + } + + func refreshSystemSettingsStateOnActivation() async { + refreshLaunchAtLoginStatus() + await refreshNotificationAuthorization() + } + + func applyStartupModePreferenceIfNeeded() async { + guard !startupModeApplied else { return } + startupModeApplied = true + selectedMode = startupMode + guard startupMode != .auto else { return } + if snapshot == nil { + await pollOnce() + selectedMode = startupMode + } + guard FanControlOwnershipPresentation.resolve(fanControlOwnershipStatus).owner == .macOS else { + lastError = "Startup manual mode remains a draft until daemon-confirmed macOS fan ownership is available." + markFanControlDraftPending() + return + } + markFanControlDraftPending() + } + + func stop() { + Task { _ = await stopAndRestore() } + } + + func stopAndRestore() async -> AppTerminationRestoreResult { + ViftyLog.fanControl.notice("Termination restore started") + let wasRunning = isRunning + let stateBeforeStop = await coordinator.state + let hasAgentLeaseRequiringRestore = agentControlStatus?.activeLease != nil + pollingController.stop() + codexUsageRefreshGeneration &+= 1 + codexUsageRefreshTask?.cancel() + codexUsageRefreshTask = nil + isRunning = false + selectedMode = .auto + manualSessionExpiresAt = nil + + var failures: [String] = [] + let ownershipBeforeRestore: FanControlOwnershipStatus? + do { + let status = try await coordinator.fanControlOwnershipStatus() + fanControlOwnershipStatus = status + fanControlOwnershipStatusError = nil + ownershipBeforeRestore = status + } catch { + let message = "Could not confirm daemon fan-control ownership before termination: \(error.localizedDescription)" + fanControlOwnershipStatus = nil + fanControlOwnershipStatusError = error.localizedDescription + ownershipBeforeRestore = nil + failures.append(message) + } + + let requiresHardwareRestore = stateBeforeStop.manualControlActive + || stateBeforeStop.mode != .auto + || hasAgentLeaseRequiringRestore + || ownershipBeforeRestore.map { + !FanControlCoordinator.confirmsCleanOSOwnership($0) + } == true + + // A failed ownership read must not suppress a restore already required + // by local manual state or an agent lease. We still fail termination + // closed on that unreadable precondition, but make the best available + // safety move back to Auto and then re-read authoritative ownership. + if requiresHardwareRestore { + let hardwareRestoreResult = await coordinator.forceAuto() + if case .failed(let message) = hardwareRestoreResult { + failures.append(message) + } + + do { + let status = try await coordinator.fanControlOwnershipStatus() + fanControlOwnershipStatus = status + fanControlOwnershipStatusError = nil + if !FanControlCoordinator.confirmsCleanOSOwnership(status) { + failures.append( + "Auto restore did not reach clean daemon-confirmed macOS fan ownership." + ) + } + } catch { + fanControlOwnershipStatus = nil + fanControlOwnershipStatusError = error.localizedDescription + failures.append( + "Could not confirm daemon fan-control ownership after Auto restore: \(error.localizedDescription)" + ) + } + } + await refreshAgentControlStatus() + await syncState() + + guard !failures.isEmpty else { + recordAutoRestorationApplied() + ViftyLog.fanControl.notice("Termination restore confirmed") + return .restored + } + + let message = failures.joined(separator: "\n") + lastError = message + await notifyAutoRestoreFailure(message) + selectedMode = modeSelection(for: controlState.mode) + resumePollingAfterTerminationFailure(wasRunning: wasRunning) + ViftyLog.fanControl.error("Termination restore failed") + return .failed(message: message) + } + + func modeSelection(for mode: FanMode) -> ModeSelection { + switch mode { + case .auto: + .auto + case .fixedRPM: + .fixed + case .temperatureCurve: + .curve + } + } + + func resumePollingAfterTerminationFailure(wasRunning: Bool) { + guard wasRunning, !pollingController.isRunning else { return } + let started = pollingController.start( + interval: { [weak self] in + self?.backgroundPollInterval() ?? .seconds(10) + }, + poll: { [weak self] operation in + await self?.performPollOnce(operation: operation) + } + ) + isRunning = started + } + +} diff --git a/Sources/Vifty/AppModel+MenuBar.swift b/Sources/Vifty/AppModel+MenuBar.swift new file mode 100644 index 00000000..1570681b --- /dev/null +++ b/Sources/Vifty/AppModel+MenuBar.swift @@ -0,0 +1,700 @@ +import Foundation +import SwiftUI +import ViftyCore + +@MainActor +extension AppModel { + var selectedSensor: TemperatureSensor? { + curveTemperatureSelection.curveMetric + } + + var effectiveSelectedSensorID: String? { + selectedSensor?.id + } + + var curveTemperatureSelection: TemperatureSensorSelection { + TemperatureSensorSelection.resolve( + sensors: snapshot?.temperatureSensors ?? [], + selectedSensorID: userSelectedSensorID + ) + } + + var resolvedCurveSensorID: String? { + guard snapshot?.temperatureSensors.isEmpty == false else { return selectedSensorID } + return effectiveSelectedSensorID + } + + var temperatureAttentionSummary: String? { + guard thermalPressure.menuSummary == nil else { return nil } + guard let sensor = snapshot?.highestTemperature else { return nil } + return sensor.celsius >= Self.highTemperatureAttentionThreshold ? "High temp" : nil + } + + var fanWriteBlockedWhileHotSummary: String? { + guard helperWritePathBlockedSummary != nil, + let sensor = snapshot?.highestTemperature, + sensor.celsius >= Self.highTemperatureAttentionThreshold else { + return nil + } + return "High temp · fan writes blocked" + } + + var fanWriteBlockedWhileHotRecoverySuggestion: String? { + guard fanWriteBlockedWhileHotSummary != nil else { return nil } + switch controlState.mode { + case .auto: + return "Reduce heavy work now. Keep Auto selected, then Repair/Reinstall Helper; writes stay blocked until the daemon responds." + case .fixedRPM, .temperatureCurve: + return "Reduce heavy work now. Repair/Reinstall Helper; Vifty will retry \(manualModeName) when the daemon responds. Use \(autoRestoreActionTitle) to stop retries." + } + } + + var visibleLastError: String? { + guard let lastError else { return nil } + guard !lastErrorIsCoveredByHelperRecovery(lastError) else { return nil } + return lastError + } + + var resolvedMenuBarPresentation: MenuBarPresentation { + let currentDate = now() + let lease = agentControlStatus?.activeLease + return MenuBarPresentationProvider.resolve(MenuBarPresentationInput( + displayMode: menuBarDisplayMode, + customFields: menuBarCustomFields, + snapshotIsAvailable: snapshot != nil, + selectedTemperature: selectedSensor ?? snapshot?.highestTemperature, + selectedTemperatureLabel: curveTemperatureSelection.curveMetricLabel, + fans: snapshot?.fans ?? [], + power: powerSnapshot, + thermalPressure: thermalPressure, + temperatureAttentionSummary: temperatureAttentionSummary, + fanWriteBlockedWhileHotSummary: fanWriteBlockedWhileHotSummary, + helperState: helperHealthState, + hasCompletedHardwarePoll: hasCompletedHardwarePoll, + daemonReachable: daemonReachable, + daemonResponding: daemonResponding, + lastErrorIsPresent: lastError != nil, + agentCoolingMenuSummary: agentCoolingMenuSummary, + agentStatusIsUnavailable: agentControlStatusError != nil, + shouldPreferHelperRecoveryOverAgentStatusError: shouldPreferHelperRecoveryOverAgentStatusError, + hasAgentLease: lease != nil, + agentLeaseNeedsAttention: agentControlStatusError != nil + || lease.map { !$0.isActive(at: currentDate) } == true, + fanControlOwnershipStatus: fanControlOwnershipStatus, + controlMode: controlState.mode, + controlOwnershipNeedsAttention: controlOwnershipNeedsAttention, + autoHardwareModeIsUncertain: !autoForcedModeFans.isEmpty + || !autoUnknownModeFans.isEmpty + || !autoMissingModeFans.isEmpty, + codexUsageSnapshot: codexUsageSnapshot, + codexUsageDisplayPreferences: codexUsageDisplayPreferences, + currentDate: currentDate + )) + } + + var menuBarPanelAttentionText: String? { + resolvedMenuBarPresentation.panelAttentionText + } + + var menuTitle: String { + resolvedMenuBarPresentation.title + } + + var menuPanelTitle: String { + resolvedMenuBarPresentation.panelTitle + } + + var menuBarLabelText: String { + resolvedMenuBarPresentation.labelText + } + + var menuBarStatusItemText: String? { + resolvedMenuBarPresentation.statusItemText + } + + var menuBarLabelNeedsTelemetryPrime: Bool { + resolvedMenuBarPresentation.labelNeedsTelemetryPrime + } + + var menuBarDisplaysCodexUsage: Bool { + MenuBarPresentationProvider.displaysCodexUsage( + menuBarDisplayMode, + customFields: menuBarCustomFields + ) + } + + var menuBarAllowsPlaceholderStatusItemText: Bool { + resolvedMenuBarPresentation.allowsPlaceholderStatusItemText + } + + func isMenuBarCustomFieldEnabled(_ field: MenuBarField) -> Bool { + menuBarCustomFields.contains(field) + } + + func setMenuBarCustomField(_ field: MenuBarField, enabled: Bool) { + var fields = menuBarCustomFields + if enabled { + guard !fields.contains(field) else { return } + fields.append(field) + } else { + fields.removeAll { $0 == field } + guard !fields.isEmpty else { return } + } + menuBarCustomFields = MenuBarField.normalized(fields) + } + + var codexUsageSummary: String { + CodexUsageFormatter.summaryText( + for: codexUsageSnapshot, + options: codexUsageDisplayPreferences, + now: now + ) + } + + var codexUsageDetailSummary: String? { + CodexUsageFormatter.detailText( + for: codexUsageSnapshot, + options: codexUsageDisplayPreferences + ) + } + + var codexUsageDetailLines: [String] { + CodexUsageFormatter.detailLines( + for: codexUsageSnapshot, + options: codexUsageDisplayPreferences, + now: now + ) + } + + var menuBarFanOwnerText: String { + resolvedMenuBarPresentation.fanOwnerText + } + + var menuBarLabelUsesFanIcon: Bool { + resolvedMenuBarPresentation.labelUsesFanIcon + } + + func persistAppPreferences() { + preferencesStore.save(AppPreferences( + menuBarDisplayMode: menuBarDisplayMode, + menuBarCustomFields: menuBarCustomFields, + startupMode: startupMode, + textScale: textScale, + notificationSettings: notificationSettings, + usePerFanFixedRPM: usePerFanFixedRPM, + fixedFanTargets: fixedFanTargets, + codexUsageDisplayPreferences: codexUsageDisplayPreferences + )) + } + + var codexUsageDisplayPreferences: CodexUsageDisplayPreferences { + CodexUsageDisplayPreferences( + displayStyle: codexUsageDisplayStyle, + metricMode: codexUsageMetricMode, + resetMode: codexUsageResetMode, + refreshCadence: codexUsageRefreshCadence + ) + } + + func codexUsageDisplayPreferenceDidChange() { + persistAppPreferences() + if menuBarDisplaysCodexUsage { + refreshMenuBarStatusItemIfNeeded() + } + } + + var helperWritePathBlockedSummary: String? { + helperHealthState.writePathBlockedSummary + } + + func lastErrorIsCoveredByHelperRecovery(_ error: String) -> Bool { + guard helperWritePathBlockedSummary != nil else { return false } + return error.lowercased().hasPrefix("manual fan control blocked:") + } + + var shouldPreferHelperRecoveryOverAgentStatusError: Bool { + agentControlStatus?.activeLease == nil + && agentControlStatusError != nil + && helperWritePathBlockedSummary != nil + } + + var agentCoolingMenuSummary: String? { + guard let lease = agentControlStatus?.activeLease else { + if shouldPreferHelperRecoveryOverAgentStatusError { return nil } + return agentControlStatusError == nil ? nil : "Agent status unavailable" + } + if agentControlStatusError != nil { + return "Agent status warning" + } + return lease.isActive(at: now()) ? "Agent cooling" : "Agent restore pending" + } + + var agentCoolingPanelTitle: String { + if agentControlStatusError != nil { + if shouldPreferHelperRecoveryOverAgentStatusError { return "Agent cooling unavailable" } + return agentControlStatus?.activeLease == nil ? "Agent status unavailable" : "Agent status warning" + } + return agentCoolingNeedsAttention ? "Agent restore pending" : "Agent cooling active" + } + + var agentCoolingSummary: String? { + guard let lease = agentControlStatus?.activeLease else { + if shouldPreferHelperRecoveryOverAgentStatusError { return nil } + guard agentControlStatusError != nil else { return nil } + return "Agent cooling status unavailable; repair helper before requesting cooling." + } + + let state = if lease.isActive(at: now()) { + "Agent \(lease.request.workload.displayName) cooling until \(lease.expiresAt.formatted(date: .omitted, time: .shortened))" + } else { + "Agent \(lease.request.workload.displayName) cooling expired; waiting for Auto restore" + } + + let targets = lease.targetRPMByFanID + .sorted { $0.key < $1.key } + .map { "F\($0.key) \($0.value) RPM" } + .joined(separator: ", ") + + let baseSummary = targets.isEmpty ? state : "\(state) · \(targets)" + if agentControlStatusError != nil { + return "\(baseSummary) · status refresh failed; do not start another workload" + } + return baseSummary + } + + var agentCoolingRecoverySuggestion: String? { + if agentControlStatusError != nil { + if shouldPreferHelperRecoveryOverAgentStatusError { return nil } + guard agentControlStatus?.activeLease != nil else { + return "Repair Helper before requesting agent cooling." + } + return "Do not start another workload; use Auto to restore cooling, then check viftyctl status/audit after helper repair." + } + guard let lease = agentControlStatus?.activeLease, !lease.isActive(at: now()) else { + return nil + } + return "Use Auto to restore daemon control before starting another workload." + } + + var agentCoolingRestoreActionAvailable: Bool { + agentControlStatus?.activeLease != nil + } + + var autoRestoreActionTitle: String { + helperWritePathBlockedSummary == nil ? "Auto" : "Request Auto" + } + + var autoRestoreActionHelp: String { + if helperWritePathBlockedSummary == nil { + return "Restore Auto" + } + return "Request Auto restore; the write cannot be confirmed until the helper responds" + } + + var modeSelectionActionTitle: String { + modeSelectionActionRestoresAuto ? autoRestoreActionTitle : "Apply" + } + + var modeSelectionActionHelp: String { + modeSelectionActionRestoresAuto + ? autoRestoreActionHelp + : (manualFanControlBlockedReason ?? "Apply selected fan mode") + } + + var modeSelectionActionRestoresAuto: Bool { + selectedMode == .auto || manualControlAttentionSummary != nil + } + + var modeSelectionActionDisabled: Bool { + !modeSelectionActionRestoresAuto && !manualFanControlAvailable + } + + var agentCoolingRestoreActionTitle: String { + autoRestoreActionTitle + } + + var agentCoolingRestoreActionHelp: String { + if helperWritePathBlockedSummary == nil { + return "Restore Auto before starting another agent workload" + } + return autoRestoreActionHelp + } + + var agentCoolingNeedsAttention: Bool { + if agentControlStatusError != nil { + if shouldPreferHelperRecoveryOverAgentStatusError { return false } + return true + } + guard let lease = agentControlStatus?.activeLease else { return false } + return !lease.isActive(at: now()) + } + + var controlOwnershipSummary: String { + if let lease = agentControlStatus?.activeLease { + let statusWarning = agentControlStatusError == nil ? "" : " · status refresh failed" + if lease.isActive(at: now()) { + return "Agent \(lease.request.workload.displayName) owns cooling until \(lease.expiresAt.formatted(date: .omitted, time: .shortened))\(statusWarning)" + } + return "Agent \(lease.request.workload.displayName) lease expired; restore Auto to clear daemon control\(statusWarning)" + } + + if let manualHelperWriteBlockedSummary { + return manualHelperWriteBlockedSummary + } + + if controlState.mode == .auto, helperWritePathBlockedSummary != nil { + return autoControlOwnershipSummary + } + + if agentControlStatusError != nil { + return "Agent control status unavailable; fan ownership uncertain" + } + + switch controlState.mode { + case .auto: + return autoControlOwnershipSummary + case .fixedRPM(let rpm): + if let manualControlDriftSummary { + return manualControlDriftSummary + } + return "Vifty Fixed owns fan targets · \(fixedModeTargetSummary(fallbackRPM: rpm)) · \(manualRunOwnershipSummary)" + case .temperatureCurve: + if let manualControlDriftSummary { + return manualControlDriftSummary + } + if let sensor = selectedSensor { + return "Vifty Curve owns fan targets · \(sensor.name) · \(manualRunOwnershipSummary)" + } + return "Vifty Curve owns fan targets · \(manualRunOwnershipSummary)" + } + } + + var compactControlOwnershipSummary: String { + if let lease = agentControlStatus?.activeLease { + if lease.isActive(at: now()) { + return "Owner: Agent until \(lease.expiresAt.formatted(date: .omitted, time: .shortened))" + } + return "Owner: Agent restore pending" + } + + if let manualHelperWriteBlockedSummary { + return manualHelperWriteBlockedSummary + } + + if controlState.mode == .auto, helperWritePathBlockedSummary != nil { + return "Owner: Mac?" + } + + if agentControlStatusError != nil { + return "Owner: uncertain" + } + + switch controlState.mode { + case .auto: + return "Owner: \(menuBarFanOwnerText)" + case .fixedRPM: + return "Owner: Vifty Fixed" + case .temperatureCurve: + return "Owner: Vifty Curve" + } + } + + var controlOwnershipNeedsAttention: Bool { + if let lease = agentControlStatus?.activeLease { + return agentControlStatusError != nil || !lease.isActive(at: now()) + } + if agentControlStatusError != nil { + return true + } + + guard controlState.mode == .auto else { + return manualHelperWriteBlockedSummary != nil || manualControlDriftSummary != nil + } + guard let fans = snapshot?.fans, !fans.isEmpty else { + return hasCompletedHardwarePoll || daemonReachable + } + return !autoSystemModeFans.isEmpty + || !autoForcedModeFans.isEmpty + || !autoUnknownModeFans.isEmpty + || !autoMissingModeFans.isEmpty + || helperWritePathBlockedSummary != nil + } + + var helperHealthState: HelperHealthState { + HelperHealthPresentation.resolve(HelperHealthPresentationInput( + hardwareIsSupported: snapshot.map { $0.isAppleSilicon && $0.isMacBookPro }, + hasCompletedHardwarePoll: hasCompletedHardwarePoll, + daemonReachable: daemonReachable, + daemonResponding: daemonResponding, + fanCount: snapshot?.fans.count ?? 0, + hasControllableFan: snapshot?.fans.contains(where: \.controllable) == true, + lastError: lastError + )) + } + + var helperHealthSummary: String { + helperHealthState.summary + } + + var helperHealthMenuSummary: String { + helperHealthState.menuSummary + } + + var helperHealthNeedsAttention: Bool { + helperHealthState.needsAttention + } + + var helperRepairActionAvailable: Bool { + helperHealthState.repairActionAvailable + } + + var helperRecoverySuggestion: String? { + helperHealthState.recoverySuggestion + } + + var helperInstallRuntimeContext: String? { + helperHealthState.installRuntimeContext + } + + var helperFailureNotificationBody: String { + fanWriteBlockedWhileHotRecoverySuggestion + ?? helperRecoverySuggestion + ?? "Repair or approve the fan helper before requesting fan control." + } + + var helperFailureNotificationTitle: String { + fanWriteBlockedWhileHotSummary == nil + ? "Vifty fan helper needs attention" + : "Vifty fan writes are blocked while hot" + } + + var helperSupportEvidenceContext: HelperSupportEvidenceContext { + var lines: [String] = [ + "selectedMode=\(selectedMode.rawValue)", + "manualRun=\(manualRunLimit.label)", + "daemon=reachable=\(daemonReachable) responding=\(daemonResponding)", + "helper=\(helperHealthSummary)", + "controlOwner=\(controlOwnershipSummary)" + ] + + if let helperInstallRuntimeContext { + lines.append("helperRuntime=\(helperInstallRuntimeContext)") + } + if let helperRecoverySuggestion { + lines.append("helperRecovery=\(helperRecoverySuggestion)") + } + if let fanWriteBlockedWhileHotSummary { + lines.append("hotFanWrites=\(fanWriteBlockedWhileHotSummary)") + } + if let fanWriteBlockedWhileHotRecoverySuggestion { + lines.append("hotRecovery=\(fanWriteBlockedWhileHotRecoverySuggestion)") + } + if let sensor = selectedSensor ?? snapshot?.highestTemperature { + lines.append("selectedTemperature=\(sensor.name) \(String(format: "%.1f", sensor.celsius)) C") + } + if let fans = snapshot?.fans, !fans.isEmpty { + let fanSummary = fans + .map { "\($0.name) \($0.currentRPM) RPM (\($0.percentage)%)" } + .joined(separator: "; ") + lines.append("fans=\(fanSummary)") + } + if let agentCoolingSummary { + lines.append("agentCooling=\(agentCoolingSummary)") + } + if let lastError { + lines.append("lastError=\(lastError)") + } + + return HelperSupportEvidenceContext(lines: lines) + } + + var helperMenuRecoverySuggestion: String? { + if fanWriteBlockedWhileHotSummary != nil { + return nil + } + return helperHealthState.menuRecoverySuggestion + } + + var manualFanControlAvailable: Bool { + manualFanControlBlockedReason == nil + } + + var manualFanControlBlockedReason: String? { + guard let snapshot else { + return daemonResponding + ? "Waiting for fan telemetry before manual fan control." + : "Install or repair the fan helper before manual fan control." + } + guard snapshot.isAppleSilicon, snapshot.isMacBookPro else { + return "Unsupported hardware. Manual fan control stays blocked." + } + if helperHealthState == .runtimeMismatch { + return "Repair/Reinstall Helper before manual fan control; the installed helper does not match this Vifty app." + } + guard daemonResponding else { + return daemonReachable + ? "Repair/Reinstall Helper before manual fan control; fan telemetry is available but daemon writes are blocked." + : "Install or repair the fan helper before manual fan control." + } + if let lease = agentControlStatus?.activeLease { + if lease.isActive(at: now()) { + return "Agent \(lease.request.workload.displayName) cooling owns fan control; restore Auto before manual fan control." + } + return "Agent cooling restore is pending; restore Auto before manual fan control." + } + if agentControlStatusError != nil { + return "Agent control status is unavailable; repair helper before manual fan control." + } + guard !snapshot.fans.isEmpty else { + return "Fan data is unavailable. Manual fan control stays blocked." + } + guard snapshot.fans.contains(where: \.controllable) else { + return "No controllable fans are available. Manual fan control stays blocked." + } + guard !snapshot.temperatureSensors.isEmpty else { + return "Temperature sensors are unavailable. Manual fan control stays blocked." + } + let confirmedOwnership = FanControlOwnershipPresentation.resolve(fanControlOwnershipStatus) + switch confirmedOwnership.owner { + case .macOS, .viftyManual: + break + case .agent: + return "Agent cooling owns fan control; restore Auto before manual fan control." + case .recovery: + return "Fan recovery is pending; restore Auto before manual fan control." + case .mixedOrUnknown: + return "Fan ownership is unconfirmed; manual fan control stays blocked." + } + return nil + } + + var manualControlAttentionSummary: String? { + guard controlState.mode != .auto, + helperWritePathBlockedSummary != nil else { + return nil + } + return "\(manualModeName) request pending · fan writes blocked" + } + + var manualControlAttentionRecoverySuggestion: String? { + guard manualControlAttentionSummary != nil else { return nil } + return "Vifty will retry \(manualModeName) when the helper responds. Use \(autoRestoreActionTitle) to stop retries; copy support evidence if repair does not clear it." + } + + var manualHelperWriteBlockedSummary: String? { + guard controlState.mode != .auto, + let helperWritePathBlockedSummary else { + return nil + } + return "\(helperWritePathBlockedSummary) · Vifty will retry \(manualModeName) when the helper responds" + } + + var manualModeName: String { + switch controlState.mode { + case .auto: + return "manual control" + case .fixedRPM: + return "Fixed" + case .temperatureCurve: + return "Curve" + } + } + + var manualRunOwnershipSummary: String { + let runLimitSummary = manualSessionExpiresAt.map { + "until \($0.formatted(date: .omitted, time: .shortened))" + } ?? "until changed" + return "\(runLimitSummary); reasserts if macOS drifts" + } + + var autoControlOwnershipSummary: String { + guard let fans = snapshot?.fans, !fans.isEmpty else { + return daemonReachable + ? "Auto selected · fan hardware state unavailable" + : "Auto selected · fan writes blocked until helper responds" + } + + if let helperWritePathBlockedSummary { + return helperWritePathBlockedSummary + } + if !autoSystemModeFans.isEmpty { + return "macOS System/protected owns fan control · \(fanIDList(autoSystemModeFans))" + } + if !autoForcedModeFans.isEmpty { + return "Hardware reports Forced mode while Vifty is Auto · \(fanIDList(autoForcedModeFans))" + } + if !autoUnknownModeFans.isEmpty { + return "Auto selected · unknown hardware mode on \(fanIDList(autoUnknownModeFans))" + } + if !autoMissingModeFans.isEmpty { + return "Auto selected · hardware mode unavailable on \(fanIDList(autoMissingModeFans))" + } + return "macOS Auto owns fan control" + } + + var manualControlDriftSummary: String? { + guard controlState.mode != .auto, + let fans = snapshot?.fans.filter(\.controllable), + !fans.isEmpty else { + return nil + } + + let reclaimed = fans.filter { fan in + guard let mode = fan.hardwareMode else { return false } + return mode != .forced + } + if !reclaimed.isEmpty { + let modes = reclaimed.reduce(into: [String]()) { names, fan in + guard let name = fan.hardwareMode?.displayName, !names.contains(name) else { return } + names.append(name) + }.joined(separator: "/") + let modeLabel = modes.isEmpty ? "non-forced mode" : modes + return "Hardware reports \(modeLabel) while Vifty manual is selected; Vifty will reassert · \(fanIDList(reclaimed))" + } + + let drifted = manualTargetDriftAttentionFans + if !drifted.isEmpty { + return "Hardware fan target drift detected; Vifty will reassert · \(fanIDList(drifted))" + } + + let unconfirmedResponse = fans.filter { fan in + guard fan.targetRPM == nil, + manualResponseAttentionIsWarranted, + let expectedRPM = expectedManualTargetRPM(for: fan) else { + return false + } + return expectedRPM - fan.currentRPM >= Self.manualResponseRPMGapThreshold + } + guard !unconfirmedResponse.isEmpty else { return nil } + return "Manual fan response not confirmed; current RPM is still below requested target · \(fanIDList(unconfirmedResponse))" + } + + var currentManualTargetDriftFans: [Fan] { + guard controlState.mode != .auto, + let fans = snapshot?.fans.filter(\.controllable), + !fans.isEmpty else { + return [] + } + + return fans.filter { fan in + guard !manualTargetSettlingFanIDs.contains(fan.id), + fan.hardwareMode == .forced, + let targetRPM = fan.targetRPM, + let expectedRPM = expectedManualTargetRPM(for: fan) else { + return false + } + return abs(targetRPM - expectedRPM) >= Self.manualTargetDriftRPMThreshold + } + } + + var manualTargetDriftAttentionFans: [Fan] { + let drifted = currentManualTargetDriftFans + if !hasCompletedHardwarePoll { + return drifted + } + return drifted.filter { fan in + (manualTargetDriftSampleCounts[fan.id] ?? 0) >= Self.manualTargetDriftAttentionSampleCount + } + } + +} diff --git a/Sources/Vifty/AppModel+Notifications.swift b/Sources/Vifty/AppModel+Notifications.swift new file mode 100644 index 00000000..c8db1b5f --- /dev/null +++ b/Sources/Vifty/AppModel+Notifications.swift @@ -0,0 +1,69 @@ +import Foundation +import SwiftUI +import ViftyCore + +@MainActor +extension AppModel { + func syncState() async { + assignIfChanged(\.controlState, await coordinator.state) + manualTargetSettlingFanIDs = await coordinator.recentManualWriteFanIDs( + at: now(), + within: Self.manualTargetWriteSettleInterval + ) + updateManualTargetDriftStability() + } + + func evaluateLocalNotifications(power: PowerSnapshot, thermalPressure: ThermalPressure) async { + if let authorization = await localNotificationCoordinator.evaluate( + settings: notificationSettings, + input: LocalNotificationEvaluationInput( + helperNeedsAttention: helperHealthState.notifiesAsHelperFailure, + helperTitle: helperFailureNotificationTitle, + helperBody: helperFailureNotificationBody, + agentNeedsAttention: agentCoolingNeedsAttention, + agentBody: agentCoolingRecoverySuggestion + ?? agentCoolingSummary + ?? "Check Vifty before starting another developer workload.", + power: power, + thermalPressure: thermalPressure + ) + ) { + notificationAuthorization = authorization + } + } + + func notifyAutoRestoreFailure(_ message: String) async { + if let authorization = await localNotificationCoordinator.notifyAutoRestoreFailure( + message, + settings: notificationSettings + ) { + notificationAuthorization = authorization + } + } + + func setNotificationEnabled(_ kind: LocalNotificationKind, isEnabled: Bool) { + notificationSettings.set(kind, enabled: isEnabled) + guard isEnabled else { return } + Task { [weak self] in + guard let self else { return } + let status = await localNotificationCoordinator.requestAuthorization() + notificationAuthorization = status + } + } + + func refreshNotificationAuthorization() async { + notificationAuthorization = await localNotificationCoordinator.authorizationStatus() + } + + func sendTestNotification() async { + let result = await localNotificationCoordinator.sendTestNotification() + notificationAuthorization = result.authorization + notificationTestMessage = result.delivered + ? "Test notification sent." + : "Test notification was not delivered." + } + + func openNotificationSettings() async { + notificationAuthorization = await localNotificationCoordinator.openSettings() + } +} diff --git a/Sources/Vifty/AppModel+Polling.swift b/Sources/Vifty/AppModel+Polling.swift new file mode 100644 index 00000000..78b95448 --- /dev/null +++ b/Sources/Vifty/AppModel+Polling.swift @@ -0,0 +1,281 @@ +import Foundation +import SwiftUI +import ViftyCore + +@MainActor +extension AppModel { + func pollOnce() async { + await pollingController.pollOnce { [weak self] operation in + await self?.performPollOnce(operation: operation) + } + } + + func performPollOnce( + operation: AppPollingOperation, + reconcileManualApply: Bool = true + ) async { + guard pollingController.isCurrent(operation) else { return } + let pollingIntervalState = ViftyLog.pollingSignposter.beginInterval("Hardware poll") + defer { + ViftyLog.pollingSignposter.endInterval("Hardware poll", pollingIntervalState) + } + let pollStartedAt = now() + let shouldRefreshPowerTelemetry = shouldRefresh( + lastRefreshAt: lastPowerTelemetryRefreshAt, + interval: powerTelemetryRefreshInterval, + at: pollStartedAt + ) + let currentPower: PowerSnapshot + let currentThermalPressure: ThermalPressure + if shouldRefreshPowerTelemetry { + currentPower = powerReader() + currentThermalPressure = thermalReader() + lastPowerTelemetryRefreshAt = pollStartedAt + assignIfChanged(\.powerSnapshot, currentPower) + assignIfChanged(\.thermalPressure, currentThermalPressure) + } else { + currentPower = powerSnapshot ?? PowerSnapshot() + currentThermalPressure = thermalPressure + } + + defer { + if pollingController.isCurrent(operation) { + hasCompletedHardwarePoll = true + refreshMenuBarStatusItemIfNeeded() + } + } + refreshCodexUsageIfNeeded() + let expiredAutoOperationGeneration = await restoreAutoIfManualSessionExpired() + guard pollingController.isCurrent(operation) else { return } + let stateBeforeTick = await coordinator.state + guard pollingController.isCurrent(operation) else { return } + do { + let nextSnapshot = try await coordinator.tick() + guard pollingController.isCurrent(operation) else { return } + recordHardwareSnapshot(nextSnapshot, power: currentPower, thermalPressure: currentThermalPressure) + assignIfChanged(\.lastError, nil) + await refreshDaemonPingIfNeeded(at: pollStartedAt, force: false) + guard pollingController.isCurrent(operation) else { return } + assignIfChanged(\.daemonReachable, daemonResponding || !nextSnapshot.fans.isEmpty) + await refreshAgentControlStatusIfNeeded(at: pollStartedAt) + guard pollingController.isCurrent(operation) else { return } + await refreshFanControlOwnershipStatus() + guard pollingController.isCurrent(operation) else { return } + assignIfChanged(\.fanAccessMessage, fanAccessMessage(for: nextSnapshot)) + await syncState() + guard pollingController.isCurrent(operation) else { return } + if let expiredAutoOperationGeneration, + canCommitAutoRestoration(generation: expiredAutoOperationGeneration) { + recordAutoRestorationApplied() + } else if reconcileManualApply { + reconcileManualApplyAttemptAfterSuccessfulPoll() + } + await evaluateLocalNotifications(power: currentPower, thermalPressure: currentThermalPressure) + ViftyLog.polling.debug("Hardware poll completed") + } catch { + guard pollingController.isCurrent(operation) else { return } + ViftyLog.polling.warning("Hardware poll failed") + let preservesManualIntent = shouldPreserveManualIntent( + afterTickFailure: error, + attemptedMode: stateBeforeTick.mode + ) + if preservesManualIntent, let observedSnapshot = await coordinator.lastObservedSnapshot { + recordHardwareSnapshot(observedSnapshot, power: currentPower, thermalPressure: currentThermalPressure) + } + assignIfChanged(\.lastError, error.localizedDescription) + await refreshDaemonPingIfNeeded(at: pollStartedAt, force: true) + guard pollingController.isCurrent(operation) else { return } + assignIfChanged(\.daemonReachable, daemonResponding || (preservesManualIntent && snapshot?.fans.isEmpty == false)) + await refreshAgentControlStatusIfNeeded(at: pollStartedAt, force: true) + guard pollingController.isCurrent(operation) else { return } + await refreshFanControlOwnershipStatus() + guard pollingController.isCurrent(operation) else { return } + if preservesManualIntent, let snapshot { + assignIfChanged(\.fanAccessMessage, fanAccessMessage(for: snapshot)) + } + if preservesManualIntent { + if selectedMode != .auto { + let intendedMode: FanMode = stateBeforeTick.mode == .auto ? selectedFanMode() : stateBeforeTick.mode + await coordinator.setMode(intendedMode) + guard pollingController.isCurrent(operation) else { return } + } + assignIfChanged(\.controlState, await coordinator.state) + } else if selectedMode == .auto, + expiredAutoOperationGeneration == nil { + // The coordinator tick already attempted the current Auto + // transaction. Do not double the retry rate with a second + // forceAuto request in the same poll; later polls may make one + // authority-free safety retry. + await syncState() + } else { + let fallbackAutoRestoreResult = await coordinator.forceAuto() + guard pollingController.isCurrent(operation) else { return } + await syncState() + guard pollingController.isCurrent(operation) else { return } + + if let expiredAutoOperationGeneration, + fanControlOperationIsCurrent(expiredAutoOperationGeneration) { + switch fallbackAutoRestoreResult { + case .restored: + if canCommitAutoRestoration(generation: expiredAutoOperationGeneration) { + recordAutoRestorationApplied() + } else { + let message = "\(error.localizedDescription)\nFallback Auto restore could not be confirmed." + assignIfChanged(\.lastError, message) + fanControlApplyState = .failed(message: message) + } + case .failed(let fallbackMessage): + let message = "\(error.localizedDescription)\nFallback Auto restore failed: \(fallbackMessage)" + assignIfChanged(\.lastError, message) + fanControlApplyState = .failed(message: message) + } + } + } + await evaluateLocalNotifications(power: currentPower, thermalPressure: currentThermalPressure) + } + } + + func refreshCodexUsageIfNeeded() { + guard menuBarDisplaysCodexUsage else { + if codexUsageRefreshTask != nil || codexUsageSnapshot != nil { + cancelCodexUsageRefresh(clearSnapshot: true) + } + return + } + guard codexUsageRefreshTask == nil else { return } + + let currentTime = now() + if let lastCodexUsageRefreshAt, + currentTime.timeIntervalSince(lastCodexUsageRefreshAt) < codexUsageRefreshCadence.seconds { + return + } + + lastCodexUsageRefreshAt = currentTime + let reader = codexUsageReader + codexUsageRefreshGeneration &+= 1 + let generation = codexUsageRefreshGeneration + codexUsageRefreshTask = Task { [weak self] in + let snapshot = await Task.detached(priority: .utility) { + reader() + }.value + let wasCancelled = Task.isCancelled + + guard let self else { return } + guard self.codexUsageRefreshGeneration == generation else { return } + self.codexUsageRefreshTask = nil + guard !wasCancelled else { return } + guard self.menuBarDisplaysCodexUsage else { + self.cancelCodexUsageRefresh(clearSnapshot: true) + return + } + + self.assignIfChanged(\.codexUsageSnapshot, snapshot) + self.refreshMenuBarStatusItemIfNeeded() + } + } + + func cancelCodexUsageRefresh(clearSnapshot: Bool) { + codexUsageRefreshGeneration &+= 1 + codexUsageRefreshTask?.cancel() + codexUsageRefreshTask = nil + lastCodexUsageRefreshAt = nil + if clearSnapshot { + assignIfChanged(\.codexUsageSnapshot, nil) + } + refreshMenuBarStatusItemIfNeeded() + } + + func waitForCodexUsageRefresh() async { + await codexUsageRefreshTask?.value + } + + @discardableResult + func refreshMenuBarStatusItemIfNeeded() -> Bool { + let nextPresentation = currentMenuBarStatusItemPresentation + guard nextPresentation != menuBarStatusItemPresentation else { return false } + menuBarStatusItemPresentation = nextPresentation + menuBarStatusItemRevision &+= 1 + return true + } + + var currentMenuBarStatusItemPresentation: MenuBarStatusItemPresentation { + resolvedMenuBarPresentation.statusItemPresentation + } + + func shouldRefresh(lastRefreshAt: Date?, interval: TimeInterval, at date: Date) -> Bool { + guard let lastRefreshAt else { return true } + return date.timeIntervalSince(lastRefreshAt) >= interval + } + + func backgroundPollInterval() -> Duration { + pollSchedulePolicy.interval( + selectedMode: selectedMode, + controlMode: controlState.mode, + hasAgentLease: agentControlStatus?.activeLease?.isActive(at: now()) == true + ) + } + + func refreshDaemonPingIfNeeded(at date: Date, force: Bool) async { + guard force || !daemonResponding || shouldRefresh( + lastRefreshAt: lastDaemonPingAt, + interval: daemonPingRefreshInterval, + at: date + ) else { + return + } + let responding = await daemonPing() + lastDaemonPingAt = date + assignIfChanged(\.daemonResponding, responding) + } + + func refreshAgentControlStatusIfNeeded(at date: Date, force: Bool = false) async { + let activeLease = agentControlStatus?.activeLease + let activeAgentWork = activeLease?.isActive(at: date) == true || agentControlStatusError != nil + guard force || activeAgentWork || shouldRefresh( + lastRefreshAt: lastAgentStatusRefreshAt, + interval: agentStatusRefreshInterval, + at: date + ) else { + return + } + await refreshAgentControlStatus() + lastAgentStatusRefreshAt = date + } + + @discardableResult + func assignIfChanged( + _ keyPath: ReferenceWritableKeyPath, + _ value: Value + ) -> Bool { + guard self[keyPath: keyPath] != value else { return false } + self[keyPath: keyPath] = value + return true + } + + func recordHardwareSnapshot( + _ nextSnapshot: HardwareSnapshot, + power: PowerSnapshot, + thermalPressure: ThermalPressure + ) { + assignIfChanged(\.snapshot, nextSnapshot) + telemetrySession.record( + snapshot: nextSnapshot, + power: power, + thermalPressure: thermalPressure, + userSelectedSensorID: userSelectedSensorID, + capturedAt: now(), + ) + publishTelemetrySession() + syncCurveDefaultsIfNeeded(from: nextSnapshot) + if usePerFanFixedRPM { + ensureFixedFanTargets(for: nextSnapshot.fans) + } + } + + func fanAccessMessage(for snapshot: HardwareSnapshot) -> String? { + snapshot.fans.isEmpty + ? (daemonResponding ? "The fan helper is running but did not return fan data." : "Install and approve the fan helper to enable fan reads and control.") + : nil + } + +} diff --git a/Sources/Vifty/AppModel+Profiles.swift b/Sources/Vifty/AppModel+Profiles.swift new file mode 100644 index 00000000..6dcfa81e --- /dev/null +++ b/Sources/Vifty/AppModel+Profiles.swift @@ -0,0 +1,155 @@ +import Foundation +import SwiftUI +import ViftyCore + +@MainActor +extension AppModel { + func saveCurrentProfile(name: String) { + guard let result = saveCurrentProfileAs(name: name, confirmOverwrite: false) else { return } + if case .overwriteConfirmationRequired(let existing, _) = result { + lastError = "Confirm before replacing the saved profile \(existing.name)." + } + } + + @discardableResult + func saveCurrentProfileAs( + name: String, + confirmOverwrite: Bool + ) -> CurveProfileSaveResult? { + guard let result = CurveProfileSavePolicy.saveAs( + name: name, + draft: currentCurveProfileDraft, + existingProfiles: savedProfiles, + confirmOverwrite: confirmOverwrite + ) else { return nil } + + switch result { + case .created(let profile): + let proposedProfiles = savedProfiles + [profile] + guard persistProfiles(proposedProfiles) else { + return .persistenceFailed(message: profilePersistenceErrorMessage) + } + savedProfiles = proposedProfiles + selectedCurveProfileID = profile.id + case .updated(let profile): + guard let index = savedProfiles.firstIndex(where: { $0.id == profile.id }) else { + return nil + } + var proposedProfiles = savedProfiles + proposedProfiles[index] = profile + guard persistProfiles(proposedProfiles) else { + return .persistenceFailed(message: profilePersistenceErrorMessage) + } + savedProfiles = proposedProfiles + selectedCurveProfileID = profile.id + case .overwriteConfirmationRequired: + break + case .persistenceFailed: + preconditionFailure("CurveProfileSavePolicy never emits persistence results.") + } + return result + } + + @discardableResult + func updateSelectedCurveProfile() -> Bool { + let selected = selectedCurveProfileID.flatMap { selectedID in + savedProfiles.first { $0.id == selectedID } + } + guard case .updated(let profile)? = CurveProfileSavePolicy.update( + selectedProfile: selected, + draft: currentCurveProfileDraft + ), let index = savedProfiles.firstIndex(where: { $0.id == profile.id }) else { + return false + } + + var proposedProfiles = savedProfiles + proposedProfiles[index] = profile + guard persistProfiles(proposedProfiles) else { return false } + savedProfiles = proposedProfiles + selectedCurveProfileID = profile.id + return true + } + + func loadProfile(_ profile: CurveProfile) { + curveStartTemp = profile.startTemp + curveStartRPM = Double(profile.startRPM) + curveMidTemp = profile.midTemp + curveMidRPM = Double(profile.midRPM) + curveMaxTemp = profile.maxTemp + curveMaxRPM = Double(profile.maxRPM) + selectedSensorID = profile.sensorID + fanOverrides = profile.fanOverrides + usePerFanOverrides = !profile.fanOverrides.isEmpty + if let fans = snapshot?.fans, usePerFanOverrides { + ensureFanOverrides(for: fans) + } + markFanControlDraftPending() + } + + @discardableResult + func selectCurveProfile(id profileID: CurveProfile.ID?) -> Bool { + guard let profileID else { + selectedCurveProfileID = nil + return true + } + guard let profile = savedProfiles.first(where: { $0.id == profileID }) else { + return false + } + selectedCurveProfileID = profileID + selectedMode = .curve + loadProfile(profile) + return true + } + + func loadDeveloperPreset(_ preset: DeveloperFanPreset) { + selectedCurveProfileID = nil + selectedMode = .curve + curveStartTemp = preset.startTemperatureCelsius + curveMidTemp = preset.midTemperatureCelsius + curveMaxTemp = preset.maxTemperatureCelsius + curveStartRPM = Double(rpm(forPercent: preset.startRPMPercent)) + curveMidRPM = Double(rpm(forPercent: preset.midRPMPercent)) + curveMaxRPM = Double(rpm(forPercent: preset.maxRPMPercent)) + if selectedSensorID == nil { + selectedSensorID = selectedSensor?.id + } + usePerFanOverrides = false + fanOverrides = [] + curveDefaultsSynced = true + markFanControlDraftPending() + } + + @discardableResult + func deleteProfile(_ profile: CurveProfile) -> Bool { + let proposedProfiles = savedProfiles.filter { $0.id != profile.id } + guard persistProfiles(proposedProfiles) else { return false } + savedProfiles = proposedProfiles + if selectedCurveProfileID == profile.id { + selectedCurveProfileID = nil + } + return true + } + + var profilePersistenceErrorMessage: String { + curveProfilePersistenceError ?? "Failed to save profiles." + } + + @discardableResult + func persistProfiles(_ profiles: [CurveProfile]) -> Bool { + do { + try profileStore.saveThrowing(profiles) + curveProfileRecoveryMessage = nil + if lastError == curveProfilePersistenceError { + lastError = nil + } + curveProfilePersistenceError = nil + return true + } catch { + let message = "Failed to save profiles: \(error.localizedDescription)" + curveProfilePersistenceError = message + lastError = message + return false + } + } + +} diff --git a/Sources/Vifty/AppModel.swift b/Sources/Vifty/AppModel.swift index 1415a83a..d17b0a48 100644 --- a/Sources/Vifty/AppModel.swift +++ b/Sources/Vifty/AppModel.swift @@ -29,7 +29,7 @@ final class AppModel: ObservableObject { } } @Published var lastError: String? - @Published private(set) var curveProfilePersistenceError: String? + @Published var curveProfilePersistenceError: String? @Published var fanAccessMessage: String? @Published var daemonResponding = false @Published var daemonReachable = false @@ -88,8 +88,8 @@ final class AppModel: ObservableObject { persistAppPreferences() } } - @Published private(set) var notificationAuthorization: LocalNotificationAuthorization = .checking - @Published private(set) var notificationTestMessage: String? + @Published var notificationAuthorization: LocalNotificationAuthorization = .checking + @Published var notificationTestMessage: String? @Published var codexUsageSnapshot: CodexUsageSnapshot? @Published var codexUsageDisplayStyle: CodexUsageDisplayStyle { didSet { @@ -112,9 +112,9 @@ final class AppModel: ObservableObject { codexUsageDisplayPreferenceDidChange() } } - @Published private(set) var launchAtLoginStatus: LaunchAtLoginStatus = .disabled - @Published private(set) var launchAtLoginError: String? - private var telemetrySession = TelemetrySession() + @Published var launchAtLoginStatus: LaunchAtLoginStatus = .disabled + @Published var launchAtLoginError: String? + var telemetrySession = TelemetrySession() var telemetryHistory: TelemetryHistory { get { telemetrySession.history } set { @@ -124,29 +124,29 @@ final class AppModel: ObservableObject { } @Published var manualRunLimit: ManualRunLimit = .defaultForManualControl @Published var manualSessionExpiresAt: Date? - @Published private(set) var fanControlApplyState: FanControlApplyState = .applied + @Published var fanControlApplyState: FanControlApplyState = .applied @Published var agentControlStatus: AgentControlStatus? @Published var agentControlStatusError: String? @Published var agentCoolingEnabled: Bool? - @Published private(set) var fanControlOwnershipStatus: FanControlOwnershipStatus? - @Published private(set) var fanControlOwnershipStatusError: String? + @Published var fanControlOwnershipStatus: FanControlOwnershipStatus? + @Published var fanControlOwnershipStatusError: String? @Published var hasCompletedHardwarePoll = false - @Published private(set) var menuBarStatusItemPresentation = MenuBarStatusItemPresentation.placeholder - @Published private(set) var menuBarStatusItemRevision = 0 - @Published private(set) var telemetryOverviewSummary = TelemetryHistorySummary(history: TelemetryHistory()) - @Published private(set) var compactTelemetryOverviewSummary = TelemetryHistorySummary( + @Published var menuBarStatusItemPresentation = MenuBarStatusItemPresentation.placeholder + @Published var menuBarStatusItemRevision = 0 + @Published var telemetryOverviewSummary = TelemetryHistorySummary(history: TelemetryHistory()) + @Published var compactTelemetryOverviewSummary = TelemetryHistorySummary( history: TelemetryHistory(), sampleLimit: 90, thermalPressureLimit: 24 ) - @Published private(set) var recentTelemetryTrendSummary: String? + @Published var recentTelemetryTrendSummary: String? var curveDefaultsSynced = false // internal, accessible via @testable import @Published var savedProfiles: [CurveProfile] = [] @Published var selectedCurveProfileID: CurveProfile.ID? - @Published private(set) var curveProfileRecoveryMessage: String? - private var isSettingSelectedSensorProgrammatically = false - private var userSelectedSensorID: String? - private var fanControlSessionController: FanControlSessionController + @Published var curveProfileRecoveryMessage: String? + var isSettingSelectedSensorProgrammatically = false + var userSelectedSensorID: String? + var fanControlSessionController: FanControlSessionController static let menuBarDisplayModeDefaultsKey = AppPreferencesStore.legacyMenuBarDisplayModeDefaultsKey static let highTemperatureAttentionThreshold = 90.0 @@ -161,33 +161,33 @@ final class AppModel: ObservableObject { static let manualResponseRPMGapThreshold = 250 static let defaultCodexUsageRefreshInterval: TimeInterval = 5 * 60 - private let coordinator: FanControlCoordinator - private let powerReader: @Sendable () -> PowerSnapshot - private let thermalReader: @Sendable () -> ThermalPressure - private let codexUsageReader: @Sendable () -> CodexUsageSnapshot? - private let now: @Sendable () -> Date - private let localNotificationCoordinator: LocalNotificationCoordinator - private let launchAtLoginManager: LaunchAtLoginManaging - private let daemonPing: @Sendable () async -> Bool - private let agentStatusReader: @Sendable () async throws -> AgentControlStatus? - private let agentPolicySetter: @Sendable (Bool) async throws -> AgentControlStatus? - private let agentRestore: @Sendable (String) async throws -> AgentControlStatus? - private let profileStore: CurveProfileStore - private let preferencesStore: AppPreferencesStore - private let pollingController: AppPollingController - private var codexUsageRefreshTask: Task? - private var codexUsageRefreshGeneration = 0 - private var startupModeApplied = false - private var manualTargetDriftSampleCounts: [Int: Int] = [:] - private var manualTargetSettlingFanIDs: Set = [] - private var lastCodexUsageRefreshAt: Date? - private var lastPowerTelemetryRefreshAt: Date? - private var lastDaemonPingAt: Date? - private var lastAgentStatusRefreshAt: Date? - private let powerTelemetryRefreshInterval: TimeInterval = 15 - private let daemonPingRefreshInterval: TimeInterval = 30 - private let agentStatusRefreshInterval: TimeInterval = 15 - private let pollSchedulePolicy = PollSchedulePolicy.standard + let coordinator: FanControlCoordinator + let powerReader: @Sendable () -> PowerSnapshot + let thermalReader: @Sendable () -> ThermalPressure + let codexUsageReader: @Sendable () -> CodexUsageSnapshot? + let now: @Sendable () -> Date + let localNotificationCoordinator: LocalNotificationCoordinator + let launchAtLoginManager: LaunchAtLoginManaging + let daemonPing: @Sendable () async -> Bool + let agentStatusReader: @Sendable () async throws -> AgentControlStatus? + let agentPolicySetter: @Sendable (Bool) async throws -> AgentControlStatus? + let agentRestore: @Sendable (String) async throws -> AgentControlStatus? + let profileStore: CurveProfileStore + let preferencesStore: AppPreferencesStore + let pollingController: AppPollingController + var codexUsageRefreshTask: Task? + var codexUsageRefreshGeneration = 0 + var startupModeApplied = false + var manualTargetDriftSampleCounts: [Int: Int] = [:] + var manualTargetSettlingFanIDs: Set = [] + var lastCodexUsageRefreshAt: Date? + var lastPowerTelemetryRefreshAt: Date? + var lastDaemonPingAt: Date? + var lastAgentStatusRefreshAt: Date? + let powerTelemetryRefreshInterval: TimeInterval = 15 + let daemonPingRefreshInterval: TimeInterval = 30 + let agentStatusRefreshInterval: TimeInterval = 15 + let pollSchedulePolicy = PollSchedulePolicy.standard var currentFanControlDraft: FanControlDraft { FanControlDraft( @@ -258,7 +258,7 @@ final class AppModel: ObservableObject { )) } - private var fanControlPresentationApplyState: FanControlApplyState { + var fanControlPresentationApplyState: FanControlApplyState { fanControlSessionController.presentationApplyState( currentDraft: currentFanControlDraft, selectedMode: selectedMode, @@ -386,2092 +386,6 @@ final class AppModel: ObservableObject { } } - var launchAtLoginEnabled: Bool { - launchAtLoginStatus.isToggleOn - } - - var launchAtLoginStatusMessage: String? { - launchAtLoginError ?? launchAtLoginStatus.message - } - - func refreshLaunchAtLoginStatus() { - launchAtLoginStatus = launchAtLoginManager.status - if launchAtLoginStatus == .enabled || launchAtLoginStatus == .disabled { - launchAtLoginError = nil - } - } - - func setLaunchAtLoginEnabled(_ enabled: Bool) { - do { - try launchAtLoginManager.setEnabled(enabled) - launchAtLoginStatus = launchAtLoginManager.status - launchAtLoginError = nil - if launchAtLoginStatus == .requiresApproval { - launchAtLoginManager.openLoginItemsSettings() - } - } catch { - launchAtLoginStatus = launchAtLoginManager.status - launchAtLoginError = "Could not update startup item: \(error.localizedDescription)" - } - } - - func openLaunchAtLoginSettings() { - launchAtLoginManager.openLoginItemsSettings() - } - - func refreshSystemSettingsStateOnActivation() async { - refreshLaunchAtLoginStatus() - await refreshNotificationAuthorization() - } - - func applyStartupModePreferenceIfNeeded() async { - guard !startupModeApplied else { return } - startupModeApplied = true - selectedMode = startupMode - guard startupMode != .auto else { return } - if snapshot == nil { - await pollOnce() - selectedMode = startupMode - } - guard FanControlOwnershipPresentation.resolve(fanControlOwnershipStatus).owner == .macOS else { - lastError = "Startup manual mode remains a draft until daemon-confirmed macOS fan ownership is available." - markFanControlDraftPending() - return - } - markFanControlDraftPending() - } - - func stop() { - Task { _ = await stopAndRestore() } - } - - func stopAndRestore() async -> AppTerminationRestoreResult { - ViftyLog.fanControl.notice("Termination restore started") - let wasRunning = isRunning - let stateBeforeStop = await coordinator.state - let hasAgentLeaseRequiringRestore = agentControlStatus?.activeLease != nil - pollingController.stop() - codexUsageRefreshGeneration &+= 1 - codexUsageRefreshTask?.cancel() - codexUsageRefreshTask = nil - isRunning = false - selectedMode = .auto - manualSessionExpiresAt = nil - - var failures: [String] = [] - let ownershipBeforeRestore: FanControlOwnershipStatus? - do { - let status = try await coordinator.fanControlOwnershipStatus() - fanControlOwnershipStatus = status - fanControlOwnershipStatusError = nil - ownershipBeforeRestore = status - } catch { - let message = "Could not confirm daemon fan-control ownership before termination: \(error.localizedDescription)" - fanControlOwnershipStatus = nil - fanControlOwnershipStatusError = error.localizedDescription - ownershipBeforeRestore = nil - failures.append(message) - } - - let requiresHardwareRestore = stateBeforeStop.manualControlActive - || stateBeforeStop.mode != .auto - || hasAgentLeaseRequiringRestore - || ownershipBeforeRestore.map { - !FanControlCoordinator.confirmsCleanOSOwnership($0) - } == true - - // A failed ownership read must not suppress a restore already required - // by local manual state or an agent lease. We still fail termination - // closed on that unreadable precondition, but make the best available - // safety move back to Auto and then re-read authoritative ownership. - if requiresHardwareRestore { - let hardwareRestoreResult = await coordinator.forceAuto() - if case .failed(let message) = hardwareRestoreResult { - failures.append(message) - } - - do { - let status = try await coordinator.fanControlOwnershipStatus() - fanControlOwnershipStatus = status - fanControlOwnershipStatusError = nil - if !FanControlCoordinator.confirmsCleanOSOwnership(status) { - failures.append( - "Auto restore did not reach clean daemon-confirmed macOS fan ownership." - ) - } - } catch { - fanControlOwnershipStatus = nil - fanControlOwnershipStatusError = error.localizedDescription - failures.append( - "Could not confirm daemon fan-control ownership after Auto restore: \(error.localizedDescription)" - ) - } - } - await refreshAgentControlStatus() - await syncState() - - guard !failures.isEmpty else { - recordAutoRestorationApplied() - ViftyLog.fanControl.notice("Termination restore confirmed") - return .restored - } - - let message = failures.joined(separator: "\n") - lastError = message - await notifyAutoRestoreFailure(message) - selectedMode = modeSelection(for: controlState.mode) - resumePollingAfterTerminationFailure(wasRunning: wasRunning) - ViftyLog.fanControl.error("Termination restore failed") - return .failed(message: message) - } - - private func modeSelection(for mode: FanMode) -> ModeSelection { - switch mode { - case .auto: - .auto - case .fixedRPM: - .fixed - case .temperatureCurve: - .curve - } - } - - private func resumePollingAfterTerminationFailure(wasRunning: Bool) { - guard wasRunning, !pollingController.isRunning else { return } - let started = pollingController.start( - interval: { [weak self] in - self?.backgroundPollInterval() ?? .seconds(10) - }, - poll: { [weak self] operation in - await self?.performPollOnce(operation: operation) - } - ) - isRunning = started - } - - func pollOnce() async { - await pollingController.pollOnce { [weak self] operation in - await self?.performPollOnce(operation: operation) - } - } - - private func performPollOnce( - operation: AppPollingOperation, - reconcileManualApply: Bool = true - ) async { - guard pollingController.isCurrent(operation) else { return } - let pollingIntervalState = ViftyLog.pollingSignposter.beginInterval("Hardware poll") - defer { - ViftyLog.pollingSignposter.endInterval("Hardware poll", pollingIntervalState) - } - let pollStartedAt = now() - let shouldRefreshPowerTelemetry = shouldRefresh( - lastRefreshAt: lastPowerTelemetryRefreshAt, - interval: powerTelemetryRefreshInterval, - at: pollStartedAt - ) - let currentPower: PowerSnapshot - let currentThermalPressure: ThermalPressure - if shouldRefreshPowerTelemetry { - currentPower = powerReader() - currentThermalPressure = thermalReader() - lastPowerTelemetryRefreshAt = pollStartedAt - assignIfChanged(\.powerSnapshot, currentPower) - assignIfChanged(\.thermalPressure, currentThermalPressure) - } else { - currentPower = powerSnapshot ?? PowerSnapshot() - currentThermalPressure = thermalPressure - } - - defer { - if pollingController.isCurrent(operation) { - hasCompletedHardwarePoll = true - refreshMenuBarStatusItemIfNeeded() - } - } - refreshCodexUsageIfNeeded() - let expiredAutoOperationGeneration = await restoreAutoIfManualSessionExpired() - guard pollingController.isCurrent(operation) else { return } - let stateBeforeTick = await coordinator.state - guard pollingController.isCurrent(operation) else { return } - do { - let nextSnapshot = try await coordinator.tick() - guard pollingController.isCurrent(operation) else { return } - recordHardwareSnapshot(nextSnapshot, power: currentPower, thermalPressure: currentThermalPressure) - assignIfChanged(\.lastError, nil) - await refreshDaemonPingIfNeeded(at: pollStartedAt, force: false) - guard pollingController.isCurrent(operation) else { return } - assignIfChanged(\.daemonReachable, daemonResponding || !nextSnapshot.fans.isEmpty) - await refreshAgentControlStatusIfNeeded(at: pollStartedAt) - guard pollingController.isCurrent(operation) else { return } - await refreshFanControlOwnershipStatus() - guard pollingController.isCurrent(operation) else { return } - assignIfChanged(\.fanAccessMessage, fanAccessMessage(for: nextSnapshot)) - await syncState() - guard pollingController.isCurrent(operation) else { return } - if let expiredAutoOperationGeneration, - canCommitAutoRestoration(generation: expiredAutoOperationGeneration) { - recordAutoRestorationApplied() - } else if reconcileManualApply { - reconcileManualApplyAttemptAfterSuccessfulPoll() - } - await evaluateLocalNotifications(power: currentPower, thermalPressure: currentThermalPressure) - ViftyLog.polling.debug("Hardware poll completed") - } catch { - guard pollingController.isCurrent(operation) else { return } - ViftyLog.polling.warning("Hardware poll failed") - let preservesManualIntent = shouldPreserveManualIntent( - afterTickFailure: error, - attemptedMode: stateBeforeTick.mode - ) - if preservesManualIntent, let observedSnapshot = await coordinator.lastObservedSnapshot { - recordHardwareSnapshot(observedSnapshot, power: currentPower, thermalPressure: currentThermalPressure) - } - assignIfChanged(\.lastError, error.localizedDescription) - await refreshDaemonPingIfNeeded(at: pollStartedAt, force: true) - guard pollingController.isCurrent(operation) else { return } - assignIfChanged(\.daemonReachable, daemonResponding || (preservesManualIntent && snapshot?.fans.isEmpty == false)) - await refreshAgentControlStatusIfNeeded(at: pollStartedAt, force: true) - guard pollingController.isCurrent(operation) else { return } - await refreshFanControlOwnershipStatus() - guard pollingController.isCurrent(operation) else { return } - if preservesManualIntent, let snapshot { - assignIfChanged(\.fanAccessMessage, fanAccessMessage(for: snapshot)) - } - if preservesManualIntent { - if selectedMode != .auto { - let intendedMode: FanMode = stateBeforeTick.mode == .auto ? selectedFanMode() : stateBeforeTick.mode - await coordinator.setMode(intendedMode) - guard pollingController.isCurrent(operation) else { return } - } - assignIfChanged(\.controlState, await coordinator.state) - } else if selectedMode == .auto, - expiredAutoOperationGeneration == nil { - // The coordinator tick already attempted the current Auto - // transaction. Do not double the retry rate with a second - // forceAuto request in the same poll; later polls may make one - // authority-free safety retry. - await syncState() - } else { - let fallbackAutoRestoreResult = await coordinator.forceAuto() - guard pollingController.isCurrent(operation) else { return } - await syncState() - guard pollingController.isCurrent(operation) else { return } - - if let expiredAutoOperationGeneration, - fanControlOperationIsCurrent(expiredAutoOperationGeneration) { - switch fallbackAutoRestoreResult { - case .restored: - if canCommitAutoRestoration(generation: expiredAutoOperationGeneration) { - recordAutoRestorationApplied() - } else { - let message = "\(error.localizedDescription)\nFallback Auto restore could not be confirmed." - assignIfChanged(\.lastError, message) - fanControlApplyState = .failed(message: message) - } - case .failed(let fallbackMessage): - let message = "\(error.localizedDescription)\nFallback Auto restore failed: \(fallbackMessage)" - assignIfChanged(\.lastError, message) - fanControlApplyState = .failed(message: message) - } - } - } - await evaluateLocalNotifications(power: currentPower, thermalPressure: currentThermalPressure) - } - } - - private func refreshCodexUsageIfNeeded() { - guard menuBarDisplaysCodexUsage else { - if codexUsageRefreshTask != nil || codexUsageSnapshot != nil { - cancelCodexUsageRefresh(clearSnapshot: true) - } - return - } - guard codexUsageRefreshTask == nil else { return } - - let currentTime = now() - if let lastCodexUsageRefreshAt, - currentTime.timeIntervalSince(lastCodexUsageRefreshAt) < codexUsageRefreshCadence.seconds { - return - } - - lastCodexUsageRefreshAt = currentTime - let reader = codexUsageReader - codexUsageRefreshGeneration &+= 1 - let generation = codexUsageRefreshGeneration - codexUsageRefreshTask = Task { [weak self] in - let snapshot = await Task.detached(priority: .utility) { - reader() - }.value - let wasCancelled = Task.isCancelled - - guard let self else { return } - guard self.codexUsageRefreshGeneration == generation else { return } - self.codexUsageRefreshTask = nil - guard !wasCancelled else { return } - guard self.menuBarDisplaysCodexUsage else { - self.cancelCodexUsageRefresh(clearSnapshot: true) - return - } - - self.assignIfChanged(\.codexUsageSnapshot, snapshot) - self.refreshMenuBarStatusItemIfNeeded() - } - } - - private func cancelCodexUsageRefresh(clearSnapshot: Bool) { - codexUsageRefreshGeneration &+= 1 - codexUsageRefreshTask?.cancel() - codexUsageRefreshTask = nil - lastCodexUsageRefreshAt = nil - if clearSnapshot { - assignIfChanged(\.codexUsageSnapshot, nil) - } - refreshMenuBarStatusItemIfNeeded() - } - - func waitForCodexUsageRefresh() async { - await codexUsageRefreshTask?.value - } - - @discardableResult - private func refreshMenuBarStatusItemIfNeeded() -> Bool { - let nextPresentation = currentMenuBarStatusItemPresentation - guard nextPresentation != menuBarStatusItemPresentation else { return false } - menuBarStatusItemPresentation = nextPresentation - menuBarStatusItemRevision &+= 1 - return true - } - - private var currentMenuBarStatusItemPresentation: MenuBarStatusItemPresentation { - resolvedMenuBarPresentation.statusItemPresentation - } - - private func shouldRefresh(lastRefreshAt: Date?, interval: TimeInterval, at date: Date) -> Bool { - guard let lastRefreshAt else { return true } - return date.timeIntervalSince(lastRefreshAt) >= interval - } - - private func backgroundPollInterval() -> Duration { - pollSchedulePolicy.interval( - selectedMode: selectedMode, - controlMode: controlState.mode, - hasAgentLease: agentControlStatus?.activeLease?.isActive(at: now()) == true - ) - } - - private func refreshDaemonPingIfNeeded(at date: Date, force: Bool) async { - guard force || !daemonResponding || shouldRefresh( - lastRefreshAt: lastDaemonPingAt, - interval: daemonPingRefreshInterval, - at: date - ) else { - return - } - let responding = await daemonPing() - lastDaemonPingAt = date - assignIfChanged(\.daemonResponding, responding) - } - - private func refreshAgentControlStatusIfNeeded(at date: Date, force: Bool = false) async { - let activeLease = agentControlStatus?.activeLease - let activeAgentWork = activeLease?.isActive(at: date) == true || agentControlStatusError != nil - guard force || activeAgentWork || shouldRefresh( - lastRefreshAt: lastAgentStatusRefreshAt, - interval: agentStatusRefreshInterval, - at: date - ) else { - return - } - await refreshAgentControlStatus() - lastAgentStatusRefreshAt = date - } - - @discardableResult - private func assignIfChanged( - _ keyPath: ReferenceWritableKeyPath, - _ value: Value - ) -> Bool { - guard self[keyPath: keyPath] != value else { return false } - self[keyPath: keyPath] = value - return true - } - - private func recordHardwareSnapshot( - _ nextSnapshot: HardwareSnapshot, - power: PowerSnapshot, - thermalPressure: ThermalPressure - ) { - assignIfChanged(\.snapshot, nextSnapshot) - telemetrySession.record( - snapshot: nextSnapshot, - power: power, - thermalPressure: thermalPressure, - userSelectedSensorID: userSelectedSensorID, - capturedAt: now(), - ) - publishTelemetrySession() - syncCurveDefaultsIfNeeded(from: nextSnapshot) - if usePerFanFixedRPM { - ensureFixedFanTargets(for: nextSnapshot.fans) - } - } - - private func fanAccessMessage(for snapshot: HardwareSnapshot) -> String? { - snapshot.fans.isEmpty - ? (daemonResponding ? "The fan helper is running but did not return fan data." : "Install and approve the fan helper to enable fan reads and control.") - : nil - } - - func applyModeSelection() { - if selectedMode == .auto { - restoreAuto() - } else { - markFanControlDraftPending() - } - } - - func performModeSelectionAction() { - Task { await performModeSelectionActionNow() } - } - - func performModeSelectionActionNow() async { - switch controlSessionPresentation.primaryAction { - case .restoreAuto: - await restoreAutoNow() - case .apply: - _ = await applyCurrentModeSelection() - case .none, .repairHelper, .copyDiagnostics: - break - } - } - - func restoreAuto() { - let generation = beginAutoRestoreOperation() - Task { await performAutoRestore(generation: generation) } - } - - var canRequestRestoreAuto: Bool { - FanControlOwnershipPresentation - .resolve(fanControlOwnershipStatus) - .canRequestRestoreAuto - } - - func markFanControlDraftPending() { - fanControlApplyState = fanControlSessionController.draftPendingApplyState( - currentDraft: currentFanControlDraft, - selectedMode: selectedMode, - controlMode: controlState.mode, - applyState: fanControlApplyState - ) - } - - func applyPendingFanControl() { - Task { _ = await applyCurrentModeSelection() } - } - - @discardableResult - func applyCurrentModeSelection() async -> FanControlApplyResult { - if selectedMode == .auto { - await restoreAutoNow() - if case .failed(let message) = fanControlApplyState { - return .failed(message: message) - } - return selectedMode == .auto && controlState.mode == .auto ? .applied : .superseded - } - - if usePerFanFixedRPM, let fans = snapshot?.fans { - ensureFixedFanTargets(for: fans) - } - let manualOperation = fanControlSessionController.beginManualOperation( - currentSessionExpiresAt: manualSessionExpiresAt - ) - let operation = manualOperation.operation - let draft = currentFanControlDraft - let mode = fanMode(for: draft) - - await refreshManualControlPreflight() - guard manualFanControlOperationIsCurrent(operation) else { - return .superseded - } - if let blockedReason = manualFanControlBlockedReason { - lastError = "Manual fan control blocked: \(blockedReason)" - fanControlApplyState = .blocked(reason: blockedReason) - await syncState() - guard manualFanControlOperationIsCurrent(operation) else { - return .superseded - } - return .blocked(reason: blockedReason) - } - - fanControlApplyState = .applying - lastError = nil - let provisionalSessionExpiresAt = fanControlSessionController.registerManualApply( - manualOperation, - draft: draft, - mode: mode - ) - manualSessionExpiresAt = provisionalSessionExpiresAt - - guard manualFanControlOperationIsCurrent(operation) else { - return .superseded - } - await coordinator.setFixedFanTargets(fixedFanTargetMap(for: draft)) - guard manualFanControlOperationIsCurrent(operation) else { - return .superseded - } - await coordinator.setFanOverrides(draft.usePerFanOverrides ? draft.fanOverrides : []) - guard manualFanControlOperationIsCurrent(operation) else { - return .superseded - } - await coordinator.setMode(mode) - guard manualFanControlOperationIsCurrent(operation) else { - return .superseded - } - fanControlSessionController.markCoordinatorConfigured(operation) - - guard manualFanControlOperationIsCurrent(operation) else { - return .superseded - } - // A successful hardware poll is necessary but not sufficient to commit - // the draft or its run limit. The fresh daemon ownership read below is - // the final Apply boundary. - await pollingController.freshPollOnce { [weak self] pollingOperation in - await self?.performPollOnce( - operation: pollingOperation, - reconcileManualApply: false - ) - } - guard manualFanControlOperationIsCurrent(operation) else { - return .superseded - } - if let lastError { - restorePreviousManualDeadline(for: manualOperation) - fanControlApplyState = .failed(message: lastError) - return .failed(message: lastError) - } - do { - let confirmedOwnership = try await coordinator.confirmCurrentManualOwnership() - guard manualFanControlOperationIsCurrent(operation) else { - return .superseded - } - fanControlOwnershipStatus = confirmedOwnership - fanControlOwnershipStatusError = nil - } catch { - guard manualFanControlOperationIsCurrent(operation) else { - return .superseded - } - let message = "Manual fan control could not be confirmed after Apply: \(error.localizedDescription)" - rejectUnconfirmedManualApply( - manualOperation, - provisionalSessionExpiresAt: provisionalSessionExpiresAt - ) - lastError = message - fanControlOwnershipStatus = nil - fanControlOwnershipStatusError = error.localizedDescription - fanControlApplyState = .failed(message: message) - return .failed(message: message) - } - guard controlState.mode == mode, controlState.manualControlActive else { - let message = "Manual fan control could not be confirmed after Apply." - rejectUnconfirmedManualApply( - manualOperation, - provisionalSessionExpiresAt: provisionalSessionExpiresAt - ) - lastError = message - fanControlApplyState = .failed(message: message) - return .failed(message: message) - } - reconcileManualApplyAttemptAfterSuccessfulPoll() - return .applied - } - - func restoreAutoNow() async { - let generation = beginAutoRestoreOperation() - await performAutoRestore(generation: generation) - } - - private func performAutoRestore(generation: FanControlSessionOperation) async { - guard fanControlOperationIsCurrent(generation) else { return } - await coordinator.setMode( - .auto, - unreadableJournalRecoveryAuthority: .explicitOperator - ) - guard fanControlOperationIsCurrent(generation) else { return } - - // Auto is a safety-priority operation. Do not coalesce it behind an - // in-flight manual poll: invalidate that poll's publication token and - // start a concurrent Auto tick so the daemon can preempt at the next - // physical fan boundary. - pollingController.supersedeActivePoll() - await pollOnce() - guard fanControlOperationIsCurrent(generation) else { return } - // The coordinator's protocol-v2 full-Auto transaction routes through - // AgentControlService in the daemon and clears the lease atomically. - // Refresh visibility only; never issue a second lease-clear restore. - await refreshAgentControlStatus() - guard fanControlOperationIsCurrent(generation) else { return } - if let lastError { - fanControlApplyState = .failed(message: lastError) - await notifyAutoRestoreFailure(lastError) - } else if controlState.mode != .auto || controlState.manualControlActive { - let message = "Auto restore could not be confirmed." - lastError = message - fanControlApplyState = .failed(message: message) - await notifyAutoRestoreFailure(message) - } else { - recordAutoRestorationApplied() - } - } - - func saveCurrentProfile(name: String) { - guard let result = saveCurrentProfileAs(name: name, confirmOverwrite: false) else { return } - if case .overwriteConfirmationRequired(let existing, _) = result { - lastError = "Confirm before replacing the saved profile \(existing.name)." - } - } - - @discardableResult - func saveCurrentProfileAs( - name: String, - confirmOverwrite: Bool - ) -> CurveProfileSaveResult? { - guard let result = CurveProfileSavePolicy.saveAs( - name: name, - draft: currentCurveProfileDraft, - existingProfiles: savedProfiles, - confirmOverwrite: confirmOverwrite - ) else { return nil } - - switch result { - case .created(let profile): - let proposedProfiles = savedProfiles + [profile] - guard persistProfiles(proposedProfiles) else { - return .persistenceFailed(message: profilePersistenceErrorMessage) - } - savedProfiles = proposedProfiles - selectedCurveProfileID = profile.id - case .updated(let profile): - guard let index = savedProfiles.firstIndex(where: { $0.id == profile.id }) else { - return nil - } - var proposedProfiles = savedProfiles - proposedProfiles[index] = profile - guard persistProfiles(proposedProfiles) else { - return .persistenceFailed(message: profilePersistenceErrorMessage) - } - savedProfiles = proposedProfiles - selectedCurveProfileID = profile.id - case .overwriteConfirmationRequired: - break - case .persistenceFailed: - preconditionFailure("CurveProfileSavePolicy never emits persistence results.") - } - return result - } - - @discardableResult - func updateSelectedCurveProfile() -> Bool { - let selected = selectedCurveProfileID.flatMap { selectedID in - savedProfiles.first { $0.id == selectedID } - } - guard case .updated(let profile)? = CurveProfileSavePolicy.update( - selectedProfile: selected, - draft: currentCurveProfileDraft - ), let index = savedProfiles.firstIndex(where: { $0.id == profile.id }) else { - return false - } - - var proposedProfiles = savedProfiles - proposedProfiles[index] = profile - guard persistProfiles(proposedProfiles) else { return false } - savedProfiles = proposedProfiles - selectedCurveProfileID = profile.id - return true - } - - func loadProfile(_ profile: CurveProfile) { - curveStartTemp = profile.startTemp - curveStartRPM = Double(profile.startRPM) - curveMidTemp = profile.midTemp - curveMidRPM = Double(profile.midRPM) - curveMaxTemp = profile.maxTemp - curveMaxRPM = Double(profile.maxRPM) - selectedSensorID = profile.sensorID - fanOverrides = profile.fanOverrides - usePerFanOverrides = !profile.fanOverrides.isEmpty - if let fans = snapshot?.fans, usePerFanOverrides { - ensureFanOverrides(for: fans) - } - markFanControlDraftPending() - } - - @discardableResult - func selectCurveProfile(id profileID: CurveProfile.ID?) -> Bool { - guard let profileID else { - selectedCurveProfileID = nil - return true - } - guard let profile = savedProfiles.first(where: { $0.id == profileID }) else { - return false - } - selectedCurveProfileID = profileID - selectedMode = .curve - loadProfile(profile) - return true - } - - func loadDeveloperPreset(_ preset: DeveloperFanPreset) { - selectedCurveProfileID = nil - selectedMode = .curve - curveStartTemp = preset.startTemperatureCelsius - curveMidTemp = preset.midTemperatureCelsius - curveMaxTemp = preset.maxTemperatureCelsius - curveStartRPM = Double(rpm(forPercent: preset.startRPMPercent)) - curveMidRPM = Double(rpm(forPercent: preset.midRPMPercent)) - curveMaxRPM = Double(rpm(forPercent: preset.maxRPMPercent)) - if selectedSensorID == nil { - selectedSensorID = selectedSensor?.id - } - usePerFanOverrides = false - fanOverrides = [] - curveDefaultsSynced = true - markFanControlDraftPending() - } - - @discardableResult - func deleteProfile(_ profile: CurveProfile) -> Bool { - let proposedProfiles = savedProfiles.filter { $0.id != profile.id } - guard persistProfiles(proposedProfiles) else { return false } - savedProfiles = proposedProfiles - if selectedCurveProfileID == profile.id { - selectedCurveProfileID = nil - } - return true - } - - private var profilePersistenceErrorMessage: String { - curveProfilePersistenceError ?? "Failed to save profiles." - } - - @discardableResult - private func persistProfiles(_ profiles: [CurveProfile]) -> Bool { - do { - try profileStore.saveThrowing(profiles) - curveProfileRecoveryMessage = nil - if lastError == curveProfilePersistenceError { - lastError = nil - } - curveProfilePersistenceError = nil - return true - } catch { - let message = "Failed to save profiles: \(error.localizedDescription)" - curveProfilePersistenceError = message - lastError = message - return false - } - } - - func targetRPMPreview(for fan: Fan) -> Int? { - guard fan.controllable else { return nil } - switch selectedMode { - case .auto: - return nil - case .fixed: - if perFanFixedRPMApplies { - return fixedFanTargetRPM(for: fan) - } - return FanCurve.clamp(Int(fixedRPM.rounded()), fan.minimumRPM, fan.maximumRPM) - case .curve: - guard let sensor = selectedSensor else { return nil } - return FanCurveTargetResolver.targetRPM( - baseCurve: currentCurve(), - fan: fan, - temperature: sensor.celsius, - overrides: usePerFanOverrides ? fanOverrides : [] - ) - } - } - - func appliedTargetRPM(for fan: Fan) -> Int? { - let targetRPM = fan.targetRPM ?? (controlState.manualControlActive ? controlState.lastAppliedRPM[fan.id] : nil) - return targetRPM.map { FanCurve.clamp($0, fan.minimumRPM, fan.maximumRPM) } - } - - func draftTargetRPMPreview(for fan: Fan) -> Int? { - guard selectedMode != .auto, hasPendingFanControlChanges else { return nil } - let draftTargetRPM = targetRPMPreview(for: fan) - return draftTargetRPM == appliedTargetRPM(for: fan) ? nil : draftTargetRPM - } - - private func publishTelemetrySession() { - assignIfChanged(\.telemetryOverviewSummary, telemetrySession.overviewSummary) - assignIfChanged(\.compactTelemetryOverviewSummary, telemetrySession.compactSummary) - assignIfChanged(\.recentTelemetryTrendSummary, telemetrySession.recentTrendSummary) - } - - func ensureFixedFanTargets(for fans: [Fan]) { - let controllableFans = fans.filter(\.controllable) - let baseRatio = fixedRPMBaseRangeRatio(for: fixedRPMBaseBounds(for: controllableFans)) - let existingByFanID = fixedFanTargets.reduce(into: [Int: FixedFanTarget]()) { targetsByID, target in - targetsByID[target.fanID] = target - } - let nextTargets = controllableFans.map { fan in - if let existing = existingByFanID[fan.id] { - return FixedFanTarget( - fanID: fan.id, - rpm: FanCurve.clamp(existing.rpm, fan.minimumRPM, fan.maximumRPM) - ) - } - return defaultFixedFanTarget(for: fan, baseRatio: baseRatio) - } - guard nextTargets != fixedFanTargets else { return } - fixedFanTargets = nextTargets - persistAppPreferences() - } - - func fixedFanTarget(for fanID: Int) -> FixedFanTarget? { - fixedFanTargets.first { $0.fanID == fanID } - } - - func fixedFanTargetRPM(for fan: Fan) -> Int { - fixedFanTarget(for: fan.id)?.rpm ?? defaultFixedFanTargetRPM(for: fan) - } - - func fixedFanSliderRPM(for fan: Fan) -> Int { - fixedFanTargetRPM(for: fan) - } - - func fixedFanTargetPercent(for fan: Fan) -> Int { - rpmPercent(fixedFanTargetRPM(for: fan), for: fan) - } - - func setFixedFanRPM(_ rpm: Int, for fan: Fan, persist: Bool = true) { - updateFixedFanTarget(for: fan) { target in - target.rpm = FanCurve.clamp(rpm, fan.minimumRPM, fan.maximumRPM) - } - if persist { - persistAppPreferences() - } - } - - func commitFixedFanTargetsAndApply() { - persistAppPreferences() - markFanControlDraftPending() - } - - func commitFixedFanTargetsAndApplyNow() async { - persistAppPreferences() - markFanControlDraftPending() - } - - func ensureFanOverrides(for fans: [Fan]) { - let existingByFanID = fanOverrides.reduce(into: [Int: FanCurveOverride]()) { overridesByID, override in - overridesByID[override.fanID] = override - } - fanOverrides = fans.filter(\.controllable).map { fan in - existingByFanID[fan.id] ?? defaultFanOverride(for: fan) - } - } - - func fanOverride(for fanID: Int) -> FanCurveOverride? { - fanOverrides.last { $0.fanID == fanID } - } - - func setOverrideStartRPM(_ rpm: Int, for fan: Fan) { - guard fan.controllable else { return } - updateFanOverride(for: fan) { override in - override.startRPM = FanCurve.clamp(rpm, fan.minimumRPM, fan.maximumRPM) - } - } - - func setOverrideMidRPM(_ rpm: Int, for fan: Fan) { - guard fan.controllable else { return } - updateFanOverride(for: fan) { override in - override.midRPM = FanCurve.clamp(rpm, fan.minimumRPM, fan.maximumRPM) - } - } - - func setOverrideMaxRPM(_ rpm: Int, for fan: Fan) { - guard fan.controllable else { return } - updateFanOverride(for: fan) { override in - override.maxRPM = FanCurve.clamp(rpm, fan.minimumRPM, fan.maximumRPM) - } - } - - var selectedSensor: TemperatureSensor? { - curveTemperatureSelection.curveMetric - } - - var effectiveSelectedSensorID: String? { - selectedSensor?.id - } - - var curveTemperatureSelection: TemperatureSensorSelection { - TemperatureSensorSelection.resolve( - sensors: snapshot?.temperatureSensors ?? [], - selectedSensorID: userSelectedSensorID - ) - } - - private var resolvedCurveSensorID: String? { - guard snapshot?.temperatureSensors.isEmpty == false else { return selectedSensorID } - return effectiveSelectedSensorID - } - - var temperatureAttentionSummary: String? { - guard thermalPressure.menuSummary == nil else { return nil } - guard let sensor = snapshot?.highestTemperature else { return nil } - return sensor.celsius >= Self.highTemperatureAttentionThreshold ? "High temp" : nil - } - - var fanWriteBlockedWhileHotSummary: String? { - guard helperWritePathBlockedSummary != nil, - let sensor = snapshot?.highestTemperature, - sensor.celsius >= Self.highTemperatureAttentionThreshold else { - return nil - } - return "High temp · fan writes blocked" - } - - var fanWriteBlockedWhileHotRecoverySuggestion: String? { - guard fanWriteBlockedWhileHotSummary != nil else { return nil } - switch controlState.mode { - case .auto: - return "Reduce heavy work now. Keep Auto selected, then Repair/Reinstall Helper; writes stay blocked until the daemon responds." - case .fixedRPM, .temperatureCurve: - return "Reduce heavy work now. Repair/Reinstall Helper; Vifty will retry \(manualModeName) when the daemon responds. Use \(autoRestoreActionTitle) to stop retries." - } - } - - var visibleLastError: String? { - guard let lastError else { return nil } - guard !lastErrorIsCoveredByHelperRecovery(lastError) else { return nil } - return lastError - } - - private var resolvedMenuBarPresentation: MenuBarPresentation { - let currentDate = now() - let lease = agentControlStatus?.activeLease - return MenuBarPresentationProvider.resolve(MenuBarPresentationInput( - displayMode: menuBarDisplayMode, - customFields: menuBarCustomFields, - snapshotIsAvailable: snapshot != nil, - selectedTemperature: selectedSensor ?? snapshot?.highestTemperature, - selectedTemperatureLabel: curveTemperatureSelection.curveMetricLabel, - fans: snapshot?.fans ?? [], - power: powerSnapshot, - thermalPressure: thermalPressure, - temperatureAttentionSummary: temperatureAttentionSummary, - fanWriteBlockedWhileHotSummary: fanWriteBlockedWhileHotSummary, - helperState: helperHealthState, - hasCompletedHardwarePoll: hasCompletedHardwarePoll, - daemonReachable: daemonReachable, - daemonResponding: daemonResponding, - lastErrorIsPresent: lastError != nil, - agentCoolingMenuSummary: agentCoolingMenuSummary, - agentStatusIsUnavailable: agentControlStatusError != nil, - shouldPreferHelperRecoveryOverAgentStatusError: shouldPreferHelperRecoveryOverAgentStatusError, - hasAgentLease: lease != nil, - agentLeaseNeedsAttention: agentControlStatusError != nil - || lease.map { !$0.isActive(at: currentDate) } == true, - fanControlOwnershipStatus: fanControlOwnershipStatus, - controlMode: controlState.mode, - controlOwnershipNeedsAttention: controlOwnershipNeedsAttention, - autoHardwareModeIsUncertain: !autoForcedModeFans.isEmpty - || !autoUnknownModeFans.isEmpty - || !autoMissingModeFans.isEmpty, - codexUsageSnapshot: codexUsageSnapshot, - codexUsageDisplayPreferences: codexUsageDisplayPreferences, - currentDate: currentDate - )) - } - - private var menuBarPanelAttentionText: String? { - resolvedMenuBarPresentation.panelAttentionText - } - - var menuTitle: String { - resolvedMenuBarPresentation.title - } - - var menuPanelTitle: String { - resolvedMenuBarPresentation.panelTitle - } - - var menuBarLabelText: String { - resolvedMenuBarPresentation.labelText - } - - var menuBarStatusItemText: String? { - resolvedMenuBarPresentation.statusItemText - } - - var menuBarLabelNeedsTelemetryPrime: Bool { - resolvedMenuBarPresentation.labelNeedsTelemetryPrime - } - - var menuBarDisplaysCodexUsage: Bool { - MenuBarPresentationProvider.displaysCodexUsage( - menuBarDisplayMode, - customFields: menuBarCustomFields - ) - } - - var menuBarAllowsPlaceholderStatusItemText: Bool { - resolvedMenuBarPresentation.allowsPlaceholderStatusItemText - } - - func isMenuBarCustomFieldEnabled(_ field: MenuBarField) -> Bool { - menuBarCustomFields.contains(field) - } - - func setMenuBarCustomField(_ field: MenuBarField, enabled: Bool) { - var fields = menuBarCustomFields - if enabled { - guard !fields.contains(field) else { return } - fields.append(field) - } else { - fields.removeAll { $0 == field } - guard !fields.isEmpty else { return } - } - menuBarCustomFields = MenuBarField.normalized(fields) - } - - var codexUsageSummary: String { - CodexUsageFormatter.summaryText( - for: codexUsageSnapshot, - options: codexUsageDisplayPreferences, - now: now - ) - } - - var codexUsageDetailSummary: String? { - CodexUsageFormatter.detailText( - for: codexUsageSnapshot, - options: codexUsageDisplayPreferences - ) - } - - var codexUsageDetailLines: [String] { - CodexUsageFormatter.detailLines( - for: codexUsageSnapshot, - options: codexUsageDisplayPreferences, - now: now - ) - } - - var menuBarFanOwnerText: String { - resolvedMenuBarPresentation.fanOwnerText - } - - var menuBarLabelUsesFanIcon: Bool { - resolvedMenuBarPresentation.labelUsesFanIcon - } - - private func persistAppPreferences() { - preferencesStore.save(AppPreferences( - menuBarDisplayMode: menuBarDisplayMode, - menuBarCustomFields: menuBarCustomFields, - startupMode: startupMode, - textScale: textScale, - notificationSettings: notificationSettings, - usePerFanFixedRPM: usePerFanFixedRPM, - fixedFanTargets: fixedFanTargets, - codexUsageDisplayPreferences: codexUsageDisplayPreferences - )) - } - - private var codexUsageDisplayPreferences: CodexUsageDisplayPreferences { - CodexUsageDisplayPreferences( - displayStyle: codexUsageDisplayStyle, - metricMode: codexUsageMetricMode, - resetMode: codexUsageResetMode, - refreshCadence: codexUsageRefreshCadence - ) - } - - private func codexUsageDisplayPreferenceDidChange() { - persistAppPreferences() - if menuBarDisplaysCodexUsage { - refreshMenuBarStatusItemIfNeeded() - } - } - - private var helperWritePathBlockedSummary: String? { - helperHealthState.writePathBlockedSummary - } - - private func lastErrorIsCoveredByHelperRecovery(_ error: String) -> Bool { - guard helperWritePathBlockedSummary != nil else { return false } - return error.lowercased().hasPrefix("manual fan control blocked:") - } - - private var shouldPreferHelperRecoveryOverAgentStatusError: Bool { - agentControlStatus?.activeLease == nil - && agentControlStatusError != nil - && helperWritePathBlockedSummary != nil - } - - var agentCoolingMenuSummary: String? { - guard let lease = agentControlStatus?.activeLease else { - if shouldPreferHelperRecoveryOverAgentStatusError { return nil } - return agentControlStatusError == nil ? nil : "Agent status unavailable" - } - if agentControlStatusError != nil { - return "Agent status warning" - } - return lease.isActive(at: now()) ? "Agent cooling" : "Agent restore pending" - } - - var agentCoolingPanelTitle: String { - if agentControlStatusError != nil { - if shouldPreferHelperRecoveryOverAgentStatusError { return "Agent cooling unavailable" } - return agentControlStatus?.activeLease == nil ? "Agent status unavailable" : "Agent status warning" - } - return agentCoolingNeedsAttention ? "Agent restore pending" : "Agent cooling active" - } - - var agentCoolingSummary: String? { - guard let lease = agentControlStatus?.activeLease else { - if shouldPreferHelperRecoveryOverAgentStatusError { return nil } - guard agentControlStatusError != nil else { return nil } - return "Agent cooling status unavailable; repair helper before requesting cooling." - } - - let state = if lease.isActive(at: now()) { - "Agent \(lease.request.workload.displayName) cooling until \(lease.expiresAt.formatted(date: .omitted, time: .shortened))" - } else { - "Agent \(lease.request.workload.displayName) cooling expired; waiting for Auto restore" - } - - let targets = lease.targetRPMByFanID - .sorted { $0.key < $1.key } - .map { "F\($0.key) \($0.value) RPM" } - .joined(separator: ", ") - - let baseSummary = targets.isEmpty ? state : "\(state) · \(targets)" - if agentControlStatusError != nil { - return "\(baseSummary) · status refresh failed; do not start another workload" - } - return baseSummary - } - - var agentCoolingRecoverySuggestion: String? { - if agentControlStatusError != nil { - if shouldPreferHelperRecoveryOverAgentStatusError { return nil } - guard agentControlStatus?.activeLease != nil else { - return "Repair Helper before requesting agent cooling." - } - return "Do not start another workload; use Auto to restore cooling, then check viftyctl status/audit after helper repair." - } - guard let lease = agentControlStatus?.activeLease, !lease.isActive(at: now()) else { - return nil - } - return "Use Auto to restore daemon control before starting another workload." - } - - var agentCoolingRestoreActionAvailable: Bool { - agentControlStatus?.activeLease != nil - } - - var autoRestoreActionTitle: String { - helperWritePathBlockedSummary == nil ? "Auto" : "Request Auto" - } - - var autoRestoreActionHelp: String { - if helperWritePathBlockedSummary == nil { - return "Restore Auto" - } - return "Request Auto restore; the write cannot be confirmed until the helper responds" - } - - var modeSelectionActionTitle: String { - modeSelectionActionRestoresAuto ? autoRestoreActionTitle : "Apply" - } - - var modeSelectionActionHelp: String { - modeSelectionActionRestoresAuto - ? autoRestoreActionHelp - : (manualFanControlBlockedReason ?? "Apply selected fan mode") - } - - var modeSelectionActionRestoresAuto: Bool { - selectedMode == .auto || manualControlAttentionSummary != nil - } - - var modeSelectionActionDisabled: Bool { - !modeSelectionActionRestoresAuto && !manualFanControlAvailable - } - - var agentCoolingRestoreActionTitle: String { - autoRestoreActionTitle - } - - var agentCoolingRestoreActionHelp: String { - if helperWritePathBlockedSummary == nil { - return "Restore Auto before starting another agent workload" - } - return autoRestoreActionHelp - } - - var agentCoolingNeedsAttention: Bool { - if agentControlStatusError != nil { - if shouldPreferHelperRecoveryOverAgentStatusError { return false } - return true - } - guard let lease = agentControlStatus?.activeLease else { return false } - return !lease.isActive(at: now()) - } - - var controlOwnershipSummary: String { - if let lease = agentControlStatus?.activeLease { - let statusWarning = agentControlStatusError == nil ? "" : " · status refresh failed" - if lease.isActive(at: now()) { - return "Agent \(lease.request.workload.displayName) owns cooling until \(lease.expiresAt.formatted(date: .omitted, time: .shortened))\(statusWarning)" - } - return "Agent \(lease.request.workload.displayName) lease expired; restore Auto to clear daemon control\(statusWarning)" - } - - if let manualHelperWriteBlockedSummary { - return manualHelperWriteBlockedSummary - } - - if controlState.mode == .auto, helperWritePathBlockedSummary != nil { - return autoControlOwnershipSummary - } - - if agentControlStatusError != nil { - return "Agent control status unavailable; fan ownership uncertain" - } - - switch controlState.mode { - case .auto: - return autoControlOwnershipSummary - case .fixedRPM(let rpm): - if let manualControlDriftSummary { - return manualControlDriftSummary - } - return "Vifty Fixed owns fan targets · \(fixedModeTargetSummary(fallbackRPM: rpm)) · \(manualRunOwnershipSummary)" - case .temperatureCurve: - if let manualControlDriftSummary { - return manualControlDriftSummary - } - if let sensor = selectedSensor { - return "Vifty Curve owns fan targets · \(sensor.name) · \(manualRunOwnershipSummary)" - } - return "Vifty Curve owns fan targets · \(manualRunOwnershipSummary)" - } - } - - var compactControlOwnershipSummary: String { - if let lease = agentControlStatus?.activeLease { - if lease.isActive(at: now()) { - return "Owner: Agent until \(lease.expiresAt.formatted(date: .omitted, time: .shortened))" - } - return "Owner: Agent restore pending" - } - - if let manualHelperWriteBlockedSummary { - return manualHelperWriteBlockedSummary - } - - if controlState.mode == .auto, helperWritePathBlockedSummary != nil { - return "Owner: Mac?" - } - - if agentControlStatusError != nil { - return "Owner: uncertain" - } - - switch controlState.mode { - case .auto: - return "Owner: \(menuBarFanOwnerText)" - case .fixedRPM: - return "Owner: Vifty Fixed" - case .temperatureCurve: - return "Owner: Vifty Curve" - } - } - - var controlOwnershipNeedsAttention: Bool { - if let lease = agentControlStatus?.activeLease { - return agentControlStatusError != nil || !lease.isActive(at: now()) - } - if agentControlStatusError != nil { - return true - } - - guard controlState.mode == .auto else { - return manualHelperWriteBlockedSummary != nil || manualControlDriftSummary != nil - } - guard let fans = snapshot?.fans, !fans.isEmpty else { - return hasCompletedHardwarePoll || daemonReachable - } - return !autoSystemModeFans.isEmpty - || !autoForcedModeFans.isEmpty - || !autoUnknownModeFans.isEmpty - || !autoMissingModeFans.isEmpty - || helperWritePathBlockedSummary != nil - } - - var helperHealthState: HelperHealthState { - HelperHealthPresentation.resolve(HelperHealthPresentationInput( - hardwareIsSupported: snapshot.map { $0.isAppleSilicon && $0.isMacBookPro }, - hasCompletedHardwarePoll: hasCompletedHardwarePoll, - daemonReachable: daemonReachable, - daemonResponding: daemonResponding, - fanCount: snapshot?.fans.count ?? 0, - hasControllableFan: snapshot?.fans.contains(where: \.controllable) == true, - lastError: lastError - )) - } - - var helperHealthSummary: String { - helperHealthState.summary - } - - var helperHealthMenuSummary: String { - helperHealthState.menuSummary - } - - var helperHealthNeedsAttention: Bool { - helperHealthState.needsAttention - } - - var helperRepairActionAvailable: Bool { - helperHealthState.repairActionAvailable - } - - var helperRecoverySuggestion: String? { - helperHealthState.recoverySuggestion - } - - var helperInstallRuntimeContext: String? { - helperHealthState.installRuntimeContext - } - - var helperFailureNotificationBody: String { - fanWriteBlockedWhileHotRecoverySuggestion - ?? helperRecoverySuggestion - ?? "Repair or approve the fan helper before requesting fan control." - } - - var helperFailureNotificationTitle: String { - fanWriteBlockedWhileHotSummary == nil - ? "Vifty fan helper needs attention" - : "Vifty fan writes are blocked while hot" - } - - var helperSupportEvidenceContext: HelperSupportEvidenceContext { - var lines: [String] = [ - "selectedMode=\(selectedMode.rawValue)", - "manualRun=\(manualRunLimit.label)", - "daemon=reachable=\(daemonReachable) responding=\(daemonResponding)", - "helper=\(helperHealthSummary)", - "controlOwner=\(controlOwnershipSummary)" - ] - - if let helperInstallRuntimeContext { - lines.append("helperRuntime=\(helperInstallRuntimeContext)") - } - if let helperRecoverySuggestion { - lines.append("helperRecovery=\(helperRecoverySuggestion)") - } - if let fanWriteBlockedWhileHotSummary { - lines.append("hotFanWrites=\(fanWriteBlockedWhileHotSummary)") - } - if let fanWriteBlockedWhileHotRecoverySuggestion { - lines.append("hotRecovery=\(fanWriteBlockedWhileHotRecoverySuggestion)") - } - if let sensor = selectedSensor ?? snapshot?.highestTemperature { - lines.append("selectedTemperature=\(sensor.name) \(String(format: "%.1f", sensor.celsius)) C") - } - if let fans = snapshot?.fans, !fans.isEmpty { - let fanSummary = fans - .map { "\($0.name) \($0.currentRPM) RPM (\($0.percentage)%)" } - .joined(separator: "; ") - lines.append("fans=\(fanSummary)") - } - if let agentCoolingSummary { - lines.append("agentCooling=\(agentCoolingSummary)") - } - if let lastError { - lines.append("lastError=\(lastError)") - } - - return HelperSupportEvidenceContext(lines: lines) - } - - var helperMenuRecoverySuggestion: String? { - if fanWriteBlockedWhileHotSummary != nil { - return nil - } - return helperHealthState.menuRecoverySuggestion - } - - var manualFanControlAvailable: Bool { - manualFanControlBlockedReason == nil - } - - var manualFanControlBlockedReason: String? { - guard let snapshot else { - return daemonResponding - ? "Waiting for fan telemetry before manual fan control." - : "Install or repair the fan helper before manual fan control." - } - guard snapshot.isAppleSilicon, snapshot.isMacBookPro else { - return "Unsupported hardware. Manual fan control stays blocked." - } - if helperHealthState == .runtimeMismatch { - return "Repair/Reinstall Helper before manual fan control; the installed helper does not match this Vifty app." - } - guard daemonResponding else { - return daemonReachable - ? "Repair/Reinstall Helper before manual fan control; fan telemetry is available but daemon writes are blocked." - : "Install or repair the fan helper before manual fan control." - } - if let lease = agentControlStatus?.activeLease { - if lease.isActive(at: now()) { - return "Agent \(lease.request.workload.displayName) cooling owns fan control; restore Auto before manual fan control." - } - return "Agent cooling restore is pending; restore Auto before manual fan control." - } - if agentControlStatusError != nil { - return "Agent control status is unavailable; repair helper before manual fan control." - } - guard !snapshot.fans.isEmpty else { - return "Fan data is unavailable. Manual fan control stays blocked." - } - guard snapshot.fans.contains(where: \.controllable) else { - return "No controllable fans are available. Manual fan control stays blocked." - } - guard !snapshot.temperatureSensors.isEmpty else { - return "Temperature sensors are unavailable. Manual fan control stays blocked." - } - let confirmedOwnership = FanControlOwnershipPresentation.resolve(fanControlOwnershipStatus) - switch confirmedOwnership.owner { - case .macOS, .viftyManual: - break - case .agent: - return "Agent cooling owns fan control; restore Auto before manual fan control." - case .recovery: - return "Fan recovery is pending; restore Auto before manual fan control." - case .mixedOrUnknown: - return "Fan ownership is unconfirmed; manual fan control stays blocked." - } - return nil - } - - var manualControlAttentionSummary: String? { - guard controlState.mode != .auto, - helperWritePathBlockedSummary != nil else { - return nil - } - return "\(manualModeName) request pending · fan writes blocked" - } - - var manualControlAttentionRecoverySuggestion: String? { - guard manualControlAttentionSummary != nil else { return nil } - return "Vifty will retry \(manualModeName) when the helper responds. Use \(autoRestoreActionTitle) to stop retries; copy support evidence if repair does not clear it." - } - - private var manualHelperWriteBlockedSummary: String? { - guard controlState.mode != .auto, - let helperWritePathBlockedSummary else { - return nil - } - return "\(helperWritePathBlockedSummary) · Vifty will retry \(manualModeName) when the helper responds" - } - - private var manualModeName: String { - switch controlState.mode { - case .auto: - return "manual control" - case .fixedRPM: - return "Fixed" - case .temperatureCurve: - return "Curve" - } - } - - private var manualRunOwnershipSummary: String { - let runLimitSummary = manualSessionExpiresAt.map { - "until \($0.formatted(date: .omitted, time: .shortened))" - } ?? "until changed" - return "\(runLimitSummary); reasserts if macOS drifts" - } - - private var autoControlOwnershipSummary: String { - guard let fans = snapshot?.fans, !fans.isEmpty else { - return daemonReachable - ? "Auto selected · fan hardware state unavailable" - : "Auto selected · fan writes blocked until helper responds" - } - - if let helperWritePathBlockedSummary { - return helperWritePathBlockedSummary - } - if !autoSystemModeFans.isEmpty { - return "macOS System/protected owns fan control · \(fanIDList(autoSystemModeFans))" - } - if !autoForcedModeFans.isEmpty { - return "Hardware reports Forced mode while Vifty is Auto · \(fanIDList(autoForcedModeFans))" - } - if !autoUnknownModeFans.isEmpty { - return "Auto selected · unknown hardware mode on \(fanIDList(autoUnknownModeFans))" - } - if !autoMissingModeFans.isEmpty { - return "Auto selected · hardware mode unavailable on \(fanIDList(autoMissingModeFans))" - } - return "macOS Auto owns fan control" - } - - private var manualControlDriftSummary: String? { - guard controlState.mode != .auto, - let fans = snapshot?.fans.filter(\.controllable), - !fans.isEmpty else { - return nil - } - - let reclaimed = fans.filter { fan in - guard let mode = fan.hardwareMode else { return false } - return mode != .forced - } - if !reclaimed.isEmpty { - let modes = reclaimed.reduce(into: [String]()) { names, fan in - guard let name = fan.hardwareMode?.displayName, !names.contains(name) else { return } - names.append(name) - }.joined(separator: "/") - let modeLabel = modes.isEmpty ? "non-forced mode" : modes - return "Hardware reports \(modeLabel) while Vifty manual is selected; Vifty will reassert · \(fanIDList(reclaimed))" - } - - let drifted = manualTargetDriftAttentionFans - if !drifted.isEmpty { - return "Hardware fan target drift detected; Vifty will reassert · \(fanIDList(drifted))" - } - - let unconfirmedResponse = fans.filter { fan in - guard fan.targetRPM == nil, - manualResponseAttentionIsWarranted, - let expectedRPM = expectedManualTargetRPM(for: fan) else { - return false - } - return expectedRPM - fan.currentRPM >= Self.manualResponseRPMGapThreshold - } - guard !unconfirmedResponse.isEmpty else { return nil } - return "Manual fan response not confirmed; current RPM is still below requested target · \(fanIDList(unconfirmedResponse))" - } - - private var currentManualTargetDriftFans: [Fan] { - guard controlState.mode != .auto, - let fans = snapshot?.fans.filter(\.controllable), - !fans.isEmpty else { - return [] - } - - return fans.filter { fan in - guard !manualTargetSettlingFanIDs.contains(fan.id), - fan.hardwareMode == .forced, - let targetRPM = fan.targetRPM, - let expectedRPM = expectedManualTargetRPM(for: fan) else { - return false - } - return abs(targetRPM - expectedRPM) >= Self.manualTargetDriftRPMThreshold - } - } - - private var manualTargetDriftAttentionFans: [Fan] { - let drifted = currentManualTargetDriftFans - if !hasCompletedHardwarePoll { - return drifted - } - return drifted.filter { fan in - (manualTargetDriftSampleCounts[fan.id] ?? 0) >= Self.manualTargetDriftAttentionSampleCount - } - } - - private func updateManualTargetDriftStability() { - let driftedIDs = Set(currentManualTargetDriftFans.map(\.id)) - guard !driftedIDs.isEmpty else { - manualTargetDriftSampleCounts = [:] - return - } - - manualTargetDriftSampleCounts = driftedIDs.reduce(into: [:]) { countsByFanID, fanID in - let count = (manualTargetDriftSampleCounts[fanID] ?? 0) + 1 - countsByFanID[fanID] = min(count, Self.manualTargetDriftAttentionSampleCount) - } - } - - private var manualResponseAttentionIsWarranted: Bool { - if thermalPressure == .serious || thermalPressure == .critical { - return true - } - guard let sensor = snapshot?.highestTemperature else { return false } - return sensor.celsius >= Self.highTemperatureAttentionThreshold - } - - private func fixedModeTargetSummary(fallbackRPM: Int) -> String { - guard perFanFixedRPMApplies else { return "\(fallbackRPM) RPM" } - - let controllableFans = snapshot?.fans.filter(\.controllable) ?? [] - if !controllableFans.isEmpty { - return controllableFans.map { fan in - let rpm = expectedManualTargetRPM(for: fan) - ?? FanCurve.clamp(fallbackRPM, fan.minimumRPM, fan.maximumRPM) - return "\(fan.name) \(rpm) RPM" - } - .joined(separator: ", ") - } - - let targets = fixedFanTargets.sorted { $0.fanID < $1.fanID } - guard !targets.isEmpty else { return "per-fan RPM" } - return targets - .map { target in "F\(target.fanID) \(target.rpm) RPM" } - .joined(separator: ", ") - } - - private func expectedManualTargetRPM(for fan: Fan) -> Int? { - if let lastAppliedRPM = controlState.lastAppliedRPM[fan.id] { - return lastAppliedRPM - } - - switch controlState.mode { - case .auto: - return nil - case .fixedRPM(let rpm): - if perFanFixedRPMApplies, let target = fixedFanTarget(for: fan.id)?.rpm { - return FanCurve.clamp(target, fan.minimumRPM, fan.maximumRPM) - } - return FanCurve.clamp(rpm, fan.minimumRPM, fan.maximumRPM) - case .temperatureCurve(let curve): - guard let snapshot, - let sensor = snapshot.temperatureSensors.first(where: { $0.id == (curve.sensorID ?? controlState.selectedSensorID) }) - ?? selectedSensor else { - return nil - } - return curve.targetRPM( - for: sensor.celsius, - minimumRPM: fan.minimumRPM, - maximumRPM: fan.maximumRPM - ) - } - } - - private var autoSystemModeFans: [Fan] { - guard controlState.mode == .auto else { return [] } - return snapshot?.fans.filter { $0.hardwareMode == .system } ?? [] - } - - private var autoForcedModeFans: [Fan] { - guard controlState.mode == .auto else { return [] } - return snapshot?.fans.filter { $0.hardwareMode == .forced } ?? [] - } - - private var autoUnknownModeFans: [Fan] { - guard controlState.mode == .auto else { return [] } - return snapshot?.fans.filter { fan in - if case .unknown = fan.hardwareMode { - return true - } - return false - } ?? [] - } - - private var autoMissingModeFans: [Fan] { - guard controlState.mode == .auto else { return [] } - return snapshot?.fans.filter { $0.hardwareMode == nil } ?? [] - } - - private func fanIDList(_ fans: [Fan]) -> String { - fans.map { "F\($0.id)" }.joined(separator: ", ") - } - - var fanRange: ClosedRange { - let bounds = fixedRPMBaseBounds - return Double(bounds.minimumRPM)...Double(bounds.maximumRPM) - } - - private func rpm(forPercent percent: Int) -> Int { - let bounds = fixedRPMBaseBounds - let span = bounds.maximumRPM - bounds.minimumRPM - let rpm = bounds.minimumRPM + Int((Double(span) * Double(percent) / 100.0).rounded()) - return FanCurve.clamp(rpm, bounds.minimumRPM, bounds.maximumRPM) - } - - private func currentCurve() -> FanCurve { - FanCurve(sensorID: resolvedCurveSensorID, points: [ - CurvePoint(temperatureCelsius: curveStartTemp, rpm: Int(curveStartRPM.rounded())), - CurvePoint(temperatureCelsius: curveMidTemp, rpm: Int(curveMidRPM.rounded())), - CurvePoint(temperatureCelsius: curveMaxTemp, rpm: Int(curveMaxRPM.rounded())) - ]) - } - - private func fixedFanTargetMap(for draft: FanControlDraft) -> [Int: Int] { - guard draft.mode == .fixed, draft.usePerFanFixedRPM else { return [:] } - if let fans = snapshot?.fans { - guard fans.filter(\.controllable).count > 1 else { return [:] } - } else { - guard draft.fixedFanTargets.count > 1 else { return [:] } - } - return draft.fixedFanTargets.reduce(into: [Int: Int]()) { targetsByID, target in - targetsByID[target.fanID] = target.rpm - } - } - - private var perFanFixedRPMApplies: Bool { - guard usePerFanFixedRPM else { return false } - if let fans = snapshot?.fans { - return fans.filter(\.controllable).count > 1 - } - return fixedFanTargets.count > 1 - } - - private func selectedFanMode() -> FanMode { - fanMode(for: currentFanControlDraft) - } - - private func fanMode(for draft: FanControlDraft) -> FanMode { - fanControlSessionController.fanMode(for: draft) - } - - func applyCurveOverrides() { - markFanControlDraftPending() - } - - private func restoreAutoIfManualSessionExpired() async -> FanControlSessionOperation? { - guard fanControlSessionController.shouldRestoreExpiredManualSession( - selectedMode: selectedMode, - manualSessionExpiresAt: manualSessionExpiresAt - ) else { - return nil - } - - let generation = beginAutoRestoreOperation() - guard fanControlOperationIsCurrent(generation) else { return nil } - await coordinator.setMode(.auto) - guard fanControlOperationIsCurrent(generation) else { return nil } - return generation - } - - private func recordAutoRestorationApplied() { - fanControlSessionController.recordAutoRestorationApplied(currentDraft: currentFanControlDraft) - fanControlApplyState = .applied - } - -#if DEBUG - func configureReviewFixtureAppliedFanControlDraft() { - fanControlSessionController.recordAutoRestorationApplied(currentDraft: currentFanControlDraft) - fanControlApplyState = .applied - } -#endif - - private func beginAutoRestoreOperation() -> FanControlSessionOperation { - let operation = fanControlSessionController.beginAutoOperation() - fanControlApplyState = .applying - lastError = nil - selectedMode = .auto - manualSessionExpiresAt = nil - return operation - } - - private func fanControlOperationIsCurrent(_ operation: FanControlSessionOperation) -> Bool { - fanControlSessionController.isCurrent(operation) - } - - private func manualFanControlOperationIsCurrent(_ operation: FanControlSessionOperation) -> Bool { - fanControlSessionController.isCurrentManual(operation, selectedMode: selectedMode) - } - - private func canCommitAutoRestoration(generation: FanControlSessionOperation) -> Bool { - fanControlSessionController.canCommitAutoRestoration( - operation: generation, - selectedMode: selectedMode, - controlState: controlState - ) - } - - private func reconcileManualApplyAttemptAfterSuccessfulPoll() { - guard let reconciliation = fanControlSessionController.reconcileManualApplyAfterSuccessfulPoll( - operationSelectedMode: selectedMode, - controlState: controlState, - currentDraft: currentFanControlDraft - ) else { return } - manualSessionExpiresAt = reconciliation.manualSessionExpiresAt - fanControlApplyState = reconciliation.applyState - } - - private func restorePreviousManualDeadline(for operation: FanControlManualOperation) { - // Use the operation's immutable capture: the polling path may have - // advanced controller reconciliation state before a later check fails. - manualSessionExpiresAt = operation.previousSessionExpiresAt - } - - private func rejectUnconfirmedManualApply( - _ operation: FanControlManualOperation, - provisionalSessionExpiresAt: Date? - ) { - fanControlSessionController.rejectManualApply(operation.operation) - // If this was the first finite Apply there is no previous deadline to - // restore. Keep the provisional bound because the hardware transaction - // completed before its final ownership read failed; never turn that - // bounded user request into an indefinite manual session. - manualSessionExpiresAt = operation.previousSessionExpiresAt - ?? provisionalSessionExpiresAt - } - - private func shouldPreserveManualIntent(afterTickFailure error: Error, attemptedMode: FanMode) -> Bool { - guard attemptedMode != .auto else { return false } - guard let viftyError = error as? ViftyError else { return true } - - switch viftyError { - case .helperRejected, .smcUnavailable, .smcOpenFailed, .smcCallFailed, .smcKeyUnavailable, .smcWriteRejected: - return true - case .unsupportedHardware: - return false - case .noTemperatureSensors: - return true - case .noControllableFans: - return true - } - } - - private func refreshAgentControlStatus() async { - do { - agentControlStatus = try await agentStatusReader() - agentCoolingEnabled = agentControlStatus?.policy?.enabled - agentControlStatusError = nil - } catch { - agentControlStatusError = error.localizedDescription - } - } - - func setAgentCoolingEnabled(_ enabled: Bool) async { - do { - guard let status = try await agentPolicySetter(enabled) else { return } - agentControlStatus = status - agentCoolingEnabled = status.policy?.enabled - agentControlStatusError = nil - } catch { - agentControlStatusError = error.localizedDescription - } - } - - private func refreshFanControlOwnershipStatus() async { - do { - fanControlOwnershipStatus = try await coordinator.fanControlOwnershipStatus() - fanControlOwnershipStatusError = nil - } catch { - fanControlOwnershipStatus = nil - fanControlOwnershipStatusError = error.localizedDescription - } - } - - private func refreshManualControlPreflight() async { - daemonResponding = await daemonPing() - if let snapshot { - daemonReachable = daemonResponding || !snapshot.fans.isEmpty - } else { - daemonReachable = daemonResponding - } - - // A draft must never infer ownership from its selected mode or from - // stale fan telemetry. Refresh the daemon's transaction status as part - // of the same preflight that authorizes every manual Apply attempt. - await refreshFanControlOwnershipStatus() - - guard agentControlStatus?.activeLease == nil || agentControlStatusError != nil else { - return - } - - do { - guard let status = try await agentStatusReader() else { return } - agentControlStatus = status - agentCoolingEnabled = status.policy?.enabled - agentControlStatusError = nil - } catch { - agentControlStatusError = error.localizedDescription - } - } - - private func syncCurveDefaultsIfNeeded(from snapshot: HardwareSnapshot) { - guard !curveDefaultsSynced, - let fan = snapshot.fans.first(where: \.controllable) else { return } - curveStartRPM = Double(fan.minimumRPM) - curveMaxRPM = Double(fan.maximumRPM) - if selectedSensorID == nil { - setProgrammaticSelectedSensorID(selectedSensor?.id) - } - curveDefaultsSynced = true - } - - private func setProgrammaticSelectedSensorID(_ sensorID: String?) { - isSettingSelectedSensorProgrammatically = true - selectedSensorID = sensorID - isSettingSelectedSensorProgrammatically = false - } - - private func defaultFanOverride(for fan: Fan) -> FanCurveOverride { - FanCurveOverride( - fanID: fan.id, - startRPM: FanCurve.clamp(Int(curveStartRPM.rounded()), fan.minimumRPM, fan.maximumRPM), - midRPM: FanCurve.clamp(Int(curveMidRPM.rounded()), fan.minimumRPM, fan.maximumRPM), - maxRPM: FanCurve.clamp(Int(curveMaxRPM.rounded()), fan.minimumRPM, fan.maximumRPM) - ) - } - - private func defaultFixedFanTarget(for fan: Fan) -> FixedFanTarget { - FixedFanTarget( - fanID: fan.id, - rpm: defaultFixedFanTargetRPM(for: fan) - ) - } - - private var fixedRPMBaseBounds: (minimumRPM: Int, maximumRPM: Int) { - fixedRPMBaseBounds(for: snapshot?.fans ?? []) - } - - private func fixedRPMBaseBounds(for fans: [Fan]) -> (minimumRPM: Int, maximumRPM: Int) { - let fan = fans.first(where: \.controllable) ?? fans.first - return ( - minimumRPM: fan?.minimumRPM ?? 1200, - maximumRPM: fan?.maximumRPM ?? 6500 - ) - } - - private var fixedRPMBaseRangeRatio: Double { - fixedRPMBaseRangeRatio(for: fixedRPMBaseBounds) - } - - private func fixedRPMBaseRangeRatio(for bounds: (minimumRPM: Int, maximumRPM: Int)) -> Double { - guard bounds.maximumRPM > bounds.minimumRPM else { return 0 } - let clamped = FanCurve.clamp(Int(fixedRPM.rounded()), bounds.minimumRPM, bounds.maximumRPM) - return Double(clamped - bounds.minimumRPM) / Double(bounds.maximumRPM - bounds.minimumRPM) - } - - private func defaultFixedFanTargetRPM(for fan: Fan) -> Int { - defaultFixedFanTarget(for: fan, baseRatio: fixedRPMBaseRangeRatio).rpm - } - - private func defaultFixedFanTarget(for fan: Fan, baseRatio: Double) -> FixedFanTarget { - guard fan.maximumRPM > fan.minimumRPM else { - return FixedFanTarget( - fanID: fan.id, - rpm: FanCurve.clamp(Int(fixedRPM.rounded()), fan.minimumRPM, fan.maximumRPM) - ) - } - let rpm = Double(fan.minimumRPM) + Double(fan.maximumRPM - fan.minimumRPM) * baseRatio - return FixedFanTarget( - fanID: fan.id, - rpm: FanCurve.clamp(Int(rpm.rounded()), fan.minimumRPM, fan.maximumRPM) - ) - } - - private func rpmPercent(_ rpm: Int, for fan: Fan) -> Int { - guard fan.maximumRPM > fan.minimumRPM else { return 0 } - let clamped = FanCurve.clamp(rpm, fan.minimumRPM, fan.maximumRPM) - let ratio = Double(clamped - fan.minimumRPM) / Double(fan.maximumRPM - fan.minimumRPM) - return min(100, max(0, Int((ratio * 100).rounded()))) - } - - private func updateFixedFanTarget(for fan: Fan, mutate: (inout FixedFanTarget) -> Void) { - if let index = fixedFanTargets.firstIndex(where: { $0.fanID == fan.id }) { - mutate(&fixedFanTargets[index]) - } else { - var target = defaultFixedFanTarget(for: fan) - mutate(&target) - fixedFanTargets.append(target) - } - fixedFanTargets = fixedFanTargets.reduce(into: [Int: FixedFanTarget]()) { targetsByID, target in - targetsByID[target.fanID] = target - } - .sorted { $0.key < $1.key } - .map(\.value) - } - - private func updateFanOverride(for fan: Fan, mutate: (inout FanCurveOverride) -> Void) { - // Fan-curve resolution and presentation are deliberately last-wins for - // duplicate persisted records. Mutate that same effective record before - // canonicalizing, otherwise the untouched final duplicate would erase - // the user's edit during the reduction below. - if let index = fanOverrides.lastIndex(where: { $0.fanID == fan.id }) { - mutate(&fanOverrides[index]) - } else { - var override = defaultFanOverride(for: fan) - mutate(&override) - fanOverrides.append(override) - } - fanOverrides = fanOverrides.reduce(into: [Int: FanCurveOverride]()) { overridesByID, override in - overridesByID[override.fanID] = override - } - .sorted { $0.key < $1.key } - .map(\.value) - } - - private func syncState() async { - assignIfChanged(\.controlState, await coordinator.state) - manualTargetSettlingFanIDs = await coordinator.recentManualWriteFanIDs( - at: now(), - within: Self.manualTargetWriteSettleInterval - ) - updateManualTargetDriftStability() - } - - private func evaluateLocalNotifications(power: PowerSnapshot, thermalPressure: ThermalPressure) async { - if let authorization = await localNotificationCoordinator.evaluate( - settings: notificationSettings, - input: LocalNotificationEvaluationInput( - helperNeedsAttention: helperHealthState.notifiesAsHelperFailure, - helperTitle: helperFailureNotificationTitle, - helperBody: helperFailureNotificationBody, - agentNeedsAttention: agentCoolingNeedsAttention, - agentBody: agentCoolingRecoverySuggestion - ?? agentCoolingSummary - ?? "Check Vifty before starting another developer workload.", - power: power, - thermalPressure: thermalPressure - ) - ) { - notificationAuthorization = authorization - } - } - - private func notifyAutoRestoreFailure(_ message: String) async { - if let authorization = await localNotificationCoordinator.notifyAutoRestoreFailure( - message, - settings: notificationSettings - ) { - notificationAuthorization = authorization - } - } - - func setNotificationEnabled(_ kind: LocalNotificationKind, isEnabled: Bool) { - notificationSettings.set(kind, enabled: isEnabled) - guard isEnabled else { return } - Task { [weak self] in - guard let self else { return } - let status = await localNotificationCoordinator.requestAuthorization() - notificationAuthorization = status - } - } - - func refreshNotificationAuthorization() async { - notificationAuthorization = await localNotificationCoordinator.authorizationStatus() - } - - func sendTestNotification() async { - let result = await localNotificationCoordinator.sendTestNotification() - notificationAuthorization = result.authorization - notificationTestMessage = result.delivered - ? "Test notification sent." - : "Test notification was not delivered." - } - - func openNotificationSettings() async { - notificationAuthorization = await localNotificationCoordinator.openSettings() - } } enum ModeSelection: String, Codable, CaseIterable, Identifiable { diff --git a/Sources/ViftyCore/HardwareService.swift b/Sources/ViftyCore/HardwareService.swift index c831ad59..fb1bd06d 100644 --- a/Sources/ViftyCore/HardwareService.swift +++ b/Sources/ViftyCore/HardwareService.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation public protocol HardwareService: Sendable { @@ -279,7 +280,9 @@ public actor FanControlCoordinator { autoRestoreRequested = false lastManualWriteAtByFanID = [:] state.manualControlActive = true - uncleanMarker.markActive() + if !uncleanMarker.markActive() { + state.statusMessage = "Manual fan control is active, but the crash-recovery marker could not be written; restore Auto before quitting." + } } } @@ -695,28 +698,90 @@ public struct ManualControlMarker: Sendable { FileManager.default.fileExists(atPath: url.path) } - public func markActive() { + @discardableResult + public func markActive() -> Bool { let directory = url.deletingLastPathComponent() - try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - try? FileManager.default.setAttributes( - [.posixPermissions: NSNumber(value: 0o700)], - ofItemAtPath: directory.path - ) - if FileManager.default.fileExists(atPath: url.path) { - try? FileManager.default.setAttributes( - [.posixPermissions: NSNumber(value: 0o600)], - ofItemAtPath: url.path + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: 0o700)], + ofItemAtPath: directory.path ) - return + } catch { + return false + } + + // Reassert permissions on an existing marker without replacing its + // inode, so a repeated mark preserves the original file identity. + if FileManager.default.fileExists(atPath: url.path) { + do { + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: 0o600)], + ofItemAtPath: url.path + ) + return true + } catch { + return false + } + } + + // The marker is the unclean-exit recovery signal for the next launch: + // write it through a same-directory temporary file, fsync the file and + // directory, then rename, so a crash or power loss cannot lose the + // evidence after a manual fan write has been issued. + let temporaryURL = directory.appendingPathComponent( + ".manual-control-active.tmp.\(UUID().uuidString)", + isDirectory: false + ) + let descriptor = open(temporaryURL.path, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0o600) + guard descriptor >= 0 else { return false } + defer { close(descriptor) } + let bytes = Array("active".utf8) + var written = 0 + while written < bytes.count { + let count = bytes.withUnsafeBytes { buffer in + Darwin.write(descriptor, buffer.baseAddress!.advanced(by: written), bytes.count - written) + } + if count < 0 { + if errno == EINTR { continue } + try? FileManager.default.removeItem(at: temporaryURL) + return false + } + written += count + } + guard fsync(descriptor) == 0 else { + try? FileManager.default.removeItem(at: temporaryURL) + return false } - try? Data("active".utf8).write(to: url, options: .atomic) try? FileManager.default.setAttributes( [.posixPermissions: NSNumber(value: 0o600)], - ofItemAtPath: url.path + ofItemAtPath: temporaryURL.path ) + guard rename(temporaryURL.path, url.path) == 0 else { + try? FileManager.default.removeItem(at: temporaryURL) + return false + } + let directoryDescriptor = open(directory.path, O_RDONLY | O_DIRECTORY | O_CLOEXEC) + if directoryDescriptor >= 0 { + fsync(directoryDescriptor) + close(directoryDescriptor) + } + return true } - public func clear() { - try? FileManager.default.removeItem(at: url) + @discardableResult + public func clear() -> Bool { + do { + try FileManager.default.removeItem(at: url) + let directory = url.deletingLastPathComponent() + let directoryDescriptor = open(directory.path, O_RDONLY | O_DIRECTORY | O_CLOEXEC) + if directoryDescriptor >= 0 { + fsync(directoryDescriptor) + close(directoryDescriptor) + } + return true + } catch { + return !FileManager.default.fileExists(atPath: url.path) + } } } diff --git a/Tests/ViftyCoreTests/MakefileTrustGateTests.swift b/Tests/ViftyCoreTests/MakefileTrustGateTests.swift index be5df033..86a94af8 100644 --- a/Tests/ViftyCoreTests/MakefileTrustGateTests.swift +++ b/Tests/ViftyCoreTests/MakefileTrustGateTests.swift @@ -5,10 +5,10 @@ final class MakefileTrustGateTests: XCTestCase { func testVerifyTargetRunsLocalTrustGates() throws { let makefile = try read("Makefile") - XCTAssertTrue(makefile.contains("verify: ## Run fast local trust gates without installing")) + XCTAssertTrue(makefile.contains("verify: check-toolchain ## Run fast local trust gates without installing")) XCTAssertTrue(makefile.contains("verify-full: verify release-contract-ruby-tests installer-lifecycle-ruby-tests ui-review-ruby-tests ## Run full trust gates, including slow XCTest suites, for CI/release-facing checks")) - XCTAssertTrue(makefile.contains("test-fast: ## Run the fast local XCTest suite")) - XCTAssertTrue(makefile.contains("test-full: ## Run the full XCTest suite, including slow evidence/release script tests")) + XCTAssertTrue(makefile.contains("test-fast: check-toolchain ## Run the fast local XCTest suite")) + XCTAssertTrue(makefile.contains("test-full: check-toolchain ## Run the full XCTest suite, including slow evidence/release script tests")) XCTAssertTrue(makefile.contains("repair-helper: ## Explicitly repair the installed privileged helper")) XCTAssertTrue(makefile.contains("validation-evidence: ## Collect read-only release/hardware validation evidence")) XCTAssertTrue(makefile.contains("validation-evidence-current-build: ## Build current app and collect read-only local-ad-hoc validation evidence")) @@ -163,15 +163,15 @@ final class MakefileTrustGateTests: XCTestCase { let makefile = try read("Makefile") XCTAssertTrue(makefile.contains("install-dev-adhoc repair-helper uninstall-helper")) - XCTAssertTrue(makefile.contains("run-app: ## Build and open the local app bundle")) + XCTAssertTrue(makefile.contains("run-app: check-toolchain ## Build and open the local app bundle")) XCTAssertTrue(makefile.contains("install-public-release: ## Verify and install the exact current published release archive")) XCTAssertTrue(makefile.contains("PUBLIC_RELEASE_ARCHIVE is required and must be an absolute path to the canonical public release zip")) XCTAssertTrue(makefile.contains("CONFIGURATION=release ./scripts/install-vifty.sh --public-release-archive \"$(PUBLIC_RELEASE_ARCHIVE)\"")) - XCTAssertTrue(makefile.contains("verify: ## Run fast local trust gates without installing")) + XCTAssertTrue(makefile.contains("verify: check-toolchain ## Run fast local trust gates without installing")) XCTAssertTrue(makefile.contains("verify-full: verify release-contract-ruby-tests installer-lifecycle-ruby-tests ui-review-ruby-tests ## Run full trust gates, including slow XCTest suites, for CI/release-facing checks")) XCTAssertTrue(makefile.contains("repair-helper: ## Explicitly repair the installed privileged helper")) XCTAssertTrue(makefile.contains("./scripts/repair-vifty-helper.sh --app \"$(REPAIR_HELPER_APP)\"")) - XCTAssertTrue(makefile.contains("install-dev-adhoc: ## Explicit debug-only install with exact UID/path XPC allowlist")) + XCTAssertTrue(makefile.contains("install-dev-adhoc: check-toolchain ## Explicit debug-only install with exact UID/path XPC allowlist")) XCTAssertTrue(makefile.contains("VIFTY_ENABLE_ADHOC_XPC=1 ./scripts/install-vifty.sh")) XCTAssertTrue(makefile.contains("uninstall-helper: ## Safely remove the installed privileged helper")) XCTAssertTrue(makefile.contains("validation-evidence: ## Collect read-only release/hardware validation evidence")) diff --git a/Tests/ViftyCoreTests/ManualControlMarkerTests.swift b/Tests/ViftyCoreTests/ManualControlMarkerTests.swift index e0a1b3c9..0930a4a5 100644 --- a/Tests/ViftyCoreTests/ManualControlMarkerTests.swift +++ b/Tests/ViftyCoreTests/ManualControlMarkerTests.swift @@ -22,25 +22,25 @@ final class ManualControlMarkerTests: XCTestCase { func testMarkActiveCreatesFile() { let url = tempURL() let marker = ManualControlMarker(url: url) - marker.markActive() + XCTAssertTrue(marker.markActive()) XCTAssertTrue(marker.wasManualControlActive) } func testClearRemovesFile() { let url = tempURL() let marker = ManualControlMarker(url: url) - marker.markActive() + XCTAssertTrue(marker.markActive()) XCTAssertTrue(marker.wasManualControlActive) - marker.clear() + XCTAssertTrue(marker.clear()) XCTAssertFalse(marker.wasManualControlActive) } func testDoubleMarkKeepsExistingMarkerIdentity() throws { let url = tempURL() let marker = ManualControlMarker(url: url) - marker.markActive() + XCTAssertTrue(marker.markActive()) let originalAttributes = try FileManager.default.attributesOfItem(atPath: url.path) - marker.markActive() + XCTAssertTrue(marker.markActive()) let repeatedAttributes = try FileManager.default.attributesOfItem(atPath: url.path) XCTAssertTrue(marker.wasManualControlActive) XCTAssertEqual( @@ -53,8 +53,8 @@ final class ManualControlMarkerTests: XCTestCase { func testDoubleClearDoesNotCrash() { let url = tempURL() let marker = ManualControlMarker(url: url) - marker.clear() - marker.clear() + XCTAssertTrue(marker.clear()) + XCTAssertTrue(marker.clear()) XCTAssertFalse(marker.wasManualControlActive) } @@ -62,12 +62,23 @@ final class ManualControlMarkerTests: XCTestCase { let url = tempURL() let marker = ManualControlMarker(url: url) - marker.markActive() + XCTAssertTrue(marker.markActive()) XCTAssertEqual(try posixPermissions(at: url.deletingLastPathComponent()), 0o700) XCTAssertEqual(try posixPermissions(at: url), 0o600) } + func testMarkActiveFailsWhenDirectoryCannotBeCreated() throws { + let url = tempURL() + let directory = url.deletingLastPathComponent() + try Data("not a directory".utf8).write(to: directory) + defer { try? FileManager.default.removeItem(at: directory) } + + let marker = ManualControlMarker(url: url) + XCTAssertFalse(marker.markActive()) + XCTAssertFalse(marker.wasManualControlActive) + } + private func tempURL() -> URL { FileManager.default .temporaryDirectory diff --git a/scripts/check-toolchain.sh b/scripts/check-toolchain.sh new file mode 100755 index 00000000..2f16f435 --- /dev/null +++ b/scripts/check-toolchain.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Vifty builds SwiftUI views whose macros are compiled from Xcode's SwiftUI +# framework. The Command Line Tools SDK ships no SwiftUIMacros plugin, so a +# CLT-only developer directory fails with cryptic "SwiftUIMacros" errors. + +if [[ -n "${DEVELOPER_DIR:-}" ]]; then + # An explicit per-shell toolchain override takes precedence; honor it. + exit 0 +fi + +developer_dir="$(/usr/bin/xcode-select -p 2>/dev/null || true)" +if [[ "${developer_dir}" == *"/CommandLineTools"* ]]; then + echo "check-toolchain: the active developer directory is the Command Line Tools SDK (${developer_dir})." >&2 + echo "check-toolchain: Vifty needs a full Xcode toolchain for SwiftUI macros; builds under CLT fail with" >&2 + echo "check-toolchain: 'SwiftUIMacros' compile errors. Select Xcode once and re-run:" >&2 + echo "check-toolchain: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" >&2 + echo "check-toolchain: or set DEVELOPER_DIR for this shell, e.g.:" >&2 + echo "check-toolchain: DEVELOPER_DIR=/Applications/Xcode-beta.app/Contents/Developer make verify" >&2 + exit 1 +fi + +if ! /usr/bin/xcodebuild -version >/dev/null 2>&1; then + echo "check-toolchain: xcodebuild is unavailable under the active developer directory '${developer_dir}'." >&2 + echo "check-toolchain: Select a full Xcode installation and re-run, or set DEVELOPER_DIR for this shell." >&2 + exit 1 +fi