"Keep computer awake" was a boolean: off, or a machine-global power-save blocker held for as long as Hermes Desktop is open. That is the wrong shape for its own stated purpose. An unattended overnight run dies when the machine sleeps unless the user predicted, before starting, that they would need the toggle — and a user who did flip it leaves a laptop pinned awake all week, including the idle hours after the run finished (#101991). Main already knows, live, whether any turn is in flight: `mergeActiveWork(activeWorkByWebContents.values()).count > 0` is the exact expression that drives stream throttling. This change wires the blocker to that same signal behind a three-way mode: off never hold the blocker (unchanged default) while-working hold it only while a turn is in flight; release at idle always hold it whenever the app is open (what `on` used to mean) Both persisted copies migrate in place. The renderer's localStorage boolean (`hermes.desktop.keepAwake.v1`) and main's `keep-awake.json` `{ on }` are read once and mapped `true -> always`, `false -> off`, so an existing user sees exactly the behaviour they had. The IPC channel keeps its name and accepts either the mode or the old boolean. An unrecognised saved value is refused rather than trusted and falls back to `off`. No new listener, timer, or IPC channel: `updateStreamThrottleFromActiveWork` computes the merged picture once and hands it to both consumers, and the cold-launch apply reads the mode instead of the flag. No trailing delay between back-to-back turns — a sub-second release cannot let a machine sleep, and `powerSaveBlocker` start/stop are cheap. The policy is three pure functions in power-save.ts (parse, read, wanted) so it is testable without the main process; main only wires them. (cherry picked from commit c6dab931a2891ea7026eea2c07c271bcf48446b9)
95 lines
2.9 KiB
TypeScript
95 lines
2.9 KiB
TypeScript
/**
|
|
* Keep-awake — hold a single machine-global power-save blocker.
|
|
*
|
|
* `prevent-app-suspension` stops the system from sleeping (long overnight
|
|
* agent runs keep going) while still letting the display dim. The renderer
|
|
* owns the preference (persisted in localStorage) and mirrors it here over
|
|
* IPC; the main process owns the one native blocker, same authority split as
|
|
* translucency/zoom. Electron auto-releases the blocker on quit.
|
|
*/
|
|
|
|
export type KeepAwakeType = 'prevent-app-suspension' | 'prevent-display-sleep'
|
|
|
|
/** The slice of Electron's `powerSaveBlocker` we use (injected for testing). */
|
|
export interface PowerSaveBlockerLike {
|
|
start(type: KeepAwakeType): number
|
|
stop(id: number): void
|
|
isStarted(id: number): boolean
|
|
}
|
|
|
|
export interface KeepAwake {
|
|
/** Turn the blocker on/off (idempotent). Returns the resulting state. */
|
|
set(on: boolean): boolean
|
|
isActive(): boolean
|
|
}
|
|
|
|
export function createKeepAwake(
|
|
blocker: PowerSaveBlockerLike,
|
|
type: KeepAwakeType = 'prevent-app-suspension'
|
|
): KeepAwake {
|
|
let id: null | number = null
|
|
|
|
const isActive = () => id !== null && blocker.isStarted(id)
|
|
|
|
return {
|
|
isActive,
|
|
set(on) {
|
|
if (on && !isActive()) {
|
|
id = blocker.start(type)
|
|
} else if (!on && id !== null) {
|
|
if (blocker.isStarted(id)) {
|
|
blocker.stop(id)
|
|
}
|
|
|
|
id = null
|
|
}
|
|
|
|
return isActive()
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* How the blocker follows the user: never, only while a turn is in flight, or
|
|
* around the clock. 'while-working' is the mode the module doc-comment always
|
|
* described — overnight runs survive without the user predicting, before they
|
|
* start, that they will need it, and without a laptop pinned awake all week.
|
|
*/
|
|
export type KeepAwakeMode = 'always' | 'off' | 'while-working'
|
|
|
|
const KEEP_AWAKE_MODES: readonly KeepAwakeMode[] = ['off', 'while-working', 'always']
|
|
|
|
/**
|
|
* Coerce a wire or persisted value to a mode. The pre-mode toggle sent and
|
|
* stored a boolean; `true` meant what 'always' means now, `false` meant 'off'.
|
|
*/
|
|
export function parseKeepAwakeMode(value: unknown): KeepAwakeMode | null {
|
|
if (value === true) {
|
|
return 'always'
|
|
}
|
|
|
|
if (value === false) {
|
|
return 'off'
|
|
}
|
|
|
|
return typeof value === 'string' && (KEEP_AWAKE_MODES as readonly string[]).includes(value)
|
|
? (value as KeepAwakeMode)
|
|
: null
|
|
}
|
|
|
|
/** The main process's persisted copy: the `{ mode }` shape, or the legacy `{ on }`. */
|
|
export function readKeepAwakeMode(persisted: unknown): KeepAwakeMode {
|
|
if (typeof persisted !== 'object' || persisted === null) {
|
|
return 'off'
|
|
}
|
|
|
|
const record = persisted as Record<string, unknown>
|
|
|
|
return parseKeepAwakeMode(record.mode) ?? parseKeepAwakeMode(record.on) ?? 'off'
|
|
}
|
|
|
|
/** Whether the blocker should be held right now, given the mode and the live turn picture. */
|
|
export function keepAwakeWanted(mode: KeepAwakeMode, working: boolean): boolean {
|
|
return mode === 'always' || (mode === 'while-working' && working)
|
|
}
|