feat(desktop): prevent sleep while instances busy

This commit is contained in:
Shantur Rathore
2026-02-07 22:53:46 +00:00
parent 6b81d0d703
commit 5b4edef785
13 changed files with 1007 additions and 64 deletions

View File

@@ -1,6 +1,8 @@
import { BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron"
import { BrowserWindow, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
import type { CliProcessManager, CliStatus } from "./process-manager"
let wakeLockId: number | null = null
interface DialogOpenRequest {
mode: "directory" | "file"
title?: string
@@ -62,4 +64,31 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
return { canceled: result.canceled, paths: result.filePaths }
})
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
const next = Boolean(enabled)
if (next) {
if (wakeLockId !== null && powerSaveBlocker.isStarted(wakeLockId)) {
return { enabled: true }
}
try {
wakeLockId = powerSaveBlocker.start("prevent-display-sleep")
} catch {
wakeLockId = null
return { enabled: false }
}
return { enabled: true }
}
if (wakeLockId !== null) {
try {
if (powerSaveBlocker.isStarted(wakeLockId)) {
powerSaveBlocker.stop(wakeLockId)
}
} finally {
wakeLockId = null
}
}
return { enabled: false }
})
}

View File

@@ -12,6 +12,7 @@ const electronAPI = {
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
restartCli: () => ipcRenderer.invoke("cli:restart"),
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
}
contextBridge.exposeInMainWorld("electronAPI", electronAPI)

File diff suppressed because it is too large Load Diff

View File

@@ -22,3 +22,4 @@ tauri-plugin-dialog = "2"
dirs = "5"
tauri-plugin-opener = "2"
url = "2"
tauri-plugin-keepawake = "0.1.1"

File diff suppressed because one or more lines are too long

View File

@@ -2378,6 +2378,36 @@
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
"type": "string",
"const": "keepawake:default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
},
{
"description": "Enables the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-start",
"markdownDescription": "Enables the start command without any pre-configured scope."
},
{
"description": "Enables the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-stop",
"markdownDescription": "Enables the stop command without any pre-configured scope."
},
{
"description": "Denies the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-start",
"markdownDescription": "Denies the start command without any pre-configured scope."
},
{
"description": "Denies the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-stop",
"markdownDescription": "Denies the stop command without any pre-configured scope."
},
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",

View File

@@ -2378,6 +2378,36 @@
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
"type": "string",
"const": "keepawake:default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
},
{
"description": "Enables the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-start",
"markdownDescription": "Enables the start command without any pre-configured scope."
},
{
"description": "Enables the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-stop",
"markdownDescription": "Enables the stop command without any pre-configured scope."
},
{
"description": "Denies the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-start",
"markdownDescription": "Denies the start command without any pre-configured scope."
},
{
"description": "Denies the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-stop",
"markdownDescription": "Denies the stop command without any pre-configured scope."
},
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",

View File

