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
12 changes: 10 additions & 2 deletions .greptile/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,16 @@ The app and the script serialize on one `flock(2)` lock,
(`RecoveryLock.swift`; `lockf` on fd 9 in the scripts). `uninstall.sh`
takes the lock, runs the backstop with `--force` under it, and refuses to
remove the recovery machinery while anything is still journaled. Battery
and thermal floors run only while the app is alive; the backstop does not
provide them.
and thermal end rules also run in the backstop for sessions carrying
`safetyCheckVersion: 1`: it requires `InsomniaSafetyCheckVersion: 1` before
calling the signed bundle's read-only `--check-safety 5` mode, which arms its
own lifetime before reading anything and inherits fd 9. Only an exact
`continue` line with exit 0 honors the future deadline. Cutoffs or unavailable
checks expire the saved deadline before restoring the journal, so failed
undo is retried after the hazard recedes. Legacy sessions without the key
keep deadline-only recovery. The app synchronizes every 30 seconds and before
Extend so it cannot recreate a session the backstop ended. Reconcile never
reapplies a monitored session's owned sleep setting after it was released.

## Deliberate designs, do not flag

Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,11 @@ 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
only work while the app runs.
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. The app reacts to events;
for new sessions, the recovery agent also checks the battery and thermal
cutoffs every minute, even if the menu app exits.

Before your first session, check Settings. Then run a short session you can
watch, and read `~/Library/Logs/Insomnia/insomnia.log` afterward.
Expand Down Expand Up @@ -167,7 +170,9 @@ session's end time has passed.
Some things need you:

- The recovery agent can't restore audio. Open Insomnia again for that.
- The recovery agent doesn't watch the battery or the temperature.
- During new sessions, the recovery agent checks battery and thermal cutoffs
once a minute. It ends the wake session if the check is unavailable.
Older sessions keep deadline-only recovery until they end.
- Insomnia only unfreezes a process it can prove it froze. A frozen process
it can't prove stays frozen until you check it.

Expand Down
2 changes: 2 additions & 0 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
<string>NSApplication</string>
<key>InsomniaResumeFrozenVersion</key>
<integer>1</integer>
<key>InsomniaSafetyCheckVersion</key>
<integer>1</integer>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2026 Krish Garg. MIT License.</string>
</dict>
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
86 changes: 86 additions & 0 deletions Sources/Insomnia/Core/SafetyCheckCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import Foundation

