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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,9 @@ On battery power, Insomnia turns on Low Power Mode below 40% and ends the
session below 10%. Those are the defaults. You can change both in Settings,
and an end floor of 0 turns the battery end off. The heat rules turn on Low Power
Mode when the Mac gets seriously hot and end the session at critical heat.
They are on by default, and Settings can turn them off. All of these rules
Closed-lid sessions end earlier, at serious thermal pressure, by default.
Settings can change these rules. Use a ventilated surface, never a bag; these
cutoffs are not a guarantee of safe temperatures. All of these rules
only work while the app runs.

Before your first session, check Settings. Then run a short session you can
Expand Down
9 changes: 7 additions & 2 deletions Sources/Insomnia/Core/FloorRules.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ enum FloorRules {
/// - battery below `endFloor` while not charging: end session. 0 never
/// matches, so 0 turns the battery end off; Config keeps a non-zero
/// `endFloor` below `lowPowerFloor`.
/// - thermal `critical` (if `thermalRules`): end session
/// - thermal `critical` (if either applicable thermal rule): end session
/// - lid closed and thermal `serious` (if `endClosedLidWhenHot`): end
/// - battery present but unreadable on two consecutive reads while not
/// charging, with an end floor set: end session. The floor cannot be
/// applied to a level nobody can read, and the Mac is on a battery
Expand All @@ -45,9 +46,13 @@ enum FloorRules {
if let p = percent, onBattery, p < config.endFloor {
return [.endSession(.batteryFloor)]
}
if config.thermalRules, thermal == .critical {
let closedLidThermalRule = lidClosed && config.endClosedLidWhenHot
if config.thermalRules || closedLidThermalRule, thermal == .critical {
return [.endSession(.thermalCritical)]
}
if closedLidThermalRule, thermal == .serious {
return [.endSession(.thermalSeriousClosedLid)]
}
if case let .unreadable(misses) = battery, onBattery, misses >= 2, config.endFloor > 0 {
return [.endSession(.batteryUnreadable)]
}
Expand Down
2 changes: 2 additions & 0 deletions Sources/Insomnia/Core/SessionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ enum EndReason: String, Sendable {
/// while on battery: the end floor could not be applied.
case batteryUnreadable
case thermalCritical
case thermalSeriousClosedLid
case backstop
/// Reconcile found a session on disk but could not arm the recovery
/// agent, journal, or hold sleep for it, so it ended the session instead
Expand Down Expand Up @@ -3180,6 +3181,7 @@ final class SessionManager {
case .batteryFloor: "Battery fell below \(config.endFloor)%. Sleep is back to normal."
case .batteryUnreadable: "The battery level could not be read twice in a row, so the \(config.endFloor)% floor could not be applied. Sleep is back to normal."
case .thermalCritical: "Thermal state is critical. Sleep is back to normal."
case .thermalSeriousClosedLid: "The Mac became hot with the lid closed. Sleep is back to normal. Let it cool on a ventilated surface."
case .backstop: outputsWaiting
? "A previous session left changes behind. Sleep is back to normal."
: "A previous session left changes behind; everything has been undone."
Expand Down
4 changes: 4 additions & 0 deletions Sources/Insomnia/Model/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ struct Config: Codable, Equatable, Sendable {
/// `setLowPowerFloor` and `normalizeFloors`.
var endFloor: Int = 10
var thermalRules: Bool = true
/// End a closed-lid session at serious thermal pressure instead of
/// waiting for critical. Independent of the open-lid thermal rules.
var endClosedLidWhenHot: Bool = true

// Network failover
var hotspotSSID: String = ""
Expand Down Expand Up @@ -165,6 +168,7 @@ struct Config: Codable, Equatable, Sendable {
lowPowerFloor = try c.decodeIfPresent(Int.self, forKey: .lowPowerFloor) ?? d.lowPowerFloor
endFloor = try c.decodeIfPresent(Int.self, forKey: .endFloor) ?? d.endFloor
thermalRules = try c.decodeIfPresent(Bool.self, forKey: .thermalRules) ?? d.thermalRules
endClosedLidWhenHot = try c.decodeIfPresent(Bool.self, forKey: .endClosedLidWhenHot) ?? d.endClosedLidWhenHot
hotspotSSID = try c.decodeIfPresent(String.self, forKey: .hotspotSSID) ?? d.hotspotSSID
nudgeThreshold = try c.decodeIfPresent(TimeInterval.self, forKey: .nudgeThreshold) ?? d.nudgeThreshold
tmuxTargets = try c.decodeIfPresent([String].self, forKey: .tmuxTargets) ?? d.tmuxTargets
Expand Down
4 changes: 4 additions & 0 deletions Sources/Insomnia/UI/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,10 @@ struct SettingsView: View {
.font(.caption)
.foregroundStyle(.secondary)
Toggle("Thermal rules (Low Power Mode when hot, end when critical)", isOn: bind(\.thermalRules))
Toggle("End closed-lid sessions when hot", isOn: bind(\.endClosedLidWhenHot))
Text("Ends at serious thermal pressure, before critical. Keep the Mac on a ventilated surface; these rules cannot make running in a bag safe.")
.font(.caption)
.foregroundStyle(.secondary)
}
}

Expand Down
9 changes: 9 additions & 0 deletions Tests/InsomniaTests/ConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ final class ConfigTests: XCTestCase {
XCTAssertFalse(c.freezeAllApps, "freeze-all is opt in")
XCTAssertTrue(c.lowPowerOnLidClose)
XCTAssertTrue(c.thermalRules)
XCTAssertTrue(c.endClosedLidWhenHot)
XCTAssertFalse(c.disableAppNapForAgents, "writing other apps' preferences is opt-in")
XCTAssertEqual(c.hotspotSSID, "")
XCTAssertEqual(c.tmuxTargets, [])
Expand All @@ -58,6 +59,14 @@ final class ConfigTests: XCTestCase {

// MARK: Battery floors

func testClosedLidThermalRuleDefaultsOnForOlderConfigsAndHonorsOptOut() throws {
let old = try Store.makeDecoder().decode(Config.self, from: Data(#"{"thermalRules":false}"#.utf8))
XCTAssertTrue(old.endClosedLidWhenHot)
let off = try Store.makeDecoder().decode(Config.self, from: Data(#"{"endClosedLidWhenHot":false}"#.utf8))
XCTAssertFalse(off.endClosedLidWhenHot)
XCTAssertEqual(try Store.makeDecoder().decode(Config.self, from: Store.makeEncoder().encode(off)), off)
}

func testDefaultFloorsAreOrdered() {
XCTAssertTrue(Config().floorsAreOrdered)
XCTAssertEqual(Config.floorStep, 5)
Expand Down
46 changes: 44 additions & 2 deletions Tests/InsomniaTests/FloorRulesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,33 @@ final class FloorRulesTests: XCTestCase {
XCTAssertEqual(eval(80, thermal: .serious, lp: true), [])
}

func testClosedLidSeriousHeatEndsEvenWhenChargingOrAlreadyInLowPower() {
for charging in [false, true] {
for lowPower in [false, true] {
XCTAssertEqual(eval(80, charging: charging, thermal: .serious, lid: true, lp: lowPower), [.endSession(.thermalSeriousClosedLid)])
}
}
}

func testClosedLidThermalRuleIsIndependentOfOpenLidRules() {
var c = Config()
c.thermalRules = false
XCTAssertEqual(eval(80, thermal: .serious, lid: true, config: c), [.endSession(.thermalSeriousClosedLid)])
XCTAssertEqual(eval(80, thermal: .critical, lid: true, config: c), [.endSession(.thermalCritical)])
XCTAssertEqual(eval(80, thermal: .serious, config: c), [])
c.endClosedLidWhenHot = false
XCTAssertEqual(eval(80, thermal: .serious, lid: true, config: c), [.enableLowPower(.lid)])
XCTAssertEqual(eval(80, thermal: .critical, lid: true, config: c), [.enableLowPower(.lid)])
}

func testClosedLidThermalOptOutKeepsExistingLowPowerRule() {
var c = Config()
c.endClosedLidWhenHot = false
XCTAssertEqual(eval(80, thermal: .serious, lid: true, config: c), [.enableLowPower(.thermal)])
XCTAssertEqual(eval(80, thermal: .critical, lid: true, config: c), [.endSession(.thermalCritical)])
XCTAssertEqual(eval(80, thermal: .fair, lid: true), [.enableLowPower(.lid)])
}

func testThermalBackToNominalOrFairClearsLowPower() {
XCTAssertEqual(eval(80, thermal: .nominal, lp: true), [.disableLowPower])
XCTAssertEqual(eval(80, thermal: .fair, lp: true), [.disableLowPower])
Expand Down Expand Up @@ -168,8 +195,8 @@ final class FloorRulesTests: XCTestCase {
/// The cause names the strongest reason: thermal, then battery, then lid.
func testLidIsTheWeakestCause() {
XCTAssertEqual(eval(30, lid: true), [.enableLowPower(.battery)])
XCTAssertEqual(eval(30, thermal: .serious, lid: true), [.enableLowPower(.thermal)])
XCTAssertEqual(eval(80, thermal: .serious, lid: true), [.enableLowPower(.thermal)])
XCTAssertEqual(eval(30, thermal: .serious, lid: true), [.endSession(.thermalSeriousClosedLid)])
XCTAssertEqual(eval(80, thermal: .serious, lid: true), [.endSession(.thermalSeriousClosedLid)])
}

func cause(_ percent: Int?, charging: Bool = false, thermal: ProcessInfo.ThermalState = .nominal, lid: Bool = false, config: Config = Config()) -> FloorRules.LowPowerCause? {
Expand Down Expand Up @@ -274,6 +301,20 @@ final class FloorRuleDriverTests: XCTestCase {
XCTAssertTrue(h.notifier.posts.last?.body.contains("critical") ?? false)
}

func testClosedLidSeriousHeatRestoresOwnedPowerAndEndsSession() async throws {
let m = h.makeManager()
await m.start(duration: 3600)
_ = await m.setLowPower(true)
let driver = FloorRuleDriver(manager: m, notifier: h.notifier)
await driver.run(battery: .percent(80), isCharging: true, thermal: .serious, lidClosed: true)
XCTAssertFalse(m.isActive)
XCTAssertFalse(h.guardFake.sleepDisabled)
XCTAssertFalse(h.guardFake.lowPowerOn)
XCTAssertEqual(try h.store.loadState(), RuntimeState.clean)
XCTAssertNil(try h.store.loadSession())
XCTAssertTrue(h.notifier.posts.last?.body.contains("lid closed") ?? false)
}

func testNoSessionDoesNothing() async throws {
let m = h.makeManager()
let driver = FloorRuleDriver(manager: m, notifier: h.notifier)
Expand Down Expand Up @@ -521,6 +562,7 @@ final class FloorRuleDriverTests: XCTestCase {
/// takes over, lid opens (thermal holds), cools down: announced.
func testThermalTakeoverThenLidOpenAnnouncesTheCooldown() async throws {
let m = h.makeManager()
m.config.endClosedLidWhenHot = false
await m.start(duration: 3600)
let driver = FloorRuleDriver(manager: m, notifier: h.notifier)
await driver.run(battery: .percent(80), isCharging: false, thermal: .nominal, lidClosed: true)
Expand Down
9 changes: 7 additions & 2 deletions docs/lid-close.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,13 @@ The defaults are worth knowing:
notification saying why: the floor cannot be applied to a level nobody can
read. One failed read is tolerated, and the level is re-read every 30 s
until it is readable again. A desktop has no battery and no floor. Serious
thermal state requests Low Power Mode; critical thermal state ends the
session. These rules require the app to be running.
thermal state requests Low Power Mode with the lid open; critical thermal
state ends the session. "End closed-lid sessions when hot" is on by default
and ends at serious thermal pressure, even if the other thermal rules are
off. Turning it off keeps the existing Low Power Mode rule. These rules
require the app to be running. Use a ventilated surface: thermal pressure
is not a temperature guarantee, and these rules do not make running in a
backpack safe.
Setting the end floor to 0 turns the battery end off. Otherwise the end
floor stays below the Low Power Mode floor. The Settings steppers move the
other floor when the two would cross, and a hand-edited `config.json` with
Expand Down
1 change: 1 addition & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ below cover what only a real Mac shows.
- App Nap opt-in: previous `NSAppSleepDisabled` put back at session end, by the backstop after a force-quit, and by uninstall
- App Nap on macOS 27, where `defaults read` of a missing key prints "Could not find key 'NSAppSleepDisabled' in domain '<id>'." or "Domain '<id>' not found.": an `appNapOverrides` entry whose key is already gone (deleted by hand after a force-quit) is cleared by the agent with "the key is already absent" and the run exits 0, and uninstall counts agent apps without the key as checked, not unreadable; with a sandboxed agent app on the list (one with `~/Library/Containers/<id>`), its container's preferences have no `NSAppSleepDisabled` left after the agent clears its entry
- Battery/thermal event behavior on supported hardware
- Closed-lid serious thermal event ends the session and restores owned sleep and Low Power Mode; exercise with injected thermal events rather than heating the Mac, and check the open-lid and opted-out behavior separately
- A `sudo pmset` that ignores SIGTERM: left running, lock held, journal intact, quit refused until it exits
- The same command with the app force-quit: the backstop does not take the lock until the command exits; the relaunch names the pid from `unfinished-command.json`, and at its next retry after the command exits (retries start 30 s after each refusal) resumes the session with its battery floors
- A left-running `disablesleep 0` that exits 0: the end finishes without running it again
Expand Down
Loading