@@ -35,6 +35,7 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
Ok(state.manager.status())
}
fn is_dev_mode() -> bool {
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
}
@@ -73,6 +74,7 @@ fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_keepawake::init())
.plugin(navigation_guard)
.manage(AppState {
manager: CliProcessManager::new(),

View File

@@ -27,7 +27,8 @@
"qrcode": "^1.5.3",
"shiki": "^3.13.0",
"solid-js": "^1.8.0",
"solid-toast": "^0.5.0"
"solid-toast": "^0.5.0",
"tauri-plugin-keepawake-api": "^0.1.0"
},
"devDependencies": {
"@vite-pwa/assets-generator": "^1.0.2",

View File

@@ -19,6 +19,7 @@ import { getLogger } from "./lib/logger"
import { initReleaseNotifications } from "./stores/releases"
import { runtimeEnv } from "./lib/runtime-env"
import { useI18n } from "./lib/i18n"
import { setWakeLockDesired } from "./lib/native/wake-lock"
import {
hasInstances,
isSelectingFolder,
@@ -48,6 +49,8 @@ import {
updateSessionModel,
} from "./stores/sessions"
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
const log = getLogger("actions")
const App: Component = () => {
@@ -91,6 +94,26 @@ const App: Component = () => {
initReleaseNotifications()
})
const shouldHoldWakeLock = createMemo(() => {
const map = instances()
for (const id of map.keys()) {
const status = getInstanceSessionIndicatorStatus(id)
if (status !== "idle") {
return true
}
}
return false
})
createEffect(() => {
const hold = shouldHoldWakeLock()
void setWakeLockDesired(hold)
})
onCleanup(() => {
void setWakeLockDesired(false)
})
createEffect(() => {
instances()
hasInstances()

View File

@@ -0,0 +1,158 @@
import { runtimeEnv } from "../runtime-env"
import { getLogger } from "../logger"
const log = getLogger("actions")
let desired = false
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 (runtimeEnv.host === "electron") {
const api = (window as any).electronAPI
if (api?.setWakeLock) return true
}
if (runtimeEnv.host === "tauri") {
// We'll attempt dynamic import; treat as potentially supported.
return true
}
return Boolean((navigator as any)?.wakeLock?.request)
}
async function setElectronWakeLock(enabled: boolean): Promise<boolean> {
const api = (window as typeof window & { electronAPI?: { setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }> } })
.electronAPI
if (!api?.setWakeLock) {
return false
}
try {
const result = await api.setWakeLock(Boolean(enabled))
return Boolean(result?.enabled)
} catch (error) {
log.log("[wake-lock] electron wake lock failed", error)
return false
}
}
async function setTauriWakeLock(enabled: boolean): Promise<boolean> {
try {
const mod = await import("tauri-plugin-keepawake-api")
const start = (mod as any).start as ((config?: any) => Promise<void>) | undefined
const stop = (mod as any).stop as (() => Promise<void>) | undefined
if (!start || !stop) {
return false
}
if (enabled) {
// Plugin config supports toggling display/idle/sleep. Use a conservative
// default to keep both system + display awake.
await start({ display: true, idle: true, sleep: true })
return true
}
await stop()
return false
} catch (error) {
log.log("[wake-lock] tauri wake lock failed", error)
return false
}
}
async function applyWakeLock(enabled: boolean): Promise<boolean> {
if (typeof window === "undefined") return false
if (runtimeEnv.host === "electron") {
const ok = await setElectronWakeLock(enabled)
if (ok || !enabled) return ok
// fallback to web API if electron preload didn't expose it
}
if (runtimeEnv.host === "tauri") {
const ok = await setTauriWakeLock(enabled)
if (ok || !enabled) return ok
// fallback to web API if tauri command isn't available
}
return await setWebWakeLock(enabled)
}
export function setWakeLockDesired(nextDesired: boolean): Promise<boolean> {
desired = Boolean(nextDesired)
if (inFlight) {
// Coalesce: once the current request resolves, it will re-apply the latest desired state.
return inFlight
}
const target = desired
inFlight = (async () => {
try {
const ok = await applyWakeLock(target)
// Treat disable attempts as applied even if the underlying API doesn't exist.
applied = target
return ok
} finally {
inFlight = null
// If desired changed while in-flight, re-apply once.
if (desired !== applied) {
void setWakeLockDesired(desired)
}
// If we tried to enable but there is no support, avoid re-trying forever.
if (desired && !hasAnyWakeLockSupport()) {
applied = false
}
}
})()
return inFlight!
}

View File

@@ -26,6 +26,7 @@ declare global {
onCliError?: (callback: (data: unknown) => void) => () => void
getCliStatus?: () => Promise<unknown>
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult>
setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }>
}
interface TauriDialogModule {
@@ -47,4 +48,3 @@ declare global {
}
}

View File

@@ -0,0 +1,10 @@
declare module "tauri-plugin-keepawake-api" {
export interface KeepAwakeConfig {
display?: boolean
idle?: boolean
sleep?: boolean
}
export function start(config?: KeepAwakeConfig): Promise<void>
export function stop(): Promise<void>
}