fix(wake-lock): allow display sleep during active work

Prevent idle system sleep on supported desktop runtimes without intentionally keeping the display awake. Narrow wake-lock activation to true active work states and drop the web screen-wake fallback where the platform cannot provide system-sleep-only behavior.
This commit is contained in:
Shantur Rathore
2026-04-21 20:58:40 +01:00
parent 1c317df6c0
commit 4a1147788c
9 changed files with 77 additions and 61 deletions

View File

@@ -0,0 +1,17 @@
# Wake Lock Behavior
## Product Rule
CodeNomad only requests a wake lock for qualifying active work that is already running and can continue without continuous foreground interaction. The goal is to prevent idle system sleep where the platform supports that behavior without intentionally keeping the display awake.
Wake lock must not be held when work is idle, paused, completed, cancelled, failed, or waiting for new user input or permission before it can continue.
## Platform Behavior
- **Electron:** request system-sleep-only behavior with `prevent-app-suspension`.
- **Tauri:** request the native keep-awake mode with `display: false`, `idle: true`, and `sleep: false`.
- **Web:** do not fall back to `navigator.wakeLock.request("screen")`; if a true system-sleep-only primitive is unavailable, CodeNomad degrades to no wake lock.
## Release Expectations
Wake lock should be released promptly when qualifying active work ends or when the app cleans up the active session lifecycle.

View File

