Merge pull request #68140 from NousResearch/bb/desktop-keep-awake
feat(desktop): keep-computer-awake toggle
This commit is contained in:
commit
67e73ae958
|
|
@ -20,6 +20,7 @@ import {
|
|||
nativeTheme,
|
||||
Notification,
|
||||
powerMonitor,
|
||||
powerSaveBlocker,
|
||||
protocol,
|
||||
safeStorage,
|
||||
screen,
|
||||
|
|
@ -105,6 +106,7 @@ import {
|
|||
import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window'
|
||||
import { ensureMainWindow } from './main-window-lifecycle'
|
||||
import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
|
||||
import { createKeepAwake } from './power-save'
|
||||
import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing'
|
||||
import {
|
||||
buildSessionWindowUrl,
|
||||
|
|
@ -8643,6 +8645,33 @@ ipcMain.on('hermes:translucency', (_event, payload) => {
|
|||
}
|
||||
})
|
||||
|
||||
// Keep-awake: hold the machine awake for long/overnight runs. Main owns the one
|
||||
// blocker and its persisted state so a cold launch restores it (applied on
|
||||
// ready — powerSaveBlocker needs the app ready). The renderer toggles it from
|
||||
// Settings → Advanced over IPC. See store/keep-awake.
|
||||
const KEEP_AWAKE_CONFIG_PATH = path.join(app.getPath('userData'), 'keep-awake.json')
|
||||
const keepAwake = createKeepAwake(powerSaveBlocker)
|
||||
|
||||
function readPersistedKeepAwake() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(KEEP_AWAKE_CONFIG_PATH, 'utf8')).on === true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.on('hermes:keep-awake', (_event, on) => {
|
||||
const enabled = Boolean(on)
|
||||
keepAwake.set(enabled)
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(KEEP_AWAKE_CONFIG_PATH), { recursive: true })
|
||||
fs.writeFileSync(KEEP_AWAKE_CONFIG_PATH, JSON.stringify({ on: enabled }, null, 2), 'utf8')
|
||||
} catch (error) {
|
||||
rememberLog(`[keep-awake] write failed: ${error.message}`)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:openExternal', (_event, url) => {
|
||||
if (!openExternalUrl(url)) {
|
||||
throw new Error('Invalid external URL')
|
||||
|
|
@ -9643,6 +9672,7 @@ app.whenReady().then(() => {
|
|||
ensureWslWindowsFonts()
|
||||
configureSpellChecker()
|
||||
registerPowerResumeListeners()
|
||||
keepAwake.set(readPersistedKeepAwake())
|
||||
createWindow()
|
||||
|
||||
// Win/Linux cold start: the launching hermes:// URL is in our own argv.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createKeepAwake, type PowerSaveBlockerLike } from './power-save'
|
||||
|
||||
function fakeBlocker() {
|
||||
let next = 1
|
||||
const started = new Set<number>()
|
||||
|
||||
const blocker: PowerSaveBlockerLike = {
|
||||
isStarted: id => started.has(id),
|
||||
start: vi.fn(() => {
|
||||
const id = next++
|
||||
started.add(id)
|
||||
|
||||
return id
|
||||
}),
|
||||
stop: vi.fn(id => void started.delete(id))
|
||||
}
|
||||
|
||||
return { blocker, started }
|
||||
}
|
||||
|
||||
describe('createKeepAwake', () => {
|
||||
it('starts once, is idempotent, and stops', () => {
|
||||
const { blocker } = fakeBlocker()
|
||||
const keepAwake = createKeepAwake(blocker)
|
||||
|
||||
expect(keepAwake.isActive()).toBe(false)
|
||||
expect(keepAwake.set(true)).toBe(true)
|
||||
keepAwake.set(true) // idempotent — no second blocker
|
||||
expect(blocker.start).toHaveBeenCalledTimes(1)
|
||||
expect(blocker.start).toHaveBeenCalledWith('prevent-app-suspension')
|
||||
|
||||
expect(keepAwake.set(false)).toBe(false)
|
||||
keepAwake.set(false)
|
||||
expect(blocker.stop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('re-arms after the OS dropped the blocker', () => {
|
||||
const { blocker, started } = fakeBlocker()
|
||||
const keepAwake = createKeepAwake(blocker)
|
||||
|
||||
keepAwake.set(true)
|
||||
started.clear() // system released it out from under us
|
||||
expect(keepAwake.isActive()).toBe(false)
|
||||
|
||||
keepAwake.set(true)
|
||||
expect(blocker.start).toHaveBeenCalledTimes(2)
|
||||
expect(keepAwake.isActive()).toBe(true)
|
||||
})
|
||||
|
||||
it('honors a custom blocker type', () => {
|
||||
const { blocker } = fakeBlocker()
|
||||
createKeepAwake(blocker, 'prevent-display-sleep').set(true)
|
||||
|
||||
expect(blocker.start).toHaveBeenCalledWith('prevent-display-sleep')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Keep-awake — hold a single machine-global power-save blocker.
|
||||
*
|
||||
* `prevent-app-suspension` stops the system from sleeping (long overnight
|
||||
* agent runs keep going) while still letting the display dim. The renderer
|
||||
* owns the preference (persisted in localStorage) and mirrors it here over
|
||||
* IPC; the main process owns the one native blocker, same authority split as
|
||||
* translucency/zoom. Electron auto-releases the blocker on quit.
|
||||
*/
|
||||
|
||||
export type KeepAwakeType = 'prevent-app-suspension' | 'prevent-display-sleep'
|
||||
|
||||
/** The slice of Electron's `powerSaveBlocker` we use (injected for testing). */
|
||||
export interface PowerSaveBlockerLike {
|
||||
start(type: KeepAwakeType): number
|
||||
stop(id: number): void
|
||||
isStarted(id: number): boolean
|
||||
}
|
||||
|
||||
export interface KeepAwake {
|
||||
/** Turn the blocker on/off (idempotent). Returns the resulting state. */
|
||||
set(on: boolean): boolean
|
||||
isActive(): boolean
|
||||
}
|
||||
|
||||
export function createKeepAwake(
|
||||
blocker: PowerSaveBlockerLike,
|
||||
type: KeepAwakeType = 'prevent-app-suspension'
|
||||
): KeepAwake {
|
||||
let id: null | number = null
|
||||
|
||||
const isActive = () => id !== null && blocker.isStarted(id)
|
||||
|
||||
return {
|
||||
isActive,
|
||||
set(on) {
|
||||
if (on && !isActive()) {
|
||||
id = blocker.start(type)
|
||||
} else if (!on && id !== null) {
|
||||
if (blocker.isStarted(id)) {
|
||||
blocker.stop(id)
|
||||
}
|
||||
|
||||
id = null
|
||||
}
|
||||
|
||||
return isActive()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -79,6 +79,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
setTitleBarTheme: payload => ipcRenderer.send('hermes:titlebar-theme', payload),
|
||||
setNativeTheme: mode => ipcRenderer.send('hermes:native-theme', mode),
|
||||
setTranslucency: payload => ipcRenderer.send('hermes:translucency', payload),
|
||||
setKeepAwake: on => ipcRenderer.send('hermes:keep-awake', on),
|
||||
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
||||
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
||||
openPreviewInBrowser: url => ipcRenderer.invoke('hermes:openPreviewInBrowser', url),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
|
@ -6,6 +7,7 @@ import { useSearchParams } from 'react-router-dom'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { $keepAwake, setKeepAwake } from '@/store/keep-awake'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
|
|
@ -18,7 +20,7 @@ import { enumOptionsFor, getNested, isExternalMemoryProvider, sectionFieldEntrie
|
|||
import { MemoryConnect } from './memory/connect'
|
||||
import { ProviderConfigPanel } from './memory/provider-config-panel'
|
||||
import { ModelSettings, ModelSettingsSkeleton } from './model-settings'
|
||||
import { EmptyState, LoadingState, SettingsContent } from './primitives'
|
||||
import { EmptyState, LoadingState, SettingsContent, ToggleRow } from './primitives'
|
||||
|
||||
// On the Voice page, only surface the sub-fields of the *selected* TTS/STT
|
||||
// provider — otherwise every provider's options render at once (the "totally
|
||||
|
|
@ -53,6 +55,7 @@ export function ConfigSettings({
|
|||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.settings.config
|
||||
const keepAwake = useStore($keepAwake)
|
||||
// The editable draft is local (debounced autosave watches it), but it's seeded
|
||||
// from — and saved back through — the shared config cache, so edits are visible
|
||||
// in the MCP/model surfaces and reopening the page doesn't reload-flash.
|
||||
|
|
@ -269,6 +272,11 @@ export function ConfigSettings({
|
|||
<ModelSettings onMainModelChanged={onMainModelChanged} />
|
||||
</div>
|
||||
)}
|
||||
{/* Device-local desktop pref (not config.yaml) — lives here since keeping
|
||||
the machine awake is a power-user knob. */}
|
||||
{activeSectionId === 'advanced' && (
|
||||
<ToggleRow checked={keepAwake} description={c.keepAwakeDesc} label={c.keepAwakeTitle} onChange={setKeepAwake} />
|
||||
)}
|
||||
{visibleFields.length === 0 ? (
|
||||
<EmptyState description={c.emptyDesc} title={c.emptyTitle} />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes'
|
||||
|
|
@ -7,7 +8,7 @@ import { SlidersHorizontal } from '@/lib/icons'
|
|||
import { notifyError } from '@/store/notifications'
|
||||
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
|
||||
|
||||
import { ListRow, LoadingState, Pill } from '../primitives'
|
||||
import { ListRow, Pill } from '../primitives'
|
||||
|
||||
import { FieldControl, FieldTitle } from './field-control'
|
||||
import { ProviderConfigModal } from './provider-config-modal'
|
||||
|
|
@ -95,7 +96,7 @@ export function ProviderConfigPanel({ provider }: { provider: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
return <LoadingState label="Loading memory provider settings..." />
|
||||
return <PageLoader className="min-h-24" label="Loading memory provider settings..." />
|
||||
}
|
||||
|
||||
const inlineFields = config.fields.filter(field => field.inline)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import type { ReactNode } from 'react'
|
|||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { COMPLETION_SOUND_VARIANTS, previewCompletionSound } from '@/lib/completion-sound'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
|
|
@ -20,7 +19,7 @@ import {
|
|||
import { notify } from '@/store/notifications'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { ListRow, SectionHeading, SettingsContent } from './primitives'
|
||||
import { ListRow, SectionHeading, SettingsContent, ToggleRow } from './primitives'
|
||||
|
||||
const CAPTION = 'text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)'
|
||||
|
||||
|
|
@ -28,32 +27,6 @@ function Caption({ children, className }: { children: ReactNode; className?: str
|
|||
return <p className={cn(CAPTION, className)}>{children}</p>
|
||||
}
|
||||
|
||||
function ToggleRow(props: {
|
||||
checked: boolean
|
||||
description: string
|
||||
disabled?: boolean
|
||||
label: string
|
||||
onChange: (on: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<Switch
|
||||
aria-label={props.label}
|
||||
checked={props.checked}
|
||||
disabled={props.disabled}
|
||||
onCheckedChange={on => {
|
||||
triggerHaptic('selection')
|
||||
props.onChange(on)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
description={props.description}
|
||||
title={props.label}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function NotificationsSettings() {
|
||||
const { t } = useI18n()
|
||||
const prefs = useStore($nativeNotifyPrefs)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import type { ReactNode } from 'react'
|
|||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
|
@ -108,8 +110,50 @@ export function ListRow({
|
|||
)
|
||||
}
|
||||
|
||||
// A labelled on/off row — the canonical device-pref switch (haptic baked in).
|
||||
export function ToggleRow({
|
||||
checked,
|
||||
description,
|
||||
disabled,
|
||||
label,
|
||||
onChange
|
||||
}: {
|
||||
checked: boolean
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
label: string
|
||||
onChange: (on: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<Switch
|
||||
aria-label={label}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onCheckedChange={on => {
|
||||
triggerHaptic('selection')
|
||||
onChange(on)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
description={description}
|
||||
title={label}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// The settings panels render this as the sole child of the top-padded
|
||||
// OverlayMain (pt = titlebar + 1rem, no bottom pad — see settings/index.tsx).
|
||||
// Cancel that top pad so the loader centers in the whole card, not just the
|
||||
// band beneath it. Inline loaders (mid-panel) should use <PageLoader> directly.
|
||||
export function LoadingState({ label }: { label: string }) {
|
||||
return <PageLoader label={label} />
|
||||
return (
|
||||
<PageLoader
|
||||
className="-mt-[calc(var(--titlebar-height)+1rem)] h-[calc(100%+var(--titlebar-height)+1rem)]"
|
||||
label={label}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Canonical implementation lives in components/ui; re-exported so the many
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ declare global {
|
|||
setTitleBarTheme?: (payload: HermesTitleBarTheme) => void
|
||||
setNativeTheme?: (mode: 'dark' | 'light' | 'system') => void
|
||||
setTranslucency?: (payload: { intensity: number }) => void
|
||||
setKeepAwake?: (on: boolean) => void
|
||||
setPreviewShortcutActive?: (active: boolean) => void
|
||||
openExternal: (url: string) => Promise<void>
|
||||
openPreviewInBrowser?: (url: string) => Promise<void>
|
||||
|
|
|
|||
|
|
@ -523,7 +523,9 @@ export const en: Translations = {
|
|||
failedLoad: 'Settings failed to load',
|
||||
autosaveFailed: 'Autosave failed',
|
||||
imported: 'Config imported',
|
||||
invalidJson: 'Invalid config JSON'
|
||||
invalidJson: 'Invalid config JSON',
|
||||
keepAwakeTitle: 'Keep computer awake',
|
||||
keepAwakeDesc: 'Stop this machine from sleeping so long or overnight runs keep going. The display can still dim.'
|
||||
},
|
||||
credentials: {
|
||||
pasteKey: 'Paste key',
|
||||
|
|
|
|||
|
|
@ -621,7 +621,9 @@ export const ja = defineLocale({
|
|||
failedLoad: '設定の読み込みに失敗しました',
|
||||
autosaveFailed: '自動保存に失敗しました',
|
||||
imported: '設定をインポートしました',
|
||||
invalidJson: '設定 JSON が無効です'
|
||||
invalidJson: '設定 JSON が無効です',
|
||||
keepAwakeTitle: 'コンピューターをスリープさせない',
|
||||
keepAwakeDesc: '本体のスリープを防ぎ、長時間や夜通しの実行を継続します。画面は暗転できます。'
|
||||
},
|
||||
credentials: {
|
||||
pasteKey: 'キーを貼り付け',
|
||||
|
|
|
|||
|
|
@ -435,6 +435,8 @@ export interface Translations {
|
|||
autosaveFailed: string
|
||||
imported: string
|
||||
invalidJson: string
|
||||
keepAwakeTitle: string
|
||||
keepAwakeDesc: string
|
||||
}
|
||||
credentials: {
|
||||
pasteKey: string
|
||||
|
|
|
|||
|
|
@ -609,7 +609,9 @@ export const zhHant = defineLocale({
|
|||
failedLoad: '設定載入失敗',
|
||||
autosaveFailed: '自動儲存失敗',
|
||||
imported: '設定已匯入',
|
||||
invalidJson: '設定 JSON 無效'
|
||||
invalidJson: '設定 JSON 無效',
|
||||
keepAwakeTitle: '保持電腦喚醒',
|
||||
keepAwakeDesc: '阻止本機睡眠,讓長時間或整夜執行持續進行。螢幕仍可變暗。'
|
||||
},
|
||||
credentials: {
|
||||
pasteKey: '貼上金鑰',
|
||||
|
|
|
|||
|
|
@ -720,7 +720,9 @@ export const zh: Translations = {
|
|||
failedLoad: '设置加载失败',
|
||||
autosaveFailed: '自动保存失败',
|
||||
imported: '配置已导入',
|
||||
invalidJson: '配置 JSON 无效'
|
||||
invalidJson: '配置 JSON 无效',
|
||||
keepAwakeTitle: '保持电脑唤醒',
|
||||
keepAwakeDesc: '阻止本机休眠,让长时间或通宵运行继续进行。屏幕仍可变暗。'
|
||||
},
|
||||
credentials: {
|
||||
pasteKey: '粘贴密钥',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { storedBoolean } from '@/lib/storage'
|
||||
|
||||
import { $keepAwake, setKeepAwake } from './keep-awake'
|
||||
|
||||
const KEY = 'hermes.desktop.keepAwake.v1'
|
||||
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
|
||||
const initialHermesDesktop = desktopWindow.hermesDesktop
|
||||
const setKeepAwakeBridge = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
desktopWindow.hermesDesktop = { setKeepAwake: setKeepAwakeBridge } as unknown as Window['hermesDesktop']
|
||||
setKeepAwake(false)
|
||||
setKeepAwakeBridge.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
desktopWindow.hermesDesktop = initialHermesDesktop
|
||||
})
|
||||
|
||||
describe('keep-awake store', () => {
|
||||
it('persists the pref and mirrors it to the main process', () => {
|
||||
setKeepAwake(true)
|
||||
expect($keepAwake.get()).toBe(true)
|
||||
expect(storedBoolean(KEY, false)).toBe(true)
|
||||
expect(setKeepAwakeBridge).toHaveBeenLastCalledWith(true)
|
||||
|
||||
setKeepAwake(false)
|
||||
expect(storedBoolean(KEY, true)).toBe(false)
|
||||
expect(setKeepAwakeBridge).toHaveBeenLastCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Keep-awake — stop the machine sleeping during long, unattended runs.
|
||||
*
|
||||
* A device-local preference (each computer keeps its own), off by default. This
|
||||
* atom backs the Settings → Advanced toggle and mirrors changes to the main
|
||||
* process, which owns the real power-save blocker AND its own persisted copy —
|
||||
* so a cold launch restores the blocker without the renderer visiting Settings
|
||||
* (see electron/main.ts + electron/power-save.ts). Linux/web without the bridge
|
||||
* just no-op.
|
||||
*/
|
||||
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { persistBoolean, storedBoolean } from '@/lib/storage'
|
||||
|
||||
const KEY = 'hermes.desktop.keepAwake.v1'
|
||||
|
||||
export const $keepAwake = atom<boolean>(typeof window === 'undefined' ? false : storedBoolean(KEY, false))
|
||||
|
||||
export function setKeepAwake(on: boolean): void {
|
||||
$keepAwake.set(on)
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
$keepAwake.subscribe(on => {
|
||||
persistBoolean(KEY, on)
|
||||
window.hermesDesktop?.setKeepAwake?.(on)
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue