Skip to content
Open
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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,22 @@ file and explains how upgrades replace them.
undone. Saved audio for an output that isn't connected doesn't hold it up.
Insomnia restores that output if it reconnects while the app runs, or at
the next launch.
5. **From a script or an agent.** The app's binary drives the running app:

```bash
INSOMNIA=~/Applications/Insomnia.app/Contents/MacOS/Insomnia
"$INSOMNIA" --session start 7200 # two hours
"$INSOMNIA" --session extend 1800
"$INSOMNIA" --session status
"$INSOMNIA" --session end
```

It starts, extends and ends the same session the menu bar counts down,
with the same battery and heat rules, and prints one line. It changes
nothing itself, so when the app isn't running it says so and exits 1.
A build without this mode opens the menu bar app instead, so a script
that may meet one checks `InsomniaSessionCommandVersion` in the bundle's
`Info.plist` first.

You don't have to close the lid, and opening it doesn't end the session.

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>InsomniaSessionCommandVersion</key>
<integer>1</integer>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2026 Krish Garg. MIT License.</string>
</dict>
Expand Down
408 changes: 408 additions & 0 deletions Sources/Insomnia/Core/SessionCommand.swift

Large diffs are not rendered by default.

76 changes: 54 additions & 22 deletions Sources/Insomnia/Core/SessionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ final class SessionManager {
private(set) var lastError: String?
/// How many times `fail` has run, so an undo can tell a line it put up
/// from one left by an earlier run.
@ObservationIgnored private var failCount = 0
@ObservationIgnored private(set) var failCount = 0
/// Reconcile found SleepDisabled set with no session and no journal
/// claim: something other than Insomnia disabled sleep. Kept apart from
/// `lastError` so a restore failure from the same reconcile stays
Expand Down Expand Up @@ -696,6 +696,11 @@ final class SessionManager {
/// Refused while an unfinished command runs, `deferred` is recorded in
/// the refusal itself, before the caller resumes: the command can exit
/// and its holder settle in between, and must find the work then.
/// Waits for every start, extend and end queued before this call.
func waitForQueuedWork() async {
await lifecycleTail?.value
}

private func exclusive<T: Sendable>(_ what: String, owes deferred: Deferred? = nil, _ op: @escaping @MainActor @Sendable () async -> T) async -> Result<T, TransactionRefusal> {
let previous = lifecycleTail
let task = Task<Result<T, TransactionRefusal>, Never> { @MainActor in
Expand Down Expand Up @@ -1172,29 +1177,44 @@ final class SessionManager {
/// undone from the journal like an end, and the journal keeps the entry
/// until that undo is confirmed.
func start(duration: TimeInterval) async {
_ = await tryStart(duration: duration)
}

/// `start`, returning whether this call started the session: false when
/// it was ignored, refused, abandoned or failed (`SessionCommand`
/// reports it).
func tryStart(duration: TimeInterval) async -> Bool {
guard !quitRequested else {
Log.info("start ignored: quit requested")
return
return false
}
guard pendingEnd == nil else {
fail("start refused: the previous session is still being ended; retrying shortly")
return
fail(Self.endPendingRefusal)
return false
}
let ticket = endTicket
_ = await exclusive("start") { await self.performStart(duration: duration, ticket: ticket) }
let started = await exclusive("start") { await self.performStart(duration: duration, ticket: ticket) }
// A start that goes through before a refused launch reconcile runs
// again drops that reconcile, and takes over its check.
await settleEarlierCommand()
if case .success(true) = started { return true }
return false
}

private func performStart(duration: TimeInterval, ticket: Int) async {
private func performStart(duration: TimeInterval, ticket: Int) async -> Bool {
guard session == nil else {
Log.info("start ignored: session already active")
return
return false
}
guard endTicket == ticket, !quitRequested else {
Log.info("start abandoned: an end was requested first")
return
return false
}
// An end queued ahead of this start may have left itself to its
// retry, which would stop a session started now.
guard pendingEnd == nil else {
fail(Self.endPendingRefusal)
return false
}
let now = clock()
let new = SessionMath.newSession(now: now, duration: duration, maxDuration: config.maxDuration)
Expand All @@ -1211,7 +1231,7 @@ final class SessionManager {
// could not move it aside. It may be a valid session, and a
// rollback could not put it back.
fail("start refused, nothing changed: session.json could not be read (\(error.localizedDescription)). Remove it or move it out of \(paths.appSupport.path), then start again")
return
return false
}

do {
Expand All @@ -1221,7 +1241,7 @@ final class SessionManager {
} catch {
fail("could not write session: \(error.localizedDescription)")
rollBackStart(journal: journalBefore, session: sessionBefore)
return
return false
}

// The agent is confirmed loaded before sleep is disabled, so a crash
Expand All @@ -1231,12 +1251,12 @@ final class SessionManager {
} catch {
rollBackStart(journal: journalBefore, session: sessionBefore)
fail("could not arm backstop: \(error.localizedDescription)")
return
return false
}
guard endTicket == ticket else {
rollBackStart(journal: journalBefore, session: sessionBefore)
Log.info("start abandoned before disabling sleep: end requested meanwhile")
return
return false
}

do {
Expand All @@ -1247,18 +1267,18 @@ final class SessionManager {
// written (the backstop honours that deadline if Insomnia dies);
// the start is undone like an end once the command has exited.
stopTransaction(for: still, thenEnd: .startFailed)
return
return false
} catch {
fail("could not disable sleep: \(error.localizedDescription)")
_ = await performEnd(reason: .startFailed)
return
return false
}
guard endTicket == ticket else {
// Sleep is disabled and journaled as ours. The end that was
// requested runs next and restores from that journal; the session
// is never surfaced.
Log.info("start abandoned after disabling sleep: end requested meanwhile; journal left for it")
return
return false
}

session = new
Expand All @@ -1271,39 +1291,50 @@ final class SessionManager {
// Every observer lives in AppServices.
services?.start(for: self)
// PR3: schedule "5 minutes left" notification.
return true
}

func extend(by extra: TimeInterval) async {
guard session != nil else { return }
_ = await tryExtend(by: extra)
}

/// `extend`, returning whether this call moved the deadline out.
func tryExtend(by extra: TimeInterval) async -> Bool {
guard session != nil else { return false }
let ticket = endTicket
_ = await exclusive("extend") { await self.performExtend(by: extra, ticket: ticket) }
let extended = await exclusive("extend") { await self.performExtend(by: extra, ticket: ticket) }
if case .success(true) = extended { return true }
return false
}

private func performExtend(by extra: TimeInterval, ticket: Int) async {
guard let current = session, endTicket == ticket else { return }
private func performExtend(by extra: TimeInterval, ticket: Int) async -> Bool {
guard let current = session, endTicket == ticket else { return false }
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.
do {
try await backstop.arm()
} catch {
fail("could not confirm backstop: \(error.localizedDescription)")
return
return false
}
guard endTicket == ticket, session == current else {
Log.info("extend abandoned: end requested meanwhile")
return
return false
}
do {
try store.saveSession(updated)
} catch {
fail("could not write session: \(error.localizedDescription)")
return
return false
}
session = updated
lastError = nil
Log.info("session extended by \(Int(extra))s until \(iso(updated.endsAt))")
await armDeadline(updated.endsAt)
// The cap can leave the end where it was, or move it earlier after
// a lower maxDuration; neither is an extension to report.
return updated.endsAt > current.endsAt
}

/// Full session end: delete the session file first (so a crash here leaves
Expand Down Expand Up @@ -3166,6 +3197,7 @@ final class SessionManager {
static let journalTitle = "Recovery journal unreadable"
static let commandRunningTitle = "Power command still running"
static let sessionFileTitle = "Session file unreadable"
static let endPendingRefusal = "start refused: the previous session is still being ended; retrying shortly"
static let foreignSleepTitle = "Sleep is disabled by something else"
static let foreignSleepCommand = "sudo pmset -a disablesleep 0"
static let foreignSleepLine = "Sleep is disabled by something other than Insomnia; to re-enable it: \(foreignSleepCommand)"
Expand Down
12 changes: 11 additions & 1 deletion Sources/Insomnia/InsomniaApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var settingsWindow: SettingsWindow?
private var displaySleep: DisplaySleepObserver?
private var timeChange: TimeChangeObserver?
private let sessionCommands = SessionCommandWatcher()
private var terminating = false

override init() {
Expand Down Expand Up @@ -90,7 +91,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
displaySleep = DisplaySleepObserver { [manager] asleep in manager.displaysAsleepChanged(asleep) }
timeChange = TimeChangeObserver { [manager] in manager.refreshCountdown() }
Task { await manager.reconcile() }
sessionCommands.onRequest = { [manager] request in
Task { await manager.serve(request) }
}
Task {
await manager.reconcile()
// `Insomnia --session` from a script or an agent, once reconcile
// has adopted or cleared what it found, so no request is
// answered against a session that is about to resume.
sessionCommands.start(paths: manager.paths)
}
}

/// Quitting always ends the session (spec 1). Terminate is deferred until
Expand Down
18 changes: 18 additions & 0 deletions Sources/Insomnia/Store/Paths.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,24 @@ struct Paths: Sendable, Equatable {
/// Written by scripts/simulate-lid.sh ("closed" or "open") to drive the
/// lid-close action path without touching the hinge. See LidSimulation.
var simulateLidFile: URL { appSupport.appendingPathComponent("simulate-lid") }
/// `Insomnia --session` writes each request to its own file, named by
/// the command line's id, for the running app (`SessionCommandWatcher`),
/// and the app answers next to it.
func sessionCommandRequest(_ id: String) -> URL {
appSupport.appendingPathComponent("session-command.\(id).request")
}
func sessionCommandReply(_ id: String) -> URL {
appSupport.appendingPathComponent("session-command.\(id).reply")
}
/// The id in a request or answer file name; nil for any other name.
static func sessionCommandRequestID(_ name: String) -> String? { sessionCommandID(name, suffix: ".request") }
static func sessionCommandReplyID(_ name: String) -> String? { sessionCommandID(name, suffix: ".reply") }
private static func sessionCommandID(_ name: String, suffix: String) -> String? {
let prefix = "session-command."
guard name.hasPrefix(prefix), name.hasSuffix(suffix), name.count > prefix.count + suffix.count else { return nil }
let id = String(name.dropFirst(prefix.count).dropLast(suffix.count))
return SessionCommand.isRequestID(id) ? id : nil
}

/// Both logs are owner-only and rotate to `<name>.1` past
/// `OwnerOnly.maxLogBytes`; uninstall.sh --purge removes the `.1` too.
Expand Down
11 changes: 8 additions & 3 deletions Sources/Insomnia/main.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import Foundation

// 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
// lifetime is up. Anything else starts the menu bar app.
// or SwiftUI start: backstop.sh borrows the app's kernel-level identity
// check (see ResumeFrozenCommand), which ends itself when its lifetime is
// up, and scripts drive the running app's session (see SessionCommandLine).
// Anything else starts the menu bar app.
if let output = ResumeFrozenCommand.run(Array(CommandLine.arguments.dropFirst()), endAfter: ResumeFrozenCommand.endProcess) {
for line in output.lines { print(line) }
exit(output.status)
}
if let output = SessionCommandLine.run(Array(CommandLine.arguments.dropFirst())) {
for line in output.lines { print(line) }
exit(output.status)
}
InsomniaApp.main()
Loading
Loading