@@ -92,7 +92,7 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
return { enabled: true }
}
try {
wakeLockId = powerSaveBlocker.start("prevent-display-sleep")
wakeLockId = powerSaveBlocker.start("prevent-app-suspension")
} catch {
wakeLockId = null
return { enabled: false }

View File

@@ -145,8 +145,8 @@ fn wake_lock_start(
config: Option<WakeLockConfig>,
) -> Result<(), String> {
let config = config.unwrap_or(WakeLockConfig {
display: true,
idle: false,
display: false,
idle: true,
sleep: false,
});

View File

@@ -50,7 +50,7 @@ import {
updateSessionModel,
} from "./stores/sessions"
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
import { hasWakeLockEligibleWork } from "./stores/session-status"
import { openSettings } from "./stores/settings-screen"
import {
closeSidecarTab,
@@ -204,8 +204,7 @@ const App: Component = () => {
const shouldHoldWakeLock = createMemo(() => {
const map = instances()
for (const id of map.keys()) {
const status = getInstanceSessionIndicatorStatus(id)
if (status !== "idle") {
if (hasWakeLockEligibleWork(id)) {
return true
}
}

View File

@@ -58,7 +58,9 @@ export function extractDiagnostics(state: ToolState | undefined): DiagnosticEntr
const diagnosticsMap = metadata?.diagnostics as DiagnosticsMap | undefined
if (!diagnosticsMap) return []
return buildDiagnosticEntries(diagnosticsMap, [input.filePath, metadata.filePath, metadata.filepath, input.path])
return buildDiagnosticEntries(diagnosticsMap, [input.filePath, metadata.filePath, metadata.filepath, input.path].map((value) =>
typeof value === "string" ? value : undefined,
))
}
export function resolveDiagnosticsKey(diagnostics: DiagnosticsMap, preferredPaths: Array<string | undefined>): string | undefined {

View File

@@ -9,51 +9,6 @@ let inFlight: Promise<boolean> | null = null
let applied = false
let webWakeLock: any = null
async function setWebWakeLock(enabled: boolean): Promise<boolean> {
if (typeof navigator === "undefined") return false
const api = (navigator as any).wakeLock
if (!api?.request) {
return false
}
try {
if (enabled) {
if (webWakeLock) {
return true
}
webWakeLock = await api.request("screen")
try {
webWakeLock.addEventListener?.("release", () => {
// If the lock is released by the UA (e.g., tab hidden), clear local state.
webWakeLock = null
if (desired) {
// Re-acquire best-effort.
queueMicrotask(() => {
void setWakeLockDesired(true)
})
}
})
} catch {
// optional
}
return true
}
if (webWakeLock) {
await webWakeLock.release?.()
}
webWakeLock = null
return false
} catch (error) {
log.log("[wake-lock] web wake lock failed", error)
webWakeLock = null
return false
}
}
function hasAnyWakeLockSupport(): boolean {
if (typeof window === "undefined") return false
if (isElectronHost()) {
@@ -63,7 +18,7 @@ function hasAnyWakeLockSupport(): boolean {
if (isTauriHost()) {
return typeof window.__TAURI__?.core?.invoke === "function"
}
return Boolean((navigator as any)?.wakeLock?.request)
return false
}
async function setElectronWakeLock(enabled: boolean): Promise<boolean> {
@@ -89,9 +44,7 @@ async function setTauriWakeLock(enabled: boolean): Promise<boolean> {
}
if (enabled) {
// Match Electron's prevent-display-sleep behavior by keeping the display
// awake without blocking explicit system sleep requests.
await invoke("wake_lock_start", { config: { display: true, idle: false, sleep: false } })
await invoke("wake_lock_start", { config: { display: false, idle: true, sleep: false } })
return true
}
@@ -108,17 +61,15 @@ async function applyWakeLock(enabled: boolean): Promise<boolean> {
if (isElectronHost()) {
const ok = await setElectronWakeLock(enabled)
if (ok || !enabled) return ok
// fallback to web API if electron preload didn't expose it
return ok
}
if (isTauriHost()) {
const ok = await setTauriWakeLock(enabled)
if (ok || !enabled) return ok
// fallback to web API if tauri command isn't available
return ok
}
return await setWebWakeLock(enabled)
return false
}
export function setWakeLockDesired(nextDesired: boolean): Promise<boolean> {

View File

@@ -0,0 +1,20 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { shouldSessionHoldWakeLock } from "./wake-lock-eligibility.ts"
describe("shouldSessionHoldWakeLock", () => {
it("holds wake lock only for qualifying active work", () => {
assert.equal(shouldSessionHoldWakeLock({ status: "working", pendingPermission: false, pendingQuestion: false }), true)
assert.equal(
shouldSessionHoldWakeLock({ status: "compacting", pendingPermission: false, pendingQuestion: false }),
true,
)
assert.equal(shouldSessionHoldWakeLock({ status: "idle", pendingPermission: false, pendingQuestion: false }), false)
})
it("does not hold wake lock while waiting for permission or input", () => {
assert.equal(shouldSessionHoldWakeLock({ status: "working", pendingPermission: true, pendingQuestion: false }), false)
assert.equal(shouldSessionHoldWakeLock({ status: "working", pendingPermission: false, pendingQuestion: true }), false)
})
})

View File

@@ -1,11 +1,27 @@
import type { Session, SessionRetryState, SessionStatus } from "../types/session"
import { getInstanceSessionIndicatorStatusCached, sessions } from "./session-state"
import { shouldSessionHoldWakeLock } from "./wake-lock-eligibility"
function getSession(instanceId: string, sessionId: string): Session | null {
const instanceSessions = sessions().get(instanceId)
return instanceSessions?.get(sessionId) ?? null
}
export function hasWakeLockEligibleWork(instanceId: string): boolean {
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) {
return false
}
for (const session of instanceSessions.values()) {
if (shouldSessionHoldWakeLock(session)) {
return true
}
}
return false
}
export function getSessionStatus(instanceId: string, sessionId: string): SessionStatus {
const session = getSession(instanceId, sessionId)
if (!session) {

View File

@@ -0,0 +1,11 @@
import type { Session } from "../types/session"
export function shouldSessionHoldWakeLock(
session: Pick<Session, "status" | "pendingPermission" | "pendingQuestion">,
): boolean {
if (session.pendingPermission || session.pendingQuestion) {
return false
}
return session.status === "working" || session.status === "compacting"
}