From 6ff0d60c2846f31c7a070849d5d15093924dd608 Mon Sep 17 00:00:00 2001 From: vishal Date: Sun, 20 Sep 2026 09:23:37 +0530 Subject: [PATCH 1/2] 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 3e37d12aa52d12cc575e300ed8186c885d3b7876 Mon Sep 17 00:00:00 2001 From: vishal Date: Sun, 20 Sep 2026 09:23:51 +0530 Subject: [PATCH 2/2] feat(web): Esc stops the running agent turn --- apps/web/src/components/ChatView.tsx | 8 +++++ .../KeybindingsSettings.logic.test.ts | 3 +- .../settings/settingsSearch.test.ts | 12 +++++-- apps/web/src/keybindings.test.ts | 32 +++++++++++++++++++ packages/shared/src/keybindings.ts | 1 + 5 files changed, 53 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 79d393fc8d1f..00399fe539bb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -175,6 +175,7 @@ import { import { useTheme } from "../hooks/useTheme"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isCommandPaletteOpen } from "../commandPaletteBus"; +import { isContextMenuOpen } from "../contextMenuFallback"; import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; @@ -6887,6 +6888,13 @@ export default function ChatView(props: ChatViewProps) { if (command === "thread.stop") { // An unavailable command should not shadow contextual shortcuts such as Escape to close a dialog. if (!canInterruptRunningThread) return; + // Esc still closes the topmost dialog/menu first: only stop when + // nothing dismissible is open. + if (event.key === "Escape") { + if (isContextMenuOpen()) return; + if (composerRef.current?.isModelPickerOpen()) return; + if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return; + } event.preventDefault(); event.stopPropagation(); if (event.repeat) return; diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 10fc154d5b0c..948a0b05ed0e 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -29,6 +29,7 @@ describe("KeybindingsSettings.logic", () => { "modelPicker.previousProvider", "modelPicker.nextProvider", "thread.copyReference", + "thread.stop", "pullRequest.copyNumber", ]) { expect(rows.find((row) => row.command === command)).toMatchObject({ @@ -247,7 +248,7 @@ describe("KeybindingsSettings.logic", () => { ]), ); expect(DEFAULT_RESOLVED_KEYBINDINGS.some((binding) => binding.command === "thread.stop")).toBe( - false, + true, ); }); diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 569d736a62de..5defda7c1d7f 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -235,13 +235,21 @@ describe("searchSettings", () => { }); it("sends commands without a default binding to the section", () => { - expect(searchSettings("thread.stop")[0]).toMatchObject({ - id: "keybinding-thread.stop", + expect(searchSettings("rightPanel.toggleMaximized")[0]).toMatchObject({ + id: "keybinding-rightPanel.toggleMaximized", targetId: "keybindings", }); expect(searchSettings("sidebar.toggle")[0]?.targetId).toBeUndefined(); }); + it("routes thread.stop to its default binding", () => { + expect(searchSettings("thread.stop")[0]).toMatchObject({ + id: "keybinding-thread.stop", + }); + expect(searchSettings("thread.stop")[0]?.targetId).toBeUndefined(); + expect(searchSettings("esc").map((item) => item.id)).toContain("keybinding-thread.stop"); + }); + it("keeps catalog result ids unique", () => { const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id); expect(new Set(ids).size).toBe(ids.length); diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index d9c3eccbeb4e..0101145fc25f 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -226,6 +226,38 @@ describe("settle thread shortcut", () => { }); }); +describe("stop thread shortcut", () => { + it("resolves Esc outside the terminal", () => { + assert.equal( + resolveShortcutCommand(event({ key: "Escape" }), DEFAULT_RESOLVED_KEYBINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "thread.stop", + ); + }); + + it("does not intercept the terminal", () => { + assert.isNull( + resolveShortcutCommand(event({ key: "Escape" }), DEFAULT_RESOLVED_KEYBINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + ); + }); + + it("labels the stop shortcut as Esc", () => { + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_RESOLVED_KEYBINDINGS, "thread.stop", "MacIntel"), + "Esc", + ); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_RESOLVED_KEYBINDINGS, "thread.stop", "Win32"), + "Esc", + ); + }); +}); + describe("copy thread reference shortcut", () => { it("resolves Cmd+Shift+C on macOS and Ctrl+Shift+C elsewhere", () => { assert.equal( diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index d76d4c248a97..1935957a8839 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -62,6 +62,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+c", command: "thread.copyReference", when: "!terminalFocus" }, { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" }, { key: "mod+shift+p", command: "thread.pin", when: "!terminalFocus" }, + { key: "esc", command: "thread.stop", when: "!terminalFocus" }, ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ key: `mod+${index + 1}`, command,