feat(desktop): prevent sleep while instances busy
This commit is contained in:
@@ -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"
|
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||||
|
|
||||||
|
let wakeLockId: number | null = null
|
||||||
|
|
||||||
interface DialogOpenRequest {
|
interface DialogOpenRequest {
|
||||||
mode: "directory" | "file"
|
mode: "directory" | "file"
|
||||||
title?: string
|
title?: string
|
||||||
@@ -62,4 +64,31 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
|||||||
|
|
||||||
return { canceled: result.canceled, paths: result.filePaths }
|
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 }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const electronAPI = {
|
|||||||
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
||||||
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
||||||
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
||||||
|
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||||
}
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||||
|
|||||||
778
packages/tauri-app/Cargo.lock
generated
778
packages/tauri-app/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -22,3 +22,4 @@ tauri-plugin-dialog = "2"
|
|||||||
dirs = "5"
|
dirs = "5"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
url = "2"
|
url = "2"
|
||||||
|
tauri-plugin-keepawake = "0.1.1"
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -2378,6 +2378,36 @@
|
|||||||
"const": "dialog:deny-save",
|
"const": "dialog:deny-save",
|
||||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
"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`",
|
"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",
|
"type": "string",
|
||||||
|
|||||||
@@ -2378,6 +2378,36 @@
|
|||||||
"const": "dialog:deny-save",
|
"const": "dialog:deny-save",
|
||||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
"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`",
|
"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",
|
"type": "string",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
|
|||||||
Ok(state.manager.status())
|
Ok(state.manager.status())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn is_dev_mode() -> bool {
|
fn is_dev_mode() -> bool {
|
||||||
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
|
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
|
||||||
}
|
}
|
||||||
@@ -73,6 +74,7 @@ fn main() {
|
|||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.plugin(tauri_plugin_keepawake::init())
|
||||||
.plugin(navigation_guard)
|
.plugin(navigation_guard)
|
||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
manager: CliProcessManager::new(),
|
manager: CliProcessManager::new(),
|
||||||
|
|||||||
@@ -27,7 +27,8 @@
|
|||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"shiki": "^3.13.0",
|
"shiki": "^3.13.0",
|
||||||
"solid-js": "^1.8.0",
|
"solid-js": "^1.8.0",
|
||||||
"solid-toast": "^0.5.0"
|
"solid-toast": "^0.5.0",
|
||||||
|
"tauri-plugin-keepawake-api": "^0.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vite-pwa/assets-generator": "^1.0.2",
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { getLogger } from "./lib/logger"
|
|||||||
import { initReleaseNotifications } from "./stores/releases"
|
import { initReleaseNotifications } from "./stores/releases"
|
||||||
import { runtimeEnv } from "./lib/runtime-env"
|
import { runtimeEnv } from "./lib/runtime-env"
|
||||||
import { useI18n } from "./lib/i18n"
|
import { useI18n } from "./lib/i18n"
|
||||||
|
import { setWakeLockDesired } from "./lib/native/wake-lock"
|
||||||
import {
|
import {
|
||||||
hasInstances,
|
hasInstances,
|
||||||
isSelectingFolder,
|
isSelectingFolder,
|
||||||
@@ -48,6 +49,8 @@ import {
|
|||||||
updateSessionModel,
|
updateSessionModel,
|
||||||
} from "./stores/sessions"
|
} from "./stores/sessions"
|
||||||
|
|
||||||
|
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
const App: Component = () => {
|
const App: Component = () => {
|
||||||
@@ -91,6 +94,26 @@ const App: Component = () => {
|
|||||||
initReleaseNotifications()
|
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(() => {
|
createEffect(() => {
|
||||||
instances()
|
instances()
|
||||||
hasInstances()
|
hasInstances()
|
||||||
|
|||||||
158
packages/ui/src/lib/native/wake-lock.ts
Normal file
158
packages/ui/src/lib/native/wake-lock.ts
Normal 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!
|
||||||
|
}
|
||||||
2
packages/ui/src/types/global.d.ts
vendored
2
packages/ui/src/types/global.d.ts
vendored
@@ -26,6 +26,7 @@ declare global {
|
|||||||
onCliError?: (callback: (data: unknown) => void) => () => void
|
onCliError?: (callback: (data: unknown) => void) => () => void
|
||||||
getCliStatus?: () => Promise<unknown>
|
getCliStatus?: () => Promise<unknown>
|
||||||
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult>
|
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult>
|
||||||
|
setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TauriDialogModule {
|
interface TauriDialogModule {
|
||||||
@@ -47,4 +48,3 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
10
packages/ui/src/types/tauri-plugin-keepawake-api.d.ts
vendored
Normal file
10
packages/ui/src/types/tauri-plugin-keepawake-api.d.ts
vendored
Normal 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>
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user