diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index b24decc00815d..b67e8b69f3a7e 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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. diff --git a/apps/desktop/electron/power-save.test.ts b/apps/desktop/electron/power-save.test.ts new file mode 100644 index 0000000000000..97725e0ed019e --- /dev/null +++ b/apps/desktop/electron/power-save.test.ts @@ -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() + + 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') + }) +}) diff --git a/apps/desktop/electron/power-save.ts b/apps/desktop/electron/power-save.ts new file mode 100644 index 0000000000000..0939de99d80cc --- /dev/null +++ b/apps/desktop/electron/power-save.ts @@ -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() + } + } +} diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 952fdfdc8dad0..732d13a53668b 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -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), diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index 2674fa1910392..50708ef4b88a1 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -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({ )} + {/* Device-local desktop pref (not config.yaml) — lives here since keeping + the machine awake is a power-user knob. */} + {activeSectionId === 'advanced' && ( + + )} {visibleFields.length === 0 ? ( ) : ( diff --git a/apps/desktop/src/app/settings/memory/provider-config-panel.tsx b/apps/desktop/src/app/settings/memory/provider-config-panel.tsx index ab7b29da9884f..aa0c5ffd86616 100644 --- a/apps/desktop/src/app/settings/memory/provider-config-panel.tsx +++ b/apps/desktop/src/app/settings/memory/provider-config-panel.tsx @@ -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 + return } const inlineFields = config.fields.filter(field => field.inline) diff --git a/apps/desktop/src/app/settings/notifications-settings.tsx b/apps/desktop/src/app/settings/notifications-settings.tsx index efb0bf662f6e9..b2810f691fed9 100644 --- a/apps/desktop/src/app/settings/notifications-settings.tsx +++ b/apps/desktop/src/app/settings/notifications-settings.tsx @@ -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

{children}

} -function ToggleRow(props: { - checked: boolean - description: string - disabled?: boolean - label: string - onChange: (on: boolean) => void -}) { - return ( - { - triggerHaptic('selection') - props.onChange(on) - }} - /> - } - description={props.description} - title={props.label} - /> - ) -} - export function NotificationsSettings() { const { t } = useI18n() const prefs = useStore($nativeNotifyPrefs) diff --git a/apps/desktop/src/app/settings/primitives.tsx b/apps/desktop/src/app/settings/primitives.tsx index 2d9d1906ef5ff..4e55a2f9f4b97 100644 --- a/apps/desktop/src/app/settings/primitives.tsx +++ b/apps/desktop/src/app/settings/primitives.tsx @@ -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 ( + { + 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 directly. export function LoadingState({ label }: { label: string }) { - return + return ( + + ) } // Canonical implementation lives in components/ui; re-exported so the many diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index a37091ceeb49c..9202c28e7fc62 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -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 openPreviewInBrowser?: (url: string) => Promise diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index a0e79037f8752..a17ecbb3bd60b 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index ed902c8ee8b7a..3ade882c6ab87 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -621,7 +621,9 @@ export const ja = defineLocale({ failedLoad: '設定の読み込みに失敗しました', autosaveFailed: '自動保存に失敗しました', imported: '設定をインポートしました', - invalidJson: '設定 JSON が無効です' + invalidJson: '設定 JSON が無効です', + keepAwakeTitle: 'コンピューターをスリープさせない', + keepAwakeDesc: '本体のスリープを防ぎ、長時間や夜通しの実行を継続します。画面は暗転できます。' }, credentials: { pasteKey: 'キーを貼り付け', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index ae2ec69bcdac1..25a9f67e21980 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -435,6 +435,8 @@ export interface Translations { autosaveFailed: string imported: string invalidJson: string + keepAwakeTitle: string + keepAwakeDesc: string } credentials: { pasteKey: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index c30818309a426..d061a6d57a796 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -609,7 +609,9 @@ export const zhHant = defineLocale({ failedLoad: '設定載入失敗', autosaveFailed: '自動儲存失敗', imported: '設定已匯入', - invalidJson: '設定 JSON 無效' + invalidJson: '設定 JSON 無效', + keepAwakeTitle: '保持電腦喚醒', + keepAwakeDesc: '阻止本機睡眠,讓長時間或整夜執行持續進行。螢幕仍可變暗。' }, credentials: { pasteKey: '貼上金鑰', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 253e01326c617..61d6ff602aab3 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -720,7 +720,9 @@ export const zh: Translations = { failedLoad: '设置加载失败', autosaveFailed: '自动保存失败', imported: '配置已导入', - invalidJson: '配置 JSON 无效' + invalidJson: '配置 JSON 无效', + keepAwakeTitle: '保持电脑唤醒', + keepAwakeDesc: '阻止本机休眠,让长时间或通宵运行继续进行。屏幕仍可变暗。' }, credentials: { pasteKey: '粘贴密钥', diff --git a/apps/desktop/src/store/keep-awake.test.ts b/apps/desktop/src/store/keep-awake.test.ts new file mode 100644 index 0000000000000..4807e436ae4c9 --- /dev/null +++ b/apps/desktop/src/store/keep-awake.test.ts @@ -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) + }) +}) diff --git a/apps/desktop/src/store/keep-awake.ts b/apps/desktop/src/store/keep-awake.ts new file mode 100644 index 0000000000000..f2d2022391f37 --- /dev/null +++ b/apps/desktop/src/store/keep-awake.ts @@ -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(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) + }) +}