/// A read-only, one-shot power check for the recovery agent. It starts
/// before AppKit, reads no secrets, and performs no power changes. The
/// caller holds the recovery lock and applies any undo from the journal.
enum SafetyCheckCommand {
static let flag = "--check-safety"
static let version = 1

struct Output: Equatable, Sendable {
let line: String
let status: Int32
}

/// The lifetime is armed before any system or config read. A helper
/// whose caller dies cannot keep the inherited recovery lock forever.
static func run(
_ arguments: [String],
readBattery: () -> (battery: BatteryStatus, charging: Bool) = { PowerMonitor.readBattery() },
readLid: () -> Bool? = { LidObserver.readClamshellState() },
readThermal: () -> ProcessInfo.ThermalState = { ProcessInfo.processInfo.thermalState },
readConfig: () throws -> Config? = { try Store(paths: Paths.fromEnvironment()).loadConfig() },
endAfter: (UInt32) -> Void
) -> Output? {
guard arguments.first == flag else { return nil }
guard arguments.count == 2, let seconds = ResumeFrozenCommand.lifetime(arguments[1]) else {
return Output(line: "usage", status: ResumeFrozenCommand.usageStatus)
}
endAfter(seconds)
var config: Config
do {
config = try readConfig() ?? Config()
_ = config.normalizeFloors()
} catch {
return Output(line: "end configuration-unreadable", status: 0)
}
var power = readBattery()
// The one-shot helper has no notification history. Re-read an
// unavailable battery before declaring two consecutive misses.
if case .unreadable = power.battery, !power.charging, config.endFloor > 0 {
let again = readBattery()
if case .unreadable = again.battery {
power = (.unreadable(misses: 2), again.charging)
} else if again.battery == .none {
// The first read established a laptop. A disappearing
// battery entry is not evidence that it became a desktop.
power = (.unreadable(misses: 2), again.charging)
} else {
power = again
}
}
return Output(line: decision(
battery: power.battery, isCharging: power.charging,
thermal: readThermal(), lidClosed: readLid(), config: config
), status: 0)
}

static func decision(
battery: BatteryStatus,
isCharging: Bool,
thermal: ProcessInfo.ThermalState,
lidClosed: Bool?,
config: Config
) -> String {
// A desktop has no lid. On a laptop, guessing "open" would silently
// disable its closed-lid cutoff when the registry read fails.
if lidClosed == nil, battery != .none { return "end lid-unreadable" }
if lidClosed == true, battery == .none, !isCharging, config.endFloor > 0 {
return "end batteryUnreadable"
}
switch thermal {
case .nominal, .fair, .serious, .critical:
break
@unknown default:
if config.thermalRules || (lidClosed == true && config.endClosedLidWhenHot) {
return "end thermal-unreadable"
}
}
let actions = FloorRules.evaluate(
battery: battery, isCharging: isCharging, thermal: thermal,
lidClosed: lidClosed == true, lowPowerSetByUs: false, config: config
)
for case let .endSession(reason) in actions { return "end \(reason.rawValue)" }
return "continue"
}
}
71 changes: 71 additions & 0 deletions Sources/Insomnia/Core/SessionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ enum EndReason: String, Sendable {
/// while on battery: the end floor could not be applied.
case batteryUnreadable
case thermalCritical
case thermalSeriousClosedLid
case safetyBackstop
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 @@ -439,6 +441,7 @@ final class SessionManager {
@ObservationIgnored var services: AppServices?

@ObservationIgnored private var deadlineTimer: Timer?
@ObservationIgnored private var safetySyncTimer: Timer?
@ObservationIgnored private var countdownTimer: Timer?
@ObservationIgnored private var retryTimer: Timer?
/// Whether the 1 Hz redraw is currently on the run loop. Tests assert on
Expand Down Expand Up @@ -1276,6 +1279,17 @@ final class SessionManager {

private func performExtend(by extra: TimeInterval, ticket: Int) async {
guard let current = session, endTicket == ticket else { return }
if current.safetyCheckVersion != nil {
do {
if try monitoredSessionEndedOnDisk() {
_ = await performEnd(reason: .safetyBackstop)
return
}
} catch {
fail("extend refused: could not read the session the recovery agent monitors: \(error.localizedDescription)")
return
}
}
let updated = SessionMath.extended(current, by: extra, now: clock(), maxDuration: config.maxDuration)
// The agent enforces whatever deadline is on disk; confirm it is
// still loaded before moving that deadline out.
Expand Down Expand Up @@ -2480,6 +2494,28 @@ final class SessionManager {

// MARK: Reconcile (spec section 8)

/// The recovery agent can end a future session on a battery/thermal
/// cutoff. Drop the cached session and finish app-only restoration,
/// rather than keeping a live countdown or resurrecting it on Extend.
func synchronizeWithBackstop() async {
guard session?.safetyCheckVersion != nil else { return }
_ = await exclusive("safety sync") {
guard self.session != nil else { return }
do {
if try self.monitoredSessionEndedOnDisk() {
_ = await self.performEnd(reason: .safetyBackstop)
}
} catch {
self.fail("could not check the recovery agent's session: \(error.localizedDescription)")
}
}
}

private func monitoredSessionEndedOnDisk() throws -> Bool {
let saved = try store.loadSession()
return saved == nil || saved?.isExpired(at: clock()) == true || !state.sleepDisabledByUs
}

/// Run at launch. A refused reconcile changed nothing and runs again
/// after `recoveryRetryDelay` until it goes through. Nothing else would
/// resume a session still live on disk: its sleep stays disabled until
Expand Down Expand Up @@ -2615,6 +2651,26 @@ final class SessionManager {
// sleep. Any failure ends the session rather than holding sleep
// with nothing guaranteed to release it.
session = s
if s.safetyCheckVersion != nil {
do {
// A failed on-disk cutoff may leave a future deadline.
// Once recovery (or another tool) has released sleep,
// a relaunch must not turn it off again for that session.
guard state.sleepDisabledByUs else {
_ = await performEnd(reason: .safetyBackstop)
return
}
let sleepStillDisabled = try await sleepGuard.isSleepDisabled()
if !sleepStillDisabled {
_ = await performEnd(reason: .safetyBackstop)
return
}
} catch {
fail("could not verify the monitored session still holds sleep; ending it: \(error.localizedDescription)")
_ = await performEnd(reason: .recoveryUnavailable)
return
}
}
do {
try await backstop.arm()
} catch {
Expand Down Expand Up @@ -2930,6 +2986,17 @@ final class SessionManager {
RunLoop.main.add(timer, forMode: .common)
deadlineTimer = timer

safetySyncTimer?.invalidate()
safetySyncTimer = nil
if session?.safetyCheckVersion != nil {
let sync = Timer(fire: clock().addingTimeInterval(30), interval: 30, repeats: true) { [weak self] _ in
Task { @MainActor in await self?.synchronizeWithBackstop() }
}
sync.tolerance = 2
RunLoop.main.add(sync, forMode: .common)
safetySyncTimer = sync
}

refreshCountdown()
if !countdownPaused { armCountdownTimer() }
}
Expand Down Expand Up @@ -2957,6 +3024,8 @@ final class SessionManager {
private func stopTimers() {
deadlineTimer?.invalidate()
deadlineTimer = nil
safetySyncTimer?.invalidate()
safetySyncTimer = nil
countdownTimer?.invalidate()
countdownTimer = nil
}
Expand Down Expand Up @@ -3180,6 +3249,8 @@ 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 .safetyBackstop: "The recovery agent ended this session. Sleep is back to normal. Check the log for the reason."
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
5 changes: 4 additions & 1 deletion Sources/Insomnia/Core/SessionMath.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ enum SessionMath {

/// Fresh session starting now.
static func newSession(now: Date, duration: TimeInterval, maxDuration: TimeInterval) -> Session {
Session(startedAt: now, endsAt: endsAt(start: now, duration: duration, maxDuration: maxDuration))
Session(
startedAt: now, endsAt: endsAt(start: now, duration: duration, maxDuration: maxDuration),
safetyCheckVersion: SafetyCheckCommand.version
)
}

/// Push `endsAt` out by `extra` seconds, never past `now + maxDuration`.
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
6 changes: 5 additions & 1 deletion Sources/Insomnia/Model/Session.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@ struct Session: Codable, Equatable, Sendable {
var endsAt: Date
/// Every extension applied, in order, in seconds.
var extensions: [TimeInterval]
/// New sessions require the backstop's independent power check before
/// it honors a future deadline. Missing in sessions from older builds.
var safetyCheckVersion: Int?

init(startedAt: Date, endsAt: Date, extensions: [TimeInterval] = []) {
init(startedAt: Date, endsAt: Date, extensions: [TimeInterval] = [], safetyCheckVersion: Int? = nil) {
self.startedAt = startedAt
self.endsAt = endsAt
self.extensions = extensions
self.safetyCheckVersion = safetyCheckVersion
}

/// Seconds left until `endsAt`, never negative.
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
7 changes: 7 additions & 0 deletions Sources/Insomnia/main.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import Foundation

if let output = SafetyCheckCommand.run(
Array(CommandLine.arguments.dropFirst()), endAfter: ResumeFrozenCommand.endProcess
) {
print(output.line)
exit(output.status)
}

// Entry point. A one-shot command-line mode is answered here, before AppKit
// or SwiftUI start, so backstop.sh can borrow the app's kernel-level
// identity check (see ResumeFrozenCommand), which ends itself when its
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
Loading
Loading