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..4e59d493017a --- /dev/null +++ b/apps/desktop/src/power/DesktopKeepAwake.ts @@ -0,0 +1,811 @@ +// @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 Semaphore from "effect/Semaphore"; + +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 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, 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` 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. + */ +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; +} + +/** + * 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"; + +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; +}; + +// 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, 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, 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, sub, setting, ac.toString()]); + } + if (dc !== undefined) { + runPowercfg(["/setdcvalueindex", scheme, sub, setting, dc.toString()]); + } +}; + +// 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 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, 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]); +}; + +// 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 => { + 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: KeepAwakeSavedByScheme): void => { + 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(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). + } + } +}; + +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"), + }; +}; + +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; +}; + +/** + * 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, +): KeepAwakeRecoveryOutcome => { + if (recoveryFile === null) { + return "retry"; + } + let found = false; + try { + found = NodeFs.existsSync(recoveryFile); + } catch { + return "retry"; + } + if (!found) { + return "absent"; + } + try { + const saved = sanitizeSavedByScheme( + JSON.parse(NodeFs.readFileSync(recoveryFile, "utf8")) as unknown, + ); + if (saved !== null) { + restoreAllSchemes(runPowercfg, saved); + } + } catch { + // Corrupt file: fall through to delete + log below. + } + deleteRecoveryFile(recoveryFile); + return "recovered"; +}; + +/** + * 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()); + // 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* () { + if (!isWindows) { + return; + } + 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.", + ); + } + }); + + // 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 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)); + }); + + // 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 + // the guard, delete the recovery file, release the execution state. + // Callers must hold the transition mutex. + const restoreWindowsPower: Effect.Effect = Effect.gen(function* () { + const saved = yield* Ref.getAndSet(savedByScheme, new Map()); + 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)); + }); + + // Re-assert while enabled: something else (or the user) can rewrite the + // 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); + 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 readState: Effect.Effect = Effect.gen(function* () { + const current = yield* Ref.get(currentId); + return { + enabled: supported && Option.isSome(current), + supported, + blockerType: KEEP_AWAKE_BLOCKER_TYPE, + }; + }); + + 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; + } + if (!enabled) { + yield* stopCurrent; + if (isWindows) { + yield* restoreWindowsPower; + } + return yield* readState; + } + 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(savedByScheme); + if (saved.size === 0) { + const forced = yield* enableWindowsPower; + if (!forced) { + yield* restoreWindowsPower; + } + } + } + // Idempotent re-enable: keep holding the live blocker. + return yield* readState; + } + // 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) { + 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 { + // 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* 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, () => + transitionMutex + .withPermits(1)( + Effect.gen(function* () { + yield* stopCurrent; + if (isWindows) { + yield* restoreWindowsPower; + } + }), + ) + .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..d8a4f20d37e8 --- /dev/null +++ b/apps/web/src/components/settings/KeepAwakeRow.tsx @@ -0,0 +1,160 @@ +import { useState } from "react"; + +import { useEnvironmentQuery } from "~/state/query"; +import { + keepAwakeStateAtom, + refreshKeepAwakeState, + 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"; +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; + + // 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"; + 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;