Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@

## Checklist
- [ ] Builds without errors (`xcodegen generate && xcodebuild -scheme ChillMac build`)
- [ ] Unit tests pass (`xcodebuild -project ChillMac.xcodeproj -scheme ChillMac -destination 'platform=macOS' test`)
- [ ] Tested manually in the app
27 changes: 25 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ ChillMac/
BatteryInfo Battery info via IOKit/IOPowerSources (polls every 5s)
SMC/ - IOKit bridge (SMCConnection, SMCTypes, SMCKeys)
XPC/ - HelperConnection (client), HelperInstaller
Preview/ - PreviewSupport factories for SwiftUI canvas + tests (#if DEBUG)
FanControlHelper/
main.swift - Helper daemon entry point
HelperDelegate.swift - XPC listener + code signature validation
Expand Down Expand Up @@ -123,6 +124,28 @@ scripts/
- 420x640 main popover, 370x560 detail panels
- Footer with quit button, app name, and °F/°C toggle

## No Tests
## Testing

There is no test suite. Testing is done manually via the UI.
Unit tests live in `ChillMacTests` (Swift Testing). Run:

```bash
xcodegen generate && xcodebuild -project ChillMac.xcodeproj -scheme ChillMac -destination 'platform=macOS' test
```

Layout (door-open for future suites — prefer these homes over reshuffling):

```
ChillMacTests/
Support/ Tags.swift, harness smoke
Fixtures/ PreviewSupportTests, etc.
Unit/ Pure / parallel-safe (Fan/, SMC/, App/)
Integration/ Live host / XPC later (Fan/, SMC/, XPC/)
Mocks/ Fakes for isolation
```

- Use `@Test` / `#expect` / `@Suite` (Swift Testing only — no XCTest UI suite yet)
- Tag suites via `Support/Tags.swift`: `.unit`, `.integration`, `.fan`, `.fixtures` — e.g. `@Suite("PerformanceCurve", .tags(.unit, .fan))`. Default parallel OK for pure math; use `.serialized` only for shared UserDefaults/SMC. Tags enable CI filters without changing `project.yml`
- Sample data for fixtures and canvas comes from `ChillMac/Preview/PreviewSupport.swift` — do not hit live SMC/XPC in unit tests or invent local sample data in views
- SwiftUI convention: add in-file `#Preview("…")` (plain macros, no traits) wired to `PreviewSupport` factories; keep `#Preview` under `#if DEBUG`
- Zero external test dependencies (no ViewInspector, no snapshot libs)
- Test plan: `TestPlans/Unit.xctestplan`
15 changes: 13 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ ChillMac/
BatteryInfo Battery info via IOKit/IOPowerSources (polls every 5s)
SMC/ - IOKit bridge (SMCConnection, SMCTypes, SMCKeys)
XPC/ - HelperConnection (client), HelperInstaller
Preview/ - PreviewSupport factories for SwiftUI canvas + tests (#if DEBUG)
FanControlHelper/
main.swift - Helper daemon entry point
HelperDelegate.swift - XPC listener + code signature validation
Expand Down Expand Up @@ -123,6 +124,16 @@ scripts/
- 420x640 main popover, 370x560 detail panels
- Footer with quit button, app name, and °F/°C toggle

## No Tests
## Testing

There is no test suite. Testing is done manually via the UI.
Unit tests live in `ChillMacTests` (Swift Testing). Run:

```bash
xcodegen generate && xcodebuild -project ChillMac.xcodeproj -scheme ChillMac -destination 'platform=macOS' test
```

- Use `@Test` / `#expect` / `@Suite` (Swift Testing only — no XCTest UI suite yet)
- Sample data for fixtures and canvas comes from `ChillMac/Preview/PreviewSupport.swift` — do not hit live SMC/XPC in unit tests or invent local sample data in views
- SwiftUI convention: add in-file `#Preview("…")` (plain macros, no traits) wired to `PreviewSupport` factories; keep `#Preview` under `#if DEBUG`
- Zero external test dependencies (no ViewInspector, no snapshot libs)
- Test plan: `TestPlans/Unit.xctestplan`
5 changes: 3 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Debug builds skip the helper's code signature check, so you don't need a Develop
1. Fork the repo and create a branch from `main`
2. Make your changes
3. Make sure it builds: `xcodegen generate && xcodebuild -scheme ChillMac build`
4. Test manually — there's no automated test suite
4. Run unit tests: `xcodebuild -project ChillMac.xcodeproj -scheme ChillMac -destination 'platform=macOS' test`
5. Open a PR

## Project Overview
Expand All @@ -40,7 +40,8 @@ Debug builds skip the helper's code signature check, so you don't need a Develop
- Match the existing code style (no linter configured, just stay consistent)
- SwiftUI views go in `ChillMac/Views/`
- New monitors/data models go in `ChillMac/Fan/`
- The app has zero external dependencies — let's keep it that way unless there's a strong reason
- Use `PreviewSupport` for sample data; add in-file `#Preview` (no live SMC/XPC in canvas)
- The app has zero external dependencies — let's keep it that way unless there's a strong reason (Swift Testing only; no third-party test libs)

## Architecture Notes

Expand Down
4 changes: 4 additions & 0 deletions ChillMac/App/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ enum PerformanceLevel: String, CaseIterable {
case medium
case high
case max
case ultra

var label: String {
switch self {
case .low: return "Low"
case .medium: return "Medium"
case .high: return "High"
case .max: return "Max"
case .ultra: return "Ultra"
}
}

Expand All @@ -22,6 +24,7 @@ enum PerformanceLevel: String, CaseIterable {
case .medium: return "Balanced baseline and ramp"
case .high: return "Aggressive baseline, fast ramp"
case .max: return "Smart max — full speed before throttle"
case .ultra: return "Pure performance — louder earlier, holds peak clocks"
}
}

Expand All @@ -31,6 +34,7 @@ enum PerformanceLevel: String, CaseIterable {
case .medium: return "fan"
case .high: return "fan.fill"
case .max: return "flame.fill"
case .ultra: return "gauge.with.dots.needle.67percent"
}
}
}
Expand Down
10 changes: 10 additions & 0 deletions ChillMac/App/DetailPanelController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,13 @@ struct DetailPanelCloseButton: View {
.buttonStyle(.plain)
}
}

