From 6ff0d60c2846f31c7a070849d5d15093924dd608 Mon Sep 17 00:00:00 2001 From: vishal Date: Sun, 20 Sep 2026 09:23:37 +0530 Subject: [PATCH 1/4] feat(desktop): brutal Keep Awake toggle with save/restore and crash recovery --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 4 + apps/desktop/src/ipc/channels.ts | 2 + apps/desktop/src/ipc/methods/keepAwake.ts | 30 + apps/desktop/src/main.ts | 10 + apps/desktop/src/power/DesktopKeepAwake.ts | 587 ++++++++++++++++++ apps/desktop/src/preload.ts | 3 + .../settings/ConnectionsSettings.tsx | 2 + .../src/components/settings/KeepAwakeRow.tsx | 134 ++++ .../src/components/settings/settingsSearch.ts | 12 + apps/web/src/state/keepAwakeState.ts | 111 ++++ packages/contracts/src/ipc.ts | 14 + 11 files changed, 909 insertions(+) create mode 100644 apps/desktop/src/ipc/methods/keepAwake.ts create mode 100644 apps/desktop/src/power/DesktopKeepAwake.ts create mode 100644 apps/web/src/components/settings/KeepAwakeRow.tsx create mode 100644 apps/web/src/state/keepAwakeState.ts diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index c97c602552f4..b556c0224c06 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -70,6 +70,7 @@ import { import * as PreviewIpc from "./methods/preview.ts"; import * as AppActivationIpc from "./methods/appActivation.ts"; import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; +import { getKeepAwakeState, setKeepAwakeEnabled } from "./methods/keepAwake.ts"; export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { const ipc = yield* DesktopIpc.DesktopIpc; @@ -125,6 +126,9 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setWslDistro); yield* ipc.handle(setWslOnly); + yield* ipc.handle(getKeepAwakeState); + yield* ipc.handle(setKeepAwakeEnabled); + yield* ipc.handle(pickFolder); yield* ipc.handle(pickProjectFavicon); yield* ipc.handle(pickThemeFiles); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 226793657848..b997aa1ee1a9 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -66,6 +66,8 @@ export const GET_WSL_STATE_CHANNEL = "desktop:get-wsl-state"; export const SET_WSL_BACKEND_ENABLED_CHANNEL = "desktop:set-wsl-backend-enabled"; export const SET_WSL_DISTRO_CHANNEL = "desktop:set-wsl-distro"; export const SET_WSL_ONLY_CHANNEL = "desktop:set-wsl-only"; +export const GET_KEEP_AWAKE_STATE_CHANNEL = "desktop:get-keep-awake-state"; +export const SET_KEEP_AWAKE_ENABLED_CHANNEL = "desktop:set-keep-awake-enabled"; export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab"; export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab"; diff --git a/apps/desktop/src/ipc/methods/keepAwake.ts b/apps/desktop/src/ipc/methods/keepAwake.ts new file mode 100644 index 000000000000..56a08866e618 --- /dev/null +++ b/apps/desktop/src/ipc/methods/keepAwake.ts @@ -0,0 +1,30 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopKeepAwake from "../../power/DesktopKeepAwake.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +const SetKeepAwakeEnabledInput = Schema.Struct({ + enabled: Schema.Boolean, +}); + +export const getKeepAwakeState = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.GET_KEEP_AWAKE_STATE_CHANNEL, + payload: Schema.Void, + result: DesktopKeepAwake.DesktopKeepAwakeStateSchema, + handler: Effect.fn("desktop.ipc.keepAwake.getState")(function* () { + const keepAwake = yield* DesktopKeepAwake.DesktopKeepAwake; + return yield* keepAwake.getState; + }), +}); + +export const setKeepAwakeEnabled = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SET_KEEP_AWAKE_ENABLED_CHANNEL, + payload: SetKeepAwakeEnabledInput, + result: DesktopKeepAwake.DesktopKeepAwakeStateSchema, + handler: Effect.fn("desktop.ipc.keepAwake.setEnabled")(function* (input) { + const keepAwake = yield* DesktopKeepAwake.DesktopKeepAwake; + return yield* keepAwake.setEnabled(input.enabled); + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0d626a51955d..a4f94a802814 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -67,6 +67,7 @@ import * as DesktopWindow from "./window/DesktopWindow.ts"; import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts"; import * as DesktopWslServerTree from "./wsl/DesktopWslServerTree.ts"; +import * as DesktopKeepAwake from "./power/DesktopKeepAwake.ts"; const desktopEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { @@ -186,6 +187,14 @@ const desktopWslBackendLayer = DesktopWslBackend.layer.pipe( Layer.provideMerge(desktopBackendLayer), ); +// KeepAwake implements Brutal Awake behavior on Windows: a +// powerSaveBlocker ("prevent-display-sleep") plus SetThreadExecutionState +// with display/away-mode flags plus powercfg lid=Do Nothing and +// sleep/hibernate=never with save/restore, a userData crash-recovery file, +// and a scoped 25 s re-assert loop. Other platforms hold the display-sleep +// blocker only. The Layer.scoped release restores power settings on app quit. +const desktopKeepAwakeLayer = DesktopKeepAwake.layer; + const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe( Layer.provideMerge(desktopBackendLayer), ); @@ -201,6 +210,7 @@ const desktopApplicationLayer = Layer.mergeAll( Layer.provideMerge(desktopSnapShotLayer), Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopWslBackendLayer), + Layer.provideMerge(desktopKeepAwakeLayer), Layer.provideMerge(desktopLocalEnvironmentAuthLayer), ); diff --git a/apps/desktop/src/power/DesktopKeepAwake.ts b/apps/desktop/src/power/DesktopKeepAwake.ts new file mode 100644 index 000000000000..fa0de5bf8d91 --- /dev/null +++ b/apps/desktop/src/power/DesktopKeepAwake.ts @@ -0,0 +1,587 @@ +// @effect-diagnostics nodeBuiltinImport:off -- This Windows platform boundary drives powercfg via spawnSync (windowsHide) and reads/writes the userData crash-recovery file with Node. +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; + +import * as NodeChildProcess from "node:child_process"; +import * as NodeFs from "node:fs"; +import * as NodePath from "node:path"; + +import * as Electron from "electron"; + +/** + * DesktopKeepAwake — Brutal Awake behavior for the Electron main process. + * + * While enabled this keeps the computer awake with the display on, even with + * the lid closed, via three overlapping mechanisms on Windows (mirroring + * `brutal awake/src-tauri/src/main.rs`): + * + * 1. `powerSaveBlocker.start("prevent-display-sleep")` — display + system + * inhibition for our process. + * 2. `SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED | + * ES_DISPLAY_REQUIRED | ES_AWAYMODE_REQUIRED)` called via `ffi-rs` from + * our own process (execution-state requests are process-scoped, so they + * MUST be made from the Electron main process, never a short-lived + * child). Follows the exact `ffi-rs` convention of + * `electron/WindowsForeground.ts` (`open` + `load` on `kernel32.dll`). + * 3. `powercfg` forces lid-close action to `0` (Do Nothing) and Sleep-after / + * Hibernate-after to `0` (never) on `SCHEME_CURRENT`, with a single + * batched `/setactive` — this is what actually keeps Modern Standby (S0) + * laptops awake, where the system-required flag alone is ignored. + * + * macOS/Linux hold `powerSaveBlocker("prevent-display-sleep")` only; there + * is no `powercfg` equivalent there. + * + * Safety net around the global side effects: + * + * - The six overridden values (lid AC/DC, sleep-after AC/DC, + * hibernate-after AC/DC) are queried and saved ONLY on the off→on + * transition (guarded by a `Ref`, so re-enable never clobbers the + * originals with our own forced values), then restored on disable. + * - The saved values are also persisted to a JSON recovery file under + * Electron `userData` (`app.getPath("userData")`, best-effort — skipped + * when unavailable), deleted on clean disable/restore. If a previous + * session crashed or was killed while enabled, service init finds the file, + * best-effort restores those values + `/setactive`, deletes it, and logs. + * - A `forkScoped` 25 s re-assert loop re-applies the forced values and the + * execution state while still enabled (checks the `Ref`; dies with the + * layer scope). + * - The layer-scope-close finalizer runs the same restore path when still + * enabled and can never throw. + */ +export const DesktopKeepAwakeBlockerTypeSchema = Schema.Literal("prevent-display-sleep"); +export type DesktopKeepAwakeBlockerType = typeof DesktopKeepAwakeBlockerTypeSchema.Type; + +/** The only blocker type this service may ever use. */ +export const KEEP_AWAKE_BLOCKER_TYPE: DesktopKeepAwakeBlockerType = "prevent-display-sleep"; + +export const DesktopKeepAwakeStateSchema = Schema.Struct({ + enabled: Schema.Boolean, + supported: Schema.Boolean, + blockerType: DesktopKeepAwakeBlockerTypeSchema, +}); +export type DesktopKeepAwakeState = typeof DesktopKeepAwakeStateSchema.Type; + +export class DesktopKeepAwake extends Context.Service< + DesktopKeepAwake, + { + readonly getState: Effect.Effect; + readonly setEnabled: (enabled: boolean) => Effect.Effect; + } +>()("@t3tools/desktop/power/DesktopKeepAwake") {} + +// --- SetThreadExecutionState flags (kernel32) ------------------------------- + +const ES_CONTINUOUS = 0x8000_0000; +const ES_SYSTEM_REQUIRED = 0x0000_0001; +const ES_DISPLAY_REQUIRED = 0x0000_0002; +const ES_AWAYMODE_REQUIRED = 0x0000_0040; +const ES_AWAKE_FLAGS = + ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED | ES_AWAYMODE_REQUIRED; + +// --- powercfg setting GUIDs (stable across Windows installs) ---------------- + +const SUB_BUTTONS = "4f971e89-eebd-4455-a8de-9e59040e7347"; +const LIDACTION = "5ca83367-6e45-459f-a27b-476b1d01c936"; +const SUB_SLEEP = "238c9fa8-0aad-41ed-83f4-97be242c8f20"; +const STANDBYIDLE = "29f6c1db-86da-48c5-9fdb-f2b67b1f44da"; // "Sleep after" +const HIBERNATEIDLE = "9d7815a6-7ee4-497e-8888-515a05f02364"; // "Hibernate after" + +/** Original values of every power setting we override, so we can put them back. */ +export interface KeepAwakeSavedSettings { + readonly lidAc?: number | undefined; + readonly lidDc?: number | undefined; + readonly sleepAc?: number | undefined; + readonly sleepDc?: number | undefined; + readonly hibAc?: number | undefined; + readonly hibDc?: number | undefined; +} + +/** Name of the JSON crash-recovery file inside Electron `userData`. */ +export const KEEP_AWAKE_RECOVERY_FILE_NAME = "keep-awake-recovery.json"; + +const KEEP_AWAKE_REASSERT_INTERVAL = Duration.seconds(25); + +// --- Execution state via ffi-rs (own process only) -------------------------- + +interface ExecutionStateApi { + readonly setThreadExecutionState: (flags: number) => number; +} + +let executionStateApiPromise: Promise | undefined; + +const loadExecutionStateApi = (): Promise => { + executionStateApiPromise ??= import("ffi-rs").then(({ DataType, load, open }) => { + const library = "t3-keep-awake-kernel32"; + open({ library, path: "kernel32.dll" }); + return { + setThreadExecutionState: (flags: number) => + load({ + library, + funcName: "SetThreadExecutionState", + retType: DataType.U32, + paramsType: [DataType.U32], + paramsValue: [flags], + }) as number, + } satisfies ExecutionStateApi; + }); + return executionStateApiPromise; +}; + +const defaultApplyExecutionState = (on: boolean): Effect.Effect => { + if (process.platform !== "win32") { + return Effect.void; + } + return Effect.ignore( + Effect.promise(() => + loadExecutionStateApi().then((api) => { + api.setThreadExecutionState(on ? ES_AWAKE_FLAGS : ES_CONTINUOUS); + }), + ), + ); +}; + +// --- powercfg helpers (mirroring Brutal Awake's force_awake/restore_all) ---- + +const defaultRunPowercfg = (args: ReadonlyArray): string => { + try { + const result = NodeChildProcess.spawnSync("powercfg", [...args], { + encoding: "utf8", + windowsHide: true, + }); + if (result.status !== 0) { + return ""; + } + return typeof result.stdout === "string" ? result.stdout : ""; + } catch { + return ""; + } +}; + +const parseFirstHex = (out: string): number | undefined => { + for (const token of out.split(/\s+/)) { + if (token.startsWith("0x")) { + const hex = token.slice(2).trim(); + if (hex.length > 0 && /^[0-9a-fA-F]+$/.test(hex)) { + return Number.parseInt(hex, 16); + } + } + } + return undefined; +}; + +// Read the current AC and DC index for one power setting. +const querySetting = ( + runPowercfg: (args: ReadonlyArray) => string, + sub: string, + setting: string, +): readonly [number | undefined, number | undefined] => { + const acOut = runPowercfg(["/getacvalueindex", "SCHEME_CURRENT", sub, setting]); + const dcOut = runPowercfg(["/getdcvalueindex", "SCHEME_CURRENT", sub, setting]); + return [parseFirstHex(acOut), parseFirstHex(dcOut)]; +}; + +// Set one power setting's AC and DC index (no /setactive — caller batches it). +const setSetting = ( + runPowercfg: (args: ReadonlyArray) => string, + sub: string, + setting: string, + value: number, +): void => { + const v = value.toString(); + runPowercfg(["/setacvalueindex", "SCHEME_CURRENT", sub, setting, v]); + runPowercfg(["/setdcvalueindex", "SCHEME_CURRENT", sub, setting, v]); +}; + +// Restore one setting's AC/DC index if we recorded originals for it. +const restoreSetting = ( + runPowercfg: (args: ReadonlyArray) => string, + sub: string, + setting: string, + ac: number | undefined, + dc: number | undefined, +): void => { + if (ac !== undefined) { + runPowercfg(["/setacvalueindex", "SCHEME_CURRENT", sub, setting, ac.toString()]); + } + if (dc !== undefined) { + runPowercfg(["/setdcvalueindex", "SCHEME_CURRENT", sub, setting, dc.toString()]); + } +}; + +// Force lid=Do Nothing and sleep/hibernate timeouts to 0 (never), batched +// with a single /setactive like Brutal Awake. +const forcePowerSettings = (runPowercfg: (args: ReadonlyArray) => string): void => { + setSetting(runPowercfg, SUB_BUTTONS, LIDACTION, 0); // 0 = Do nothing on lid close + setSetting(runPowercfg, SUB_SLEEP, STANDBYIDLE, 0); // 0 = never sleep + setSetting(runPowercfg, SUB_SLEEP, HIBERNATEIDLE, 0); // 0 = never hibernate + runPowercfg(["/setactive", "SCHEME_CURRENT"]); +}; + +// Put every overridden power setting back to what the user had. +const restorePowerSettings = ( + runPowercfg: (args: ReadonlyArray) => string, + saved: KeepAwakeSavedSettings, +): void => { + restoreSetting(runPowercfg, SUB_BUTTONS, LIDACTION, saved.lidAc, saved.lidDc); + restoreSetting(runPowercfg, SUB_SLEEP, STANDBYIDLE, saved.sleepAc, saved.sleepDc); + restoreSetting(runPowercfg, SUB_SLEEP, HIBERNATEIDLE, saved.hibAc, saved.hibDc); + runPowercfg(["/setactive", "SCHEME_CURRENT"]); +}; + +// --- Crash-recovery file (Electron userData) -------------------------------- + +const resolveUserDataDir = (override: string | null | undefined): string | null => { + if (typeof override === "string") { + return override; + } + if (override === null) { + return null; + } + try { + const electron = Electron as unknown as { + readonly app?: { readonly getPath?: (name: string) => string }; + }; + const dir = electron.app?.getPath?.("userData"); + return typeof dir === "string" && dir.length > 0 ? dir : null; + } catch { + // userData unavailable (e.g. app not ready): skip the recovery file. + return null; + } +}; + +const recoveryFilePath = (userDataDir: string | null): string | null => + userDataDir === null ? null : NodePath.join(userDataDir, KEEP_AWAKE_RECOVERY_FILE_NAME); + +const writeRecoveryFile = (file: string | null, saved: KeepAwakeSavedSettings): void => { + if (file === null) { + return; + } + try { + NodeFs.writeFileSync(file, JSON.stringify(saved), "utf8"); + } catch { + // Best-effort: without the file we just lose crash recovery for this session. + } +}; + +const deleteRecoveryFile = (file: string | null): void => { + if (file === null) { + return; + } + try { + NodeFs.rmSync(file, { force: true }); + } catch { + // Best-effort cleanup; a stale file is picked up by init recovery next launch. + } +}; + +const readNumberField = (record: Record, key: string): number | undefined => { + const value = record[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +}; + +const sanitizeSavedSettings = (input: unknown): KeepAwakeSavedSettings | null => { + if (typeof input !== "object" || input === null) { + return null; + } + const record = input as Record; + return { + lidAc: readNumberField(record, "lidAc"), + lidDc: readNumberField(record, "lidDc"), + sleepAc: readNumberField(record, "sleepAc"), + sleepDc: readNumberField(record, "sleepDc"), + hibAc: readNumberField(record, "hibAc"), + hibDc: readNumberField(record, "hibDc"), + }; +}; + +/** + * Best-effort restore for a previous session that died while enabled. + * Returns true when a recovery file existed (so the caller logs it). + */ +const recoverPreviousSession = ( + runPowercfg: (args: ReadonlyArray) => string, + recoveryFile: string | null, +): boolean => { + if (recoveryFile === null) { + return false; + } + let found = false; + try { + found = NodeFs.existsSync(recoveryFile); + } catch { + return false; + } + if (!found) { + return false; + } + try { + const saved = sanitizeSavedSettings( + JSON.parse(NodeFs.readFileSync(recoveryFile, "utf8")) as unknown, + ); + if (saved !== null) { + restorePowerSettings(runPowercfg, saved); + } + } catch { + // Corrupt file: fall through to delete + log below. + } + deleteRecoveryFile(recoveryFile); + return true; +}; + +/** + * Minimal structural view of `Electron.powerSaveBlocker`, kept as an + * interface (instead of using the Electron type directly) so the service can + * be built with a fake in tests and degrade to `supported: false` where the + * real API does not exist (web / unsupported platforms). + */ +export interface DesktopKeepAwakePowerSaveBlocker { + readonly start: (type: DesktopKeepAwakeBlockerType) => number; + readonly stop: (id: number) => void; + readonly isStarted: (id: number) => boolean; +} + +interface PowerSaveBlockerLike { + readonly start?: unknown; + readonly stop?: unknown; + readonly isStarted?: unknown; +} + +const resolveDefaultBlocker = (): DesktopKeepAwakePowerSaveBlocker | null => { + try { + const electron = Electron as unknown as { readonly powerSaveBlocker?: PowerSaveBlockerLike }; + const candidate = electron.powerSaveBlocker; + if ( + candidate !== null && + candidate !== undefined && + typeof candidate.start === "function" && + typeof candidate.stop === "function" && + typeof candidate.isStarted === "function" + ) { + return candidate as unknown as DesktopKeepAwakePowerSaveBlocker; + } + return null; + } catch { + // Web builds / anything without the Electron main API: unsupported, not fatal. + return null; + } +}; + +const unsupportedState: DesktopKeepAwakeState = { + enabled: false, + supported: false, + blockerType: KEEP_AWAKE_BLOCKER_TYPE, +}; + +export interface DesktopKeepAwakeOptions { + readonly powerSaveBlocker?: DesktopKeepAwakePowerSaveBlocker | null | undefined; + readonly platform?: NodeJS.Platform | undefined; + readonly runPowercfg?: ((args: ReadonlyArray) => string) | undefined; + readonly applyExecutionState?: ((on: boolean) => Effect.Effect) | undefined; + readonly userDataDir?: string | null | undefined; + readonly reassertInterval?: Duration.Duration | undefined; +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = ( + options?: DesktopKeepAwakeOptions, +): Effect.Effect => + Effect.gen(function* () { + const blocker = options?.powerSaveBlocker ?? resolveDefaultBlocker(); + const supported = blocker !== null; + const platform = options?.platform ?? process.platform; + const isWindows = platform === "win32"; + const runPowercfg = options?.runPowercfg ?? defaultRunPowercfg; + const applyExecutionState = options?.applyExecutionState ?? defaultApplyExecutionState; + const reassertInterval = options?.reassertInterval ?? KEEP_AWAKE_REASSERT_INTERVAL; + // Resolved lazily (not once at init): app.getPath("userData") can be + // unavailable this early, which would otherwise disable crash recovery + // for the entire session. + const getRecoveryFile = (): string | null => + recoveryFilePath(resolveUserDataDir(options?.userDataDir)); + + const currentId = yield* Ref.make(Option.none()); + // Present while enabled on Windows: the user's original power values. + // Doubles as the off→on save guard — re-enable never re-saves our own + // forced values over the originals. + const savedSettings = yield* Ref.make(null); + + // Crash recovery: a previous session died while enabled and left its + // recovery file behind. Restore those values + /setactive, delete the + // file, log it. Fresh sessions start disabled either way. + const recovered = yield* Effect.sync(() => + isWindows ? recoverPreviousSession(runPowercfg, getRecoveryFile()) : false, + ); + if (recovered) { + yield* Effect.logWarning( + "[keep-awake] Restored power settings left behind by a previous session and removed the recovery file.", + ); + } + + // Best-effort stop that can never fail: teardown paths (disable, app quit) + // must not crash. (A crash mid-session is covered by the recovery file.) + const stopId = (id: number): void => { + if (blocker === null) return; + try { + blocker.stop(id); + } catch { + // Intentionally ignored — see above. + } + }; + + const stopCurrent = Effect.gen(function* () { + const previous = yield* Ref.getAndSet(currentId, Option.none()); + if (Option.isSome(previous)) { + yield* Effect.sync(() => stopId(previous.value)); + } + }); + + // Save originals only on the off→on transition, persist the recovery + // file, then force lid=Do Nothing + sleep/hibernate=never and assert the + // thread execution state. All native calls are best-effort. + const enableWindowsPower: Effect.Effect = Effect.gen(function* () { + const existing = yield* Ref.get(savedSettings); + if (existing === null) { + const [lidAc, lidDc] = querySetting(runPowercfg, SUB_BUTTONS, LIDACTION); + const [sleepAc, sleepDc] = querySetting(runPowercfg, SUB_SLEEP, STANDBYIDLE); + const [hibAc, hibDc] = querySetting(runPowercfg, SUB_SLEEP, HIBERNATEIDLE); + const saved: KeepAwakeSavedSettings = { lidAc, lidDc, sleepAc, sleepDc, hibAc, hibDc }; + yield* Ref.set(savedSettings, saved); + yield* Effect.sync(() => writeRecoveryFile(getRecoveryFile(), saved)); + } + yield* Effect.sync(() => forcePowerSettings(runPowercfg)); + yield* Effect.ignore(applyExecutionState(true)); + }); + + // Restore all six saved values + /setactive, clear the guard, delete the + // recovery file, and release the thread execution state. + const restoreWindowsPower: Effect.Effect = Effect.gen(function* () { + const existing = yield* Ref.getAndSet(savedSettings, null); + yield* Effect.sync(() => { + if (existing !== null) { + restorePowerSettings(runPowercfg, existing); + } + deleteRecoveryFile(getRecoveryFile()); + }); + yield* Effect.ignore(applyExecutionState(false)); + }); + + // Re-assert while enabled: something else (or the user) can rewrite the + // power plan or clear the thread state out from under us. Scoped to the + // layer, so the loop dies with the service and never outlives the app. + yield* Effect.forkScoped( + Effect.gen(function* () { + while (true) { + yield* Effect.sleep(reassertInterval); + const saved = yield* Ref.get(savedSettings); + if (saved !== null) { + yield* Effect.sync(() => forcePowerSettings(runPowercfg)); + yield* Effect.ignore(applyExecutionState(true)); + } + } + }), + ); + + const getState: Effect.Effect = Effect.gen(function* () { + const current = yield* Ref.get(currentId); + return { + enabled: supported && Option.isSome(current), + supported, + blockerType: KEEP_AWAKE_BLOCKER_TYPE, + }; + }); + + const setEnabled = Effect.fn("desktop.keepAwake.setEnabled")(function* (enabled: boolean) { + if (!supported || blocker === null) { + return unsupportedState; + } + if (!enabled) { + yield* stopCurrent; + if (isWindows) { + yield* restoreWindowsPower; + } + return yield* getState; + } + const current = yield* Ref.get(currentId); + if (Option.isSome(current)) { + let alive = false; + try { + alive = blocker.isStarted(current.value); + } catch { + alive = false; + } + if (alive) { + if (isWindows) { + // Self-heal: blocker held but no saved originals (should not + // happen, but forced power without restore data must never stand). + const saved = yield* Ref.get(savedSettings); + if (saved === null) { + yield* enableWindowsPower; + } + } + // Idempotent re-enable: keep holding the live blocker. + return yield* getState; + } + // Stale id (e.g. the OS released it out from under us) — drop it and + // acquire a fresh blocker below. + yield* Ref.set(currentId, Option.none()); + } + // Defensive: never hold two blockers at once. + yield* stopCurrent; + if (isWindows) { + yield* enableWindowsPower; + } + let id: number | null = null; + try { + // Brutal mode: the display stays on, even with the lid closed. + id = blocker.start(KEEP_AWAKE_BLOCKER_TYPE); + } catch { + id = null; + } + if (typeof id !== "number") { + // Could not hold the blocker: roll back any power changes just made + // so a failed enable never leaves the system forced awake. + if (isWindows) { + yield* restoreWindowsPower; + } + return unsupportedState; + } + yield* Ref.set(currentId, Option.some(id)); + return yield* getState; + }); + + // Restore on layer-scope close (app quit): the same restore path as + // disable, and it can never throw. + yield* Effect.acquireRelease(Effect.void, () => + Effect.gen(function* () { + const previous = yield* Ref.getAndSet(currentId, Option.none()); + if (Option.isSome(previous)) { + const id = previous.value; + yield* Effect.sync(() => stopId(id)); + } + if (isWindows) { + const existing = yield* Ref.getAndSet(savedSettings, null); + yield* Effect.sync(() => { + if (existing !== null) { + restorePowerSettings(runPowercfg, existing); + } + deleteRecoveryFile(getRecoveryFile()); + }); + yield* Effect.ignore(applyExecutionState(false)); + } + }).pipe(Effect.ignoreCause), + ); + + return DesktopKeepAwake.of({ getState, setEnabled }); + }); + +export const layer = Layer.effect(DesktopKeepAwake, make()); + +/** Test seam: build the layer around an explicit (possibly null) blocker. */ +export const layerWithBlocker = (powerSaveBlocker: DesktopKeepAwakePowerSaveBlocker | null) => + Layer.effect(DesktopKeepAwake, make({ powerSaveBlocker })); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 885b31a64d6b..4bfedf6330e3 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -166,6 +166,9 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.SET_WSL_BACKEND_ENABLED_CHANNEL, enabled), setWslDistro: (distro) => ipcRenderer.invoke(IpcChannels.SET_WSL_DISTRO_CHANNEL, distro), setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled), + getKeepAwakeState: () => ipcRenderer.invoke(IpcChannels.GET_KEEP_AWAKE_STATE_CHANNEL), + setKeepAwakeEnabled: (enabled) => + ipcRenderer.invoke(IpcChannels.SET_KEEP_AWAKE_ENABLED_CHANNEL, { enabled }), pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), pickProjectFavicon: (initialPath) => ipcRenderer.invoke(IpcChannels.PICK_PROJECT_FAVICON_CHANNEL, initialPath), diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 324361d7fb68..5f76e074c6ec 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -67,6 +67,7 @@ import { useRelativeTimeTick, } from "./settingsLayout"; import { LocalEnvironmentSetting } from "./LocalEnvironmentSetting"; +import { KeepAwakeRow } from "./KeepAwakeRow"; import { searchableSetting } from "./settingsSearch"; import { EnvironmentIconMenu } from "./EnvironmentIconPicker"; import { @@ -3362,6 +3363,7 @@ export function ConnectionsSettings() { {renderTailscaleRow()} {renderWslRow()} + ) : canManageLocalBackend ? ( <> diff --git a/apps/web/src/components/settings/KeepAwakeRow.tsx b/apps/web/src/components/settings/KeepAwakeRow.tsx new file mode 100644 index 000000000000..7621bb0ebb6d --- /dev/null +++ b/apps/web/src/components/settings/KeepAwakeRow.tsx @@ -0,0 +1,134 @@ +import { useState } from "react"; + +import { useEnvironmentQuery } from "~/state/query"; +import { + keepAwakeStateAtom, + refreshKeepAwakeState, + setKeepAwakeEnabled, + type KeepAwakeBridge, +} from "~/state/keepAwakeState"; +import { Switch } from "../ui/switch"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { SettingsRow } from "./settingsLayout"; + +function KeepAwakeSwitch({ + checked, + disabled, + disabledReason, + onCheckedChange, + ariaLabel = "Keep this computer awake", +}: { + readonly checked: boolean; + readonly disabled: boolean; + readonly disabledReason: string | null; + readonly onCheckedChange?: (enabled: boolean) => void; + readonly ariaLabel?: string; +}) { + const control = ( + + ); + return disabledReason ? ( + + {control}} /> + {disabledReason} + + ) : ( + control + ); +} + +export function KeepAwakeRow({ + canManageLocalBackend, +}: { + readonly canManageLocalBackend: boolean; +}) { + const desktopBridge = typeof window === "undefined" ? undefined : window.desktopBridge; + const keepAwakeBridge = desktopBridge as unknown as KeepAwakeBridge | undefined; + const keepAwake = useEnvironmentQuery( + canManageLocalBackend && desktopBridge ? keepAwakeStateAtom : null, + ); + const [isUpdating, setIsUpdating] = useState(false); + + // Desktop-only row: the browser build has no power-inhibition backend. + if (!desktopBridge) return null; + + const bridgeSupportsKeepAwake = + typeof keepAwakeBridge?.getKeepAwakeState === "function" && + typeof keepAwakeBridge?.setKeepAwakeEnabled === "function"; + const state = keepAwake.data; + + const disabledReason = !canManageLocalBackend + ? "Your session does not have permission to manage this machine's settings." + : !bridgeSupportsKeepAwake + ? "This version of the desktop app does not support Keep Awake." + : state !== null && !state.supported + ? "Keep Awake is not supported on this machine." + : null; + const isBusy = keepAwake.isPending || isUpdating; + + const updateKeepAwake = async (enabled: boolean) => { + if (!canManageLocalBackend || !bridgeSupportsKeepAwake) return; + setIsUpdating(true); + try { + const next = await setKeepAwakeEnabled(enabled); + refreshKeepAwakeState(); + if (next.enabled !== enabled) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: enabled ? "Could not turn Keep Awake on" : "Could not turn Keep Awake off", + description: next.supported + ? "The setting did not change. Try again." + : "Keep Awake is not supported on this machine.", + }), + ); + return; + } + toastManager.add({ + type: "success", + title: enabled ? "Keep Awake on" : "Keep Awake off", + description: enabled + ? "This computer will stay awake for remote agent work." + : "This computer can sleep normally again.", + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to change Keep Awake."; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not change Keep Awake", + description: message, + }), + ); + refreshKeepAwakeState(); + } finally { + setIsUpdating(false); + } + }; + + return ( + {keepAwake.error} : null + } + control={ + void updateKeepAwake(enabled)} + /> + } + /> + ); +} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 4532bd5f72fb..645a0e07f200 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -756,6 +756,18 @@ export const SETTINGS_SEARCH_ITEMS = [ searchTerms: ["mobile push notifications live activities cloud tunnel"], cloudOnly: true, }, + { + id: "keep-awake", + localEnvironmentOnly: true, + title: "Keep Awake", + to: "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/settings/connections", + targetId: "connections-environment", + searchTerms: [ + "awake sleep power insomnia caffeinate prevent suspend lid display remote agent work", + ], + desktopOnly: true, + localBackendManagementOnly: true, + }, { id: "connections-environment", title: "This machine", diff --git a/apps/web/src/state/keepAwakeState.ts b/apps/web/src/state/keepAwakeState.ts new file mode 100644 index 000000000000..14889eb985b1 --- /dev/null +++ b/apps/web/src/state/keepAwakeState.ts @@ -0,0 +1,111 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { Atom } from "effect/unstable/reactivity"; + +import { appAtomRegistry } from "~/rpc/atomRegistry"; + +/** + * Desktop IPC channels backing Keep Awake. + * Wired in the desktop shell (apps/desktop/src/ipc/channels.ts + preload.ts): + * preload exposes `getKeepAwakeState` / `setKeepAwakeEnabled` on + * `window.desktopBridge`, each implemented as + * `ipcRenderer.invoke()`. + */ +export const GET_KEEP_AWAKE_STATE_CHANNEL = "desktop:get-keep-awake-state"; +export const SET_KEEP_AWAKE_ENABLED_CHANNEL = "desktop:set-keep-awake-enabled"; + +const KEEP_AWAKE_STATE_STALE_TIME_MS = 30_000; + +export const KeepAwakeStateSchema = Schema.Struct({ + enabled: Schema.Boolean, + supported: Schema.Boolean, +}); + +export interface KeepAwakeState { + readonly enabled: boolean; + /** False on machines where the OS inhibitors are unavailable. */ + readonly supported: boolean; +} + +/** + * Minimal bridge surface for Keep Awake. Declared structurally instead of + * `Pick` so web also builds against older desktop shells + * where these methods are absent — callers treat missing methods as + * unsupported (disabled row with tooltip) rather than crashing. + */ +export interface KeepAwakeBridge { + readonly getKeepAwakeState?: () => Promise; + readonly setKeepAwakeEnabled?: (enabled: boolean) => Promise; +} + +class KeepAwakeStateUnavailableError extends Schema.TaggedError()( + "KeepAwakeStateUnavailableError", + {}, +) { + override get message(): string { + return "Keep Awake is unavailable."; + } +} + +class KeepAwakeStateLoadError extends Schema.TaggedError()( + "KeepAwakeStateLoadError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to load Keep Awake state."; + } +} + +export class KeepAwakeUnsupportedError extends Error { + override readonly name = "KeepAwakeUnsupportedError"; + constructor() { + super("This version of the desktop app does not support Keep Awake."); + } +} + +function getKeepAwakeBridge(): KeepAwakeBridge | undefined { + return typeof window === "undefined" ? undefined : window.desktopBridge; +} + +export function createKeepAwakeStateAtom(getBridge: () => KeepAwakeBridge | undefined) { + const loadKeepAwakeState = Effect.fn("loadKeepAwakeState")(function* () { + const bridge = getBridge(); + if (!bridge?.getKeepAwakeState) { + return yield* new KeepAwakeStateUnavailableError(); + } + const getState = bridge.getKeepAwakeState; + return yield* Effect.tryPromise({ + try: (): Promise => getState(), + catch: (cause) => new KeepAwakeStateLoadError({ cause }), + }); + }); + + return Atom.make(loadKeepAwakeState()).pipe( + Atom.swr({ + staleTime: KEEP_AWAKE_STATE_STALE_TIME_MS, + revalidateOnMount: true, + }), + Atom.keepAlive, + Atom.withLabel("desktop:keep-awake-state:load"), + ); +} + +export const keepAwakeStateAtom = createKeepAwakeStateAtom(getKeepAwakeBridge); + +export function refreshKeepAwakeState(): void { + appAtomRegistry.refresh(keepAwakeStateAtom); +} + +/** + * Toggle entry point for the settings row. Resolves via the + * `desktop:set-keep-awake-enabled` channel (see preload's + * `setKeepAwakeEnabled`); throws {@link KeepAwakeUnsupportedError} on older + * desktop shells that lack the method. + */ +export async function setKeepAwakeEnabled(enabled: boolean): Promise { + const bridge = getKeepAwakeBridge(); + if (!bridge?.setKeepAwakeEnabled) { + throw new KeepAwakeUnsupportedError(); + } + return bridge.setKeepAwakeEnabled(enabled); +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index e1af4f936141..5562c1a77743 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -660,6 +660,18 @@ export const DesktopWslStateSchema = Schema.Struct({ preflightError: Schema.NullOr(Schema.String), }); +export const DesktopKeepAwakeStateSchema = Schema.Struct({ + enabled: Schema.Boolean, + supported: Schema.Boolean, + blockerType: Schema.Literal("prevent-display-sleep"), +}); + +export interface DesktopKeepAwakeState { + readonly enabled: boolean; + readonly supported: boolean; + readonly blockerType: "prevent-display-sleep"; +} + /** * Renderer-facing snapshot of a desktop preview tab. Mirrors the main-process * PreviewTabState shape but uses serialisable primitives only. @@ -1287,6 +1299,8 @@ export interface DesktopBridge { setWslBackendEnabled: (enabled: boolean) => Promise; setWslDistro: (distro: string | null) => Promise; setWslOnly: (enabled: boolean) => Promise; + getKeepAwakeState?: () => Promise; + setKeepAwakeEnabled?: (enabled: boolean) => Promise; pickFolder: (options?: PickFolderOptions) => Promise; /** Optional while older desktop shells can host a newer web client. */ pickProjectFavicon?: (initialPath?: string) => Promise; From 0ad37c086b545fe76ca911281a939fe052ab40b9 Mon Sep 17 00:00:00 2001 From: vishal Date: Sun, 20 Sep 2026 10:03:26 +0530 Subject: [PATCH 2/4] fix(desktop): per-scheme save/restore, serialized transitions, deferred crash recovery --- apps/desktop/src/power/DesktopKeepAwake.ts | 320 +++++++++++++++------ 1 file changed, 225 insertions(+), 95 deletions(-) diff --git a/apps/desktop/src/power/DesktopKeepAwake.ts b/apps/desktop/src/power/DesktopKeepAwake.ts index fa0de5bf8d91..1813e544ff98 100644 --- a/apps/desktop/src/power/DesktopKeepAwake.ts +++ b/apps/desktop/src/power/DesktopKeepAwake.ts @@ -7,6 +7,7 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; import * as NodeChildProcess from "node:child_process"; import * as NodeFs from "node:fs"; @@ -40,17 +41,28 @@ import * as Electron from "electron"; * Safety net around the global side effects: * * - The six overridden values (lid AC/DC, sleep-after AC/DC, - * hibernate-after AC/DC) are queried and saved ONLY on the off→on - * transition (guarded by a `Ref`, so re-enable never clobbers the - * originals with our own forced values), then restored on disable. + * hibernate-after AC/DC) are saved per power scheme GUID on first touch + * (guarded by a `Ref` map, so re-enable and the reassert loop never + * clobber the originals with our own forced values), then each touched + * scheme is restored to its own originals on disable. Saving by scheme — + * never by the `SCHEME_CURRENT` alias — is what keeps a mid-session power + * plan switch from permanently corrupting the previous plan. * - The saved values are also persisted to a JSON recovery file under * Electron `userData` (`app.getPath("userData")`, best-effort — skipped * when unavailable), deleted on clean disable/restore. If a previous - * session crashed or was killed while enabled, service init finds the file, - * best-effort restores those values + `/setactive`, deletes it, and logs. + * session crashed or was killed while enabled, the first service use + * finds the file, best-effort restores those values + `/setactive`, + * deletes it, and logs. The check is deferred to first use (not layer + * construction) because layers build before `DesktopApp` sets Electron's + * final `userData` path — an init-time lookup would read the wrong + * directory and miss the file. + * - Every power/blocker transition (enable, disable, reassert tick, quit + * finalizer) is serialized through a 1-permit semaphore: `setEnabled` + * suspends on the `ffi-rs` load, so overlapping toggles could otherwise + * double-start blockers and orphan one. * - A `forkScoped` 25 s re-assert loop re-applies the forced values and the - * execution state while still enabled (checks the `Ref`; dies with the - * layer scope). + * execution state while still enabled (checks the `Ref` map, tracking + * newly switched-to plans as it goes; dies with the layer scope). * - The layer-scope-close finalizer runs the same restore path when still * enabled and can never throw. */ @@ -102,6 +114,13 @@ export interface KeepAwakeSavedSettings { readonly hibDc?: number | undefined; } +/** + * Original power values per scheme: scheme GUID (or the + * `SCHEME_CURRENT` alias when the GUID was unresolvable) → settings. + * Keying by scheme is what makes a mid-session power plan switch safe. + */ +export type KeepAwakeSavedByScheme = ReadonlyMap; + /** Name of the JSON crash-recovery file inside Electron `userData`. */ export const KEEP_AWAKE_RECOVERY_FILE_NAME = "keep-awake-recovery.json"; @@ -175,63 +194,121 @@ const parseFirstHex = (out: string): number | undefined => { return undefined; }; -// Read the current AC and DC index for one power setting. +// Every helper below targets an explicit scheme: either a GUID resolved +// from `/getactivescheme` or the `SCHEME_CURRENT` alias as a last resort. +// Saving and restoring by scheme — never by alias — is what keeps a +// mid-session power plan switch from permanently corrupting the previous +// plan: each touched scheme is restored to its own originals. +const SCHEME_CURRENT_ALIAS = "SCHEME_CURRENT"; + +const SCHEME_GUID_PATTERN = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + +// Resolve the active power scheme GUID (`powercfg /getactivescheme` prints +// `Power Scheme GUID: ()`). Undefined when powercfg fails or +// the output is unparseable — callers fall back to the `SCHEME_CURRENT` +// alias for that operation only. +const getActiveSchemeGuid = ( + runPowercfg: (args: ReadonlyArray) => string, +): string | undefined => { + const out = runPowercfg(["/getactivescheme"]); + if (out.length === 0) { + return undefined; + } + const match = + /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/.exec(out); + return match?.[0]; +}; + +// Read the current AC and DC index for one power setting on one scheme. const querySetting = ( runPowercfg: (args: ReadonlyArray) => string, + scheme: string, sub: string, setting: string, ): readonly [number | undefined, number | undefined] => { - const acOut = runPowercfg(["/getacvalueindex", "SCHEME_CURRENT", sub, setting]); - const dcOut = runPowercfg(["/getdcvalueindex", "SCHEME_CURRENT", sub, setting]); + const acOut = runPowercfg(["/getacvalueindex", scheme, sub, setting]); + const dcOut = runPowercfg(["/getdcvalueindex", scheme, sub, setting]); return [parseFirstHex(acOut), parseFirstHex(dcOut)]; }; +// Read all three overridden settings on one scheme. +const querySchemeSettings = ( + runPowercfg: (args: ReadonlyArray) => string, + scheme: string, +): KeepAwakeSavedSettings => { + const [lidAc, lidDc] = querySetting(runPowercfg, scheme, SUB_BUTTONS, LIDACTION); + const [sleepAc, sleepDc] = querySetting(runPowercfg, scheme, SUB_SLEEP, STANDBYIDLE); + const [hibAc, hibDc] = querySetting(runPowercfg, scheme, SUB_SLEEP, HIBERNATEIDLE); + return { lidAc, lidDc, sleepAc, sleepDc, hibAc, hibDc }; +}; + // Set one power setting's AC and DC index (no /setactive — caller batches it). const setSetting = ( runPowercfg: (args: ReadonlyArray) => string, + scheme: string, sub: string, setting: string, value: number, ): void => { const v = value.toString(); - runPowercfg(["/setacvalueindex", "SCHEME_CURRENT", sub, setting, v]); - runPowercfg(["/setdcvalueindex", "SCHEME_CURRENT", sub, setting, v]); + runPowercfg(["/setacvalueindex", scheme, sub, setting, v]); + runPowercfg(["/setdcvalueindex", scheme, sub, setting, v]); }; // Restore one setting's AC/DC index if we recorded originals for it. const restoreSetting = ( runPowercfg: (args: ReadonlyArray) => string, + scheme: string, sub: string, setting: string, ac: number | undefined, dc: number | undefined, ): void => { if (ac !== undefined) { - runPowercfg(["/setacvalueindex", "SCHEME_CURRENT", sub, setting, ac.toString()]); + runPowercfg(["/setacvalueindex", scheme, sub, setting, ac.toString()]); } if (dc !== undefined) { - runPowercfg(["/setdcvalueindex", "SCHEME_CURRENT", sub, setting, dc.toString()]); + runPowercfg(["/setdcvalueindex", scheme, sub, setting, dc.toString()]); } }; -// Force lid=Do Nothing and sleep/hibernate timeouts to 0 (never), batched -// with a single /setactive like Brutal Awake. -const forcePowerSettings = (runPowercfg: (args: ReadonlyArray) => string): void => { - setSetting(runPowercfg, SUB_BUTTONS, LIDACTION, 0); // 0 = Do nothing on lid close - setSetting(runPowercfg, SUB_SLEEP, STANDBYIDLE, 0); // 0 = never sleep - setSetting(runPowercfg, SUB_SLEEP, HIBERNATEIDLE, 0); // 0 = never hibernate - runPowercfg(["/setactive", "SCHEME_CURRENT"]); +// Force lid=Do Nothing and sleep/hibernate timeouts to 0 (never) on one +// scheme, batched with a single /setactive like Brutal Awake. +const forceSchemeSettings = ( + runPowercfg: (args: ReadonlyArray) => string, + scheme: string, +): void => { + setSetting(runPowercfg, scheme, SUB_BUTTONS, LIDACTION, 0); // 0 = Do nothing on lid close + setSetting(runPowercfg, scheme, SUB_SLEEP, STANDBYIDLE, 0); // 0 = never sleep + setSetting(runPowercfg, scheme, SUB_SLEEP, HIBERNATEIDLE, 0); // 0 = never hibernate + runPowercfg(["/setactive", SCHEME_CURRENT_ALIAS]); }; -// Put every overridden power setting back to what the user had. -const restorePowerSettings = ( +// Put one scheme's overridden settings back to what the user had (no +// /setactive — the caller batches one after restoring every scheme). +const restoreSchemeSettings = ( runPowercfg: (args: ReadonlyArray) => string, + scheme: string, saved: KeepAwakeSavedSettings, ): void => { - restoreSetting(runPowercfg, SUB_BUTTONS, LIDACTION, saved.lidAc, saved.lidDc); - restoreSetting(runPowercfg, SUB_SLEEP, STANDBYIDLE, saved.sleepAc, saved.sleepDc); - restoreSetting(runPowercfg, SUB_SLEEP, HIBERNATEIDLE, saved.hibAc, saved.hibDc); - runPowercfg(["/setactive", "SCHEME_CURRENT"]); + restoreSetting(runPowercfg, scheme, SUB_BUTTONS, LIDACTION, saved.lidAc, saved.lidDc); + restoreSetting(runPowercfg, scheme, SUB_SLEEP, STANDBYIDLE, saved.sleepAc, saved.sleepDc); + restoreSetting(runPowercfg, scheme, SUB_SLEEP, HIBERNATEIDLE, saved.hibAc, saved.hibDc); +}; + +// Restore every touched scheme to its own originals, then reactivate once. +const restoreAllSchemes = ( + runPowercfg: (args: ReadonlyArray) => string, + saved: KeepAwakeSavedByScheme, +): void => { + if (saved.size === 0) { + return; + } + for (const [scheme, settings] of saved) { + restoreSchemeSettings(runPowercfg, scheme, settings); + } + runPowercfg(["/setactive", SCHEME_CURRENT_ALIAS]); }; // --- Crash-recovery file (Electron userData) -------------------------------- @@ -258,12 +335,12 @@ const resolveUserDataDir = (override: string | null | undefined): string | null const recoveryFilePath = (userDataDir: string | null): string | null => userDataDir === null ? null : NodePath.join(userDataDir, KEEP_AWAKE_RECOVERY_FILE_NAME); -const writeRecoveryFile = (file: string | null, saved: KeepAwakeSavedSettings): void => { +const writeRecoveryFile = (file: string | null, saved: KeepAwakeSavedByScheme): void => { if (file === null) { return; } try { - NodeFs.writeFileSync(file, JSON.stringify(saved), "utf8"); + NodeFs.writeFileSync(file, JSON.stringify(Object.fromEntries(saved)), "utf8"); } catch { // Best-effort: without the file we just lose crash recovery for this session. } @@ -300,6 +377,23 @@ const sanitizeSavedSettings = (input: unknown): KeepAwakeSavedSettings | null => }; }; +const sanitizeSavedByScheme = (input: unknown): KeepAwakeSavedByScheme | null => { + if (typeof input !== "object" || input === null) { + return null; + } + const result = new Map(); + for (const [key, value] of Object.entries(input as Record)) { + if (!SCHEME_GUID_PATTERN.test(key) && key !== SCHEME_CURRENT_ALIAS) { + continue; + } + const saved = sanitizeSavedSettings(value); + if (saved !== null) { + result.set(key, saved); + } + } + return result; +}; + /** * Best-effort restore for a previous session that died while enabled. * Returns true when a recovery file existed (so the caller logs it). @@ -321,11 +415,11 @@ const recoverPreviousSession = ( return false; } try { - const saved = sanitizeSavedSettings( + const saved = sanitizeSavedByScheme( JSON.parse(NodeFs.readFileSync(recoveryFile, "utf8")) as unknown, ); if (saved !== null) { - restorePowerSettings(runPowercfg, saved); + restoreAllSchemes(runPowercfg, saved); } } catch { // Corrupt file: fall through to delete + log below. @@ -406,22 +500,34 @@ export const make = ( recoveryFilePath(resolveUserDataDir(options?.userDataDir)); const currentId = yield* Ref.make(Option.none()); - // Present while enabled on Windows: the user's original power values. - // Doubles as the off→on save guard — re-enable never re-saves our own - // forced values over the originals. - const savedSettings = yield* Ref.make(null); - - // Crash recovery: a previous session died while enabled and left its - // recovery file behind. Restore those values + /setactive, delete the - // file, log it. Fresh sessions start disabled either way. - const recovered = yield* Effect.sync(() => - isWindows ? recoverPreviousSession(runPowercfg, getRecoveryFile()) : false, - ); - if (recovered) { - yield* Effect.logWarning( - "[keep-awake] Restored power settings left behind by a previous session and removed the recovery file.", + // Original power values keyed by scheme GUID, non-empty while enabled + // on Windows. Doubles as the save guard — re-enable and the reassert + // loop never re-save our own forced values over the originals. + const savedByScheme = yield* Ref.make(new Map()); + // Serializes every power/blocker transition: setEnabled suspends on the + // ffi-rs load, so overlapping toggles could otherwise double-start + // blockers and orphan one. All entry points below take this lock. + const transitionMutex = yield* Semaphore.make(1); + // Crash-recovery check, deferred to first use instead of layer + // construction: layers build before DesktopApp sets Electron's final + // userData path, so an init-time lookup would read the wrong directory + // and miss the file. The first IPC call necessarily happens after + // setPath. Idempotent — runs at most once per process. + const recoveryChecked = yield* Ref.make(false); + const ensureRecoveryChecked: Effect.Effect = Effect.gen(function* () { + const done = yield* Ref.getAndSet(recoveryChecked, true); + if (done || !isWindows) { + return; + } + const recovered = yield* Effect.sync(() => + recoverPreviousSession(runPowercfg, getRecoveryFile()), ); - } + if (recovered) { + yield* Effect.logWarning( + "[keep-awake] Restored power settings left behind by a previous session and removed the recovery file.", + ); + } + }); // Best-effort stop that can never fail: teardown paths (disable, app quit) // must not crash. (A crash mid-session is covered by the recovery file.) @@ -441,53 +547,67 @@ export const make = ( } }); - // Save originals only on the off→on transition, persist the recovery - // file, then force lid=Do Nothing + sleep/hibernate=never and assert the - // thread execution state. All native calls are best-effort. + // Save one scheme's originals on first touch (guarded by the map, so we + // never record our own forced values), persist the recovery file, then + // force lid=Do Nothing + sleep/hibernate=never on the active scheme and + // assert the thread execution state. All native calls are best-effort. + // Callers must hold the transition mutex. + const ensureSchemeSaved = (scheme: string): Effect.Effect => + Effect.gen(function* () { + const saved = yield* Ref.get(savedByScheme); + if (saved.has(scheme)) { + return; + } + const next = new Map(saved); + next.set(scheme, querySchemeSettings(runPowercfg, scheme)); + yield* Ref.set(savedByScheme, next); + yield* Effect.sync(() => writeRecoveryFile(getRecoveryFile(), next)); + }); + const enableWindowsPower: Effect.Effect = Effect.gen(function* () { - const existing = yield* Ref.get(savedSettings); - if (existing === null) { - const [lidAc, lidDc] = querySetting(runPowercfg, SUB_BUTTONS, LIDACTION); - const [sleepAc, sleepDc] = querySetting(runPowercfg, SUB_SLEEP, STANDBYIDLE); - const [hibAc, hibDc] = querySetting(runPowercfg, SUB_SLEEP, HIBERNATEIDLE); - const saved: KeepAwakeSavedSettings = { lidAc, lidDc, sleepAc, sleepDc, hibAc, hibDc }; - yield* Ref.set(savedSettings, saved); - yield* Effect.sync(() => writeRecoveryFile(getRecoveryFile(), saved)); - } - yield* Effect.sync(() => forcePowerSettings(runPowercfg)); + const scheme = getActiveSchemeGuid(runPowercfg) ?? SCHEME_CURRENT_ALIAS; + yield* ensureSchemeSaved(scheme); + yield* Effect.sync(() => forceSchemeSettings(runPowercfg, scheme)); yield* Effect.ignore(applyExecutionState(true)); }); - // Restore all six saved values + /setactive, clear the guard, delete the - // recovery file, and release the thread execution state. + // Restore every touched scheme to its own originals + /setactive, clear + // the guard, delete the recovery file, release the execution state. + // Callers must hold the transition mutex. const restoreWindowsPower: Effect.Effect = Effect.gen(function* () { - const existing = yield* Ref.getAndSet(savedSettings, null); + const saved = yield* Ref.getAndSet(savedByScheme, new Map()); yield* Effect.sync(() => { - if (existing !== null) { - restorePowerSettings(runPowercfg, existing); - } + restoreAllSchemes(runPowercfg, saved); deleteRecoveryFile(getRecoveryFile()); }); yield* Effect.ignore(applyExecutionState(false)); }); // Re-assert while enabled: something else (or the user) can rewrite the - // power plan or clear the thread state out from under us. Scoped to the - // layer, so the loop dies with the service and never outlives the app. + // power plan or clear the thread state out from under us — including + // switching plans, which we then track as a newly touched scheme. + // Scoped to the layer, so the loop dies with the service. yield* Effect.forkScoped( Effect.gen(function* () { while (true) { yield* Effect.sleep(reassertInterval); - const saved = yield* Ref.get(savedSettings); - if (saved !== null) { - yield* Effect.sync(() => forcePowerSettings(runPowercfg)); - yield* Effect.ignore(applyExecutionState(true)); - } + yield* transitionMutex.withPermits(1)( + Effect.gen(function* () { + const saved = yield* Ref.get(savedByScheme); + if (saved.size === 0) { + return; + } + yield* Effect.ignore(applyExecutionState(true)); + const scheme = getActiveSchemeGuid(runPowercfg) ?? SCHEME_CURRENT_ALIAS; + yield* ensureSchemeSaved(scheme); + yield* Effect.sync(() => forceSchemeSettings(runPowercfg, scheme)); + }), + ); } }), ); - const getState: Effect.Effect = Effect.gen(function* () { + const readState: Effect.Effect = Effect.gen(function* () { const current = yield* Ref.get(currentId); return { enabled: supported && Option.isSome(current), @@ -496,7 +616,15 @@ export const make = ( }; }); - const setEnabled = Effect.fn("desktop.keepAwake.setEnabled")(function* (enabled: boolean) { + const getState: Effect.Effect = + transitionMutex.withPermits(1)( + Effect.gen(function* () { + yield* ensureRecoveryChecked; + return yield* readState; + }), + ); + + const runTransition = Effect.fn("desktop.keepAwake.transition")(function* (enabled: boolean) { if (!supported || blocker === null) { return unsupportedState; } @@ -505,7 +633,7 @@ export const make = ( if (isWindows) { yield* restoreWindowsPower; } - return yield* getState; + return yield* readState; } const current = yield* Ref.get(currentId); if (Option.isSome(current)) { @@ -519,13 +647,13 @@ export const make = ( if (isWindows) { // Self-heal: blocker held but no saved originals (should not // happen, but forced power without restore data must never stand). - const saved = yield* Ref.get(savedSettings); - if (saved === null) { + const saved = yield* Ref.get(savedByScheme); + if (saved.size === 0) { yield* enableWindowsPower; } } // Idempotent re-enable: keep holding the live blocker. - return yield* getState; + return yield* readState; } // Stale id (e.g. the OS released it out from under us) — drop it and // acquire a fresh blocker below. @@ -552,29 +680,31 @@ export const make = ( return unsupportedState; } yield* Ref.set(currentId, Option.some(id)); - return yield* getState; + return yield* readState; + }); + + const setEnabled = Effect.fn("desktop.keepAwake.setEnabled")(function* (enabled: boolean) { + return yield* transitionMutex.withPermits(1)( + Effect.gen(function* () { + yield* ensureRecoveryChecked; + return yield* runTransition(enabled); + }), + ); }); // Restore on layer-scope close (app quit): the same restore path as // disable, and it can never throw. yield* Effect.acquireRelease(Effect.void, () => - Effect.gen(function* () { - const previous = yield* Ref.getAndSet(currentId, Option.none()); - if (Option.isSome(previous)) { - const id = previous.value; - yield* Effect.sync(() => stopId(id)); - } - if (isWindows) { - const existing = yield* Ref.getAndSet(savedSettings, null); - yield* Effect.sync(() => { - if (existing !== null) { - restorePowerSettings(runPowercfg, existing); + transitionMutex + .withPermits(1)( + Effect.gen(function* () { + yield* stopCurrent; + if (isWindows) { + yield* restoreWindowsPower; } - deleteRecoveryFile(getRecoveryFile()); - }); - yield* Effect.ignore(applyExecutionState(false)); - } - }).pipe(Effect.ignoreCause), + }), + ) + .pipe(Effect.ignoreCause), ); return DesktopKeepAwake.of({ getState, setEnabled }); From 904c25694c57e93fac51939ee12db77b907576ea Mon Sep 17 00:00:00 2001 From: vishal Date: Sun, 20 Sep 2026 10:08:10 +0530 Subject: [PATCH 3/4] fix(desktop,web): verify powercfg force/restore, recovery row for unknown state --- apps/desktop/src/power/DesktopKeepAwake.ts | 76 ++++++++++++++++++- .../src/components/settings/KeepAwakeRow.tsx | 28 ++++++- 2 files changed, 99 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/power/DesktopKeepAwake.ts b/apps/desktop/src/power/DesktopKeepAwake.ts index 1813e544ff98..2f33dc02013b 100644 --- a/apps/desktop/src/power/DesktopKeepAwake.ts +++ b/apps/desktop/src/power/DesktopKeepAwake.ts @@ -311,6 +311,45 @@ const restoreAllSchemes = ( runPowercfg(["/setactive", SCHEME_CURRENT_ALIAS]); }; +// Read back one scheme and confirm every overridden value is 0 (never / +// Do Nothing). powercfg writes fail silently without elevation, and +// reporting success then would leave the toggle lying about protection +// the machine does not have. +const verifySchemeForced = ( + runPowercfg: (args: ReadonlyArray) => string, + scheme: string, +): boolean => { + const forced = querySchemeSettings(runPowercfg, scheme); + return ( + forced.lidAc === 0 && + forced.lidDc === 0 && + forced.sleepAc === 0 && + forced.sleepDc === 0 && + forced.hibAc === 0 && + forced.hibDc === 0 + ); +}; + +// Confirm one scheme matches the originals we recorded. Only used after a +// restore, where a mismatch yields a warning — disable is best-effort, +// since there is nothing sensible to roll back to. +const verifySchemeRestored = ( + runPowercfg: (args: ReadonlyArray) => string, + scheme: string, + saved: KeepAwakeSavedSettings, +): boolean => { + const current = querySchemeSettings(runPowercfg, scheme); + const fields = [ + [current.lidAc, saved.lidAc], + [current.lidDc, saved.lidDc], + [current.sleepAc, saved.sleepAc], + [current.sleepDc, saved.sleepDc], + [current.hibAc, saved.hibAc], + [current.hibDc, saved.hibDc], + ] as const; + return fields.every(([actual, expected]) => expected === undefined || actual === expected); +}; + // --- Crash-recovery file (Electron userData) -------------------------------- const resolveUserDataDir = (override: string | null | undefined): string | null => { @@ -564,11 +603,16 @@ export const make = ( yield* Effect.sync(() => writeRecoveryFile(getRecoveryFile(), next)); }); - const enableWindowsPower: Effect.Effect = Effect.gen(function* () { + // Returns true when the force landed. A false means the powercfg + // writes did not take effect (e.g. no elevation) — callers roll back + // rather than claim protection the machine does not have. + const enableWindowsPower: Effect.Effect = Effect.gen(function* () { const scheme = getActiveSchemeGuid(runPowercfg) ?? SCHEME_CURRENT_ALIAS; yield* ensureSchemeSaved(scheme); yield* Effect.sync(() => forceSchemeSettings(runPowercfg, scheme)); + const forced = yield* Effect.sync(() => verifySchemeForced(runPowercfg, scheme)); yield* Effect.ignore(applyExecutionState(true)); + return forced; }); // Restore every touched scheme to its own originals + /setactive, clear @@ -576,10 +620,22 @@ export const make = ( // Callers must hold the transition mutex. const restoreWindowsPower: Effect.Effect = Effect.gen(function* () { const saved = yield* Ref.getAndSet(savedByScheme, new Map()); - yield* Effect.sync(() => { + const unverified = yield* Effect.sync(() => { restoreAllSchemes(runPowercfg, saved); deleteRecoveryFile(getRecoveryFile()); + const bad: Array = []; + for (const [scheme, settings] of saved) { + if (!verifySchemeRestored(runPowercfg, scheme, settings)) { + bad.push(scheme); + } + } + return bad; }); + if (unverified.length > 0) { + yield* Effect.logWarning( + `[keep-awake] Restored power settings but could not confirm ${unverified.length} scheme(s); values may still be forced.`, + ); + } yield* Effect.ignore(applyExecutionState(false)); }); @@ -649,7 +705,10 @@ export const make = ( // happen, but forced power without restore data must never stand). const saved = yield* Ref.get(savedByScheme); if (saved.size === 0) { - yield* enableWindowsPower; + const forced = yield* enableWindowsPower; + if (!forced) { + yield* restoreWindowsPower; + } } } // Idempotent re-enable: keep holding the live blocker. @@ -662,7 +721,16 @@ export const make = ( // Defensive: never hold two blockers at once. yield* stopCurrent; if (isWindows) { - yield* enableWindowsPower; + const forced = yield* enableWindowsPower; + if (!forced) { + // The powercfg writes did not land (e.g. no elevation): roll back + // so a failed enable never claims protection the machine lacks. + yield* Effect.logWarning( + "[keep-awake] Could not force power settings (powercfg writes need elevation); rolled back.", + ); + yield* restoreWindowsPower; + return yield* readState; + } } let id: number | null = null; try { diff --git a/apps/web/src/components/settings/KeepAwakeRow.tsx b/apps/web/src/components/settings/KeepAwakeRow.tsx index 7621bb0ebb6d..d8a4f20d37e8 100644 --- a/apps/web/src/components/settings/KeepAwakeRow.tsx +++ b/apps/web/src/components/settings/KeepAwakeRow.tsx @@ -7,6 +7,7 @@ import { setKeepAwakeEnabled, type KeepAwakeBridge, } from "~/state/keepAwakeState"; +import { Button } from "../ui/button"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -58,6 +59,31 @@ export function KeepAwakeRow({ // Desktop-only row: the browser build has no power-inhibition backend. if (!desktopBridge) return null; + // Load failed: keep a recovery row (with retry) visible instead of an + // interactive switch stuck at off — with unknown state a click would send + // `true` while the machine may already be awake, stranding the user with + // no way to turn it off from this row. + if (keepAwake.error) { + return ( + {keepAwake.error}} + control={ + + } + /> + ); + } + const bridgeSupportsKeepAwake = typeof keepAwakeBridge?.getKeepAwakeState === "function" && typeof keepAwakeBridge?.setKeepAwakeEnabled === "function"; @@ -124,7 +150,7 @@ export function KeepAwakeRow({ void updateKeepAwake(enabled)} /> From b23d512676304922c14650813bbcc06399287200 Mon Sep 17 00:00:00 2001 From: vishal Date: Thu, 24 Sep 2026 16:56:12 +0530 Subject: [PATCH 4/4] fix(desktop): atomic recovery writes, retryable crash recovery --- apps/desktop/src/power/DesktopKeepAwake.ts | 54 ++++++++++++++++------ 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/power/DesktopKeepAwake.ts b/apps/desktop/src/power/DesktopKeepAwake.ts index 2f33dc02013b..4e59d493017a 100644 --- a/apps/desktop/src/power/DesktopKeepAwake.ts +++ b/apps/desktop/src/power/DesktopKeepAwake.ts @@ -378,10 +378,20 @@ const writeRecoveryFile = (file: string | null, saved: KeepAwakeSavedByScheme): if (file === null) { return; } + // Atomic update: a kill between truncate and write must never leave a + // corrupt file behind while schemes stay forced — a failed replacement + // leaves the previous file (if any) untouched. + const tmp = `${file}.tmp`; try { - NodeFs.writeFileSync(file, JSON.stringify(Object.fromEntries(saved)), "utf8"); + NodeFs.writeFileSync(tmp, JSON.stringify(Object.fromEntries(saved)), "utf8"); + NodeFs.renameSync(tmp, file); } catch { // Best-effort: without the file we just lose crash recovery for this session. + try { + NodeFs.rmSync(tmp, { force: true }); + } catch { + // Ignore cleanup failures; a stale tmp file is harmless (never read). + } } }; @@ -434,24 +444,29 @@ const sanitizeSavedByScheme = (input: unknown): KeepAwakeSavedByScheme | null => }; /** - * Best-effort restore for a previous session that died while enabled. - * Returns true when a recovery file existed (so the caller logs it). + * Outcome of the best-effort restore for a previous session that died + * while enabled. "recovered": a file existed and was handled (restored + * and/or deleted). "absent": no file — nothing to do. "retry": no + * conclusive answer (unavailable path, transient fs error) — the caller + * leaves its done-flag clear so a later call tries again. */ +type KeepAwakeRecoveryOutcome = "recovered" | "absent" | "retry"; + const recoverPreviousSession = ( runPowercfg: (args: ReadonlyArray) => string, recoveryFile: string | null, -): boolean => { +): KeepAwakeRecoveryOutcome => { if (recoveryFile === null) { - return false; + return "retry"; } let found = false; try { found = NodeFs.existsSync(recoveryFile); } catch { - return false; + return "retry"; } if (!found) { - return false; + return "absent"; } try { const saved = sanitizeSavedByScheme( @@ -464,7 +479,7 @@ const recoverPreviousSession = ( // Corrupt file: fall through to delete + log below. } deleteRecoveryFile(recoveryFile); - return true; + return "recovered"; }; /** @@ -554,14 +569,25 @@ export const make = ( // setPath. Idempotent — runs at most once per process. const recoveryChecked = yield* Ref.make(false); const ensureRecoveryChecked: Effect.Effect = Effect.gen(function* () { - const done = yield* Ref.getAndSet(recoveryChecked, true); - if (done || !isWindows) { + if (!isWindows) { return; } - const recovered = yield* Effect.sync(() => - recoverPreviousSession(runPowercfg, getRecoveryFile()), - ); - if (recovered) { + const done = yield* Ref.get(recoveryChecked); + if (done) { + return; + } + const file = getRecoveryFile(); + if (file === null) { + // userData unavailable this early — leave the flag clear so a later + // call retries once the path exists. + return; + } + const outcome = yield* Effect.sync(() => recoverPreviousSession(runPowercfg, file)); + if (outcome === "retry") { + return; + } + yield* Ref.set(recoveryChecked, true); + if (outcome === "recovered") { yield* Effect.logWarning( "[keep-awake] Restored power settings left behind by a previous session and removed the recovery file.", );