#if DEBUG
#Preview("Detail Panel Close Button") {
return PreviewSupport.previewHost(theme: .dark, frame: .detail) {
DetailPanelCloseButton(action: {})
.padding()
}
}
#endif

11 changes: 11 additions & 0 deletions ChillMac/App/Theme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,14 @@ struct ThemedView<Content: View>: View {
.preferredColorScheme(settings.preferredColorScheme)
}
}

#if DEBUG
#Preview("ThemedView") {
return ThemedView(content:
Text("Sample")
.padding(20)
)
.frame(width: 200, height: 100)
}
#endif

25 changes: 17 additions & 8 deletions ChillMac/App/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// Install/load the privileged helper in the background so the UI appears immediately
DispatchQueue.global(qos: .userInitiated).async { [self] in
if HelperInstaller.isRegistered() {
// Daemon is registered — check if it's the right version
let status = HelperInstaller.checkHelperStatus()
switch status {
case .runningCorrectVersion:
Expand All @@ -44,22 +43,32 @@ class AppDelegate: NSObject, NSApplicationDelegate {
HelperInstaller.unregister()
_ = HelperInstaller.register()
case .notRunning:
// Registered but not responding — likely just needs a moment after launch
NSLog("AppDelegate: helper registered but not responding")
NSLog("AppDelegate: helper registered but not responding — re-registering")
_ = HelperInstaller.register()
}
} else {
// Not registered at all — first install, prompt is expected
NSLog("AppDelegate: helper not registered — installing")
_ = HelperInstaller.register()
}

// Reset all fans to auto on startup
self.resetFansToAuto()
HelperInstaller.openApprovalSettingsIfNeeded()

// Brief settle after registration before probing XPC
Thread.sleep(forTimeInterval: 0.5)
let liveStatus = HelperInstaller.checkHelperStatus()
let ready = HelperReadiness.isReady(liveStatus)

if ready {
self.resetFansToAuto()
}

DispatchQueue.main.async {
self.fanMonitor.helper = self.helperConnection
self.fanMonitor.helperReady = true
self.fanMonitor.setupSystemObservers()
// Only advertise helper control when XPC actually answers
self.fanMonitor.helperReady = ready
if ready {
self.fanMonitor.setupSystemObservers()
}
}
}
}
Expand Down
40 changes: 40 additions & 0 deletions ChillMac/Fan/FanControlPolicy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Foundation

/// Commits or clears local fan-target caches based on helper XPC reply.
enum FanTargetCommit {
static func apply(
ok: Bool,
fanId: Int,
rpm: Double,
manualOverrides: inout [Int: Bool],
targetOverrides: inout [Int: Double],
lastSentRPM: inout [Int: Double]
) {
if ok {
// Skip no-op writes — subscript assignment on @Published dictionaries
// republishes every time and trips "Publishing changes from within view updates".
if lastSentRPM[fanId] != rpm { lastSentRPM[fanId] = rpm }
if targetOverrides[fanId] != rpm { targetOverrides[fanId] = rpm }
if manualOverrides[fanId] != true { manualOverrides[fanId] = true }
} else {
// Do not cache a fake target — allow retries next poll
if manualOverrides[fanId] != false { manualOverrides[fanId] = false }
if targetOverrides[fanId] != nil { targetOverrides.removeValue(forKey: fanId) }
if lastSentRPM[fanId] != nil { lastSentRPM.removeValue(forKey: fanId) }
}
}
}

/// UI / control gating for Performance Mode vs live helper availability.
enum PerformanceControl {
static func isActivelyControlling(performanceMode: Bool, helperReady: Bool) -> Bool {
performanceMode && helperReady
}
}

/// Whether the app may advertise fan-write capability.
enum HelperReadiness {
static func isReady(_ status: HelperInstaller.HelperStatus) -> Bool {
status == .runningCorrectVersion
}
}
Loading