mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-09-29 17:23:59 +08:00
feat(desktop): opt in to modifier-tap HUD summoning from Keyboard Shortcuts
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
export interface HudModifierStatus {
|
||||
enabled: boolean
|
||||
state: 'disabled' | 'starting' | 'ready' | 'input-permission' | 'unavailable'
|
||||
}
|
||||
|
||||
export interface HudModifierApi {
|
||||
getSettings: () => Promise<HudModifierStatus>
|
||||
setEnabled: (enabled: boolean) => Promise<HudModifierStatus>
|
||||
openPermissionSettings: () => Promise<void>
|
||||
onStatus: (callback: (status: HudModifierStatus) => void) => () => void
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
const native = vi.hoisted(() => ({ start: vi.fn(), stop: vi.fn() }))
|
||||
const state = vi.hoisted(() => ({ directory: '', windows: [] as any[], handlers: new Map<string, any>() }))
|
||||
vi.mock('./hud-modifier-monitor', () => ({
|
||||
HudModifierMonitor: class {
|
||||
start = native.start
|
||||
stop = native.stop
|
||||
}
|
||||
}))
|
||||
vi.mock('electron', async () => {
|
||||
const { EventEmitter } = await import('node:events')
|
||||
|
||||
return {
|
||||
app: Object.assign(new EventEmitter(), { getPath: () => state.directory, getAppPath: () => '/app' }),
|
||||
powerMonitor: new EventEmitter(),
|
||||
BrowserWindow: {
|
||||
getAllWindows: () => state.windows,
|
||||
fromWebContents: (wc: unknown) => state.windows.find(win => win.webContents === wc)
|
||||
},
|
||||
ipcMain: {
|
||||
handle: (name: string, handler: unknown) => state.handlers.set(name, handler),
|
||||
removeHandler: (name: string) => state.handlers.delete(name)
|
||||
},
|
||||
shell: { openExternal: vi.fn() }
|
||||
}
|
||||
})
|
||||
|
||||
import { app, powerMonitor } from 'electron'
|
||||
|
||||
import { installHudModifierTap } from './hud-modifier'
|
||||
|
||||
const cleanups: (() => void)[] = []
|
||||
afterEach(async () => {
|
||||
cleanups.splice(0).forEach(dispose => dispose())
|
||||
await rm(state.directory, { recursive: true, force: true })
|
||||
state.windows = []
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
async function setup() {
|
||||
state.directory = await mkdtemp(path.join(os.tmpdir(), 'hud-modifier-settings-'))
|
||||
const summon = vi.fn()
|
||||
const frame = { url: 'http://127.0.0.1:5174/?win=hud#/' }
|
||||
const wc = Object.assign(new EventEmitter(), { mainFrame: frame, send: vi.fn() })
|
||||
const win = { webContents: wc, isDestroyed: () => false }
|
||||
state.windows.push(win)
|
||||
const event = { sender: wc, senderFrame: frame }
|
||||
|
||||
const install = () => {
|
||||
const dispose = installHudModifierTap({ rendererUrl: 'http://127.0.0.1:5174', summon })
|
||||
cleanups.push(dispose)
|
||||
|
||||
return dispose
|
||||
}
|
||||
|
||||
const call = (name: string, value?: unknown, sender = event) =>
|
||||
state.handlers.get(`hermes:hud-modifier:${name}`)(sender, value)
|
||||
|
||||
return { summon, event, call, install }
|
||||
}
|
||||
|
||||
it('persists opt-in, fences stale native callbacks and re-arms after lock without requesting permission', async () => {
|
||||
const { summon, call, install } = await setup()
|
||||
const dispose = install()
|
||||
expect(await call('settings:get')).toEqual({ enabled: false, state: 'disabled' })
|
||||
expect(native.start).not.toHaveBeenCalled()
|
||||
await call('settings:set', true)
|
||||
expect(JSON.parse(await readFile(path.join(state.directory, 'hud-modifier.json'), 'utf8'))).toEqual({ enabled: true })
|
||||
const [oldTap, oldStatus, permission] = native.start.mock.calls.at(-1)!
|
||||
expect(permission).toBe(true)
|
||||
oldTap()
|
||||
expect(summon).not.toHaveBeenCalled()
|
||||
oldStatus({ type: 'ready' })
|
||||
oldTap()
|
||||
expect(summon).toHaveBeenCalledOnce()
|
||||
powerMonitor.emit('lock-screen')
|
||||
oldTap()
|
||||
oldStatus({ type: 'ready' })
|
||||
expect(summon).toHaveBeenCalledOnce()
|
||||
powerMonitor.emit('unlock-screen')
|
||||
expect(native.start.mock.calls.at(-1)![2]).toBe(false)
|
||||
await call('settings:set', false)
|
||||
const [lateTap, lateStatus] = native.start.mock.calls.at(-1)!
|
||||
lateStatus({ type: 'ready' })
|
||||
lateTap()
|
||||
expect(await call('settings:get')).toEqual({ enabled: false, state: 'disabled' })
|
||||
expect(summon).toHaveBeenCalledOnce()
|
||||
await call('settings:set', true)
|
||||
dispose()
|
||||
install()
|
||||
expect(native.start.mock.calls.at(-1)![2]).toBe(false)
|
||||
const [restartTap, restartStatus] = native.start.mock.calls.at(-1)!
|
||||
restartStatus({ type: 'ready' })
|
||||
expect(await call('settings:get')).toEqual({ enabled: true, state: 'ready' })
|
||||
app.emit('will-quit')
|
||||
restartTap()
|
||||
expect(summon).toHaveBeenCalledOnce()
|
||||
expect(state.handlers.size).toBe(0)
|
||||
expect(powerMonitor.listenerCount('unlock-screen')).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects guest frames and invalid writes, and reports native permission failure without summoning', async () => {
|
||||
const { summon, event, call, install } = await setup()
|
||||
install()
|
||||
await expect(call('settings:set', true, { ...event, senderFrame: { url: 'https://example.org/' } })).rejects.toThrow(
|
||||
'untrusted'
|
||||
)
|
||||
await expect(call('settings:set', 'true')).rejects.toThrow('boolean')
|
||||
expect(native.start).not.toHaveBeenCalled()
|
||||
await call('settings:set', true)
|
||||
const [tap, status] = native.start.mock.calls.at(-1)!
|
||||
status({ type: 'error', code: 'permission-required' })
|
||||
tap()
|
||||
expect(await call('settings:get')).toEqual({ enabled: true, state: 'input-permission' })
|
||||
expect(summon).not.toHaveBeenCalled()
|
||||
// A failed authoritative write does not silently disable a live setting.
|
||||
const directory = state.directory
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
const { writeFile } = await import('node:fs/promises')
|
||||
await writeFile(directory, 'not a directory')
|
||||
await expect(call('settings:set', false)).rejects.toThrow()
|
||||
expect(await call('settings:get')).toEqual({ enabled: true, state: 'input-permission' })
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { app, BrowserWindow, ipcMain, powerMonitor, shell } from 'electron'
|
||||
import type { IpcMainInvokeEvent } from 'electron'
|
||||
|
||||
import { HudModifierMonitor } from './hud-modifier-monitor'
|
||||
import type { HudModifierStatus } from './hud-modifier-types'
|
||||
|
||||
/** Opt-in device preference: native input never crosses into a renderer. */
|
||||
export function installHudModifierTap({
|
||||
rendererUrl,
|
||||
summon
|
||||
}: {
|
||||
rendererUrl: string
|
||||
summon: () => void
|
||||
}): () => void {
|
||||
const configPath = path.join(app.getPath('userData'), 'hud-modifier.json')
|
||||
const expectedUrl = new URL(rendererUrl)
|
||||
const monitor = new HudModifierMonitor({ appPath: app.getAppPath() })
|
||||
let enabled = false
|
||||
let state: HudModifierStatus['state'] = 'disabled'
|
||||
let disposed = false
|
||||
let generation = 0
|
||||
|
||||
try {
|
||||
enabled = JSON.parse(readFileSync(configPath, 'utf8')).enabled === true
|
||||
} catch {
|
||||
// An absent or unreadable preference never grants input monitoring.
|
||||
}
|
||||
|
||||
const status = (): HudModifierStatus => ({ enabled, state })
|
||||
|
||||
const publish = () => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send('hermes:hud-modifier:status', status())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
generation += 1
|
||||
monitor.stop()
|
||||
state = 'disabled'
|
||||
}
|
||||
|
||||
const start = (requestPermission = false) => {
|
||||
const current = ++generation
|
||||
state = 'starting'
|
||||
monitor.start(
|
||||
() => {
|
||||
if (!disposed && enabled && state === 'ready' && current === generation) {
|
||||
summon()
|
||||
}
|
||||
},
|
||||
result => {
|
||||
if (disposed || current !== generation) {
|
||||
return
|
||||
}
|
||||
|
||||
state =
|
||||
result.type === 'error'
|
||||
? result.code === 'permission-required'
|
||||
? 'input-permission'
|
||||
: 'unavailable'
|
||||
: result.type === 'stopped'
|
||||
? 'disabled'
|
||||
: result.type
|
||||
publish()
|
||||
},
|
||||
requestPermission
|
||||
)
|
||||
}
|
||||
|
||||
const trusted = (event: IpcMainInvokeEvent) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
|
||||
if (!win || win.isDestroyed() || event.senderFrame !== event.sender.mainFrame) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(event.senderFrame.url)
|
||||
|
||||
return (
|
||||
url.protocol === expectedUrl.protocol && url.host === expectedUrl.host && url.pathname === expectedUrl.pathname
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const handlers: Record<string, (value: unknown) => unknown> = {
|
||||
'settings:get': status,
|
||||
'settings:set': value => {
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new Error('HUD gesture setting must be a boolean')
|
||||
}
|
||||
|
||||
// Persist before changing the listener; a failed write leaves the old setting live.
|
||||
mkdirSync(path.dirname(configPath), { recursive: true })
|
||||
writeFileSync(`${configPath}.tmp`, JSON.stringify({ enabled: value }), { mode: 0o600 })
|
||||
renameSync(`${configPath}.tmp`, configPath)
|
||||
enabled = value
|
||||
stop()
|
||||
|
||||
if (enabled) {
|
||||
start(true)
|
||||
}
|
||||
|
||||
publish()
|
||||
|
||||
return status()
|
||||
},
|
||||
permission: async () => {
|
||||
if (process.platform === 'darwin') {
|
||||
await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, handler] of Object.entries(handlers)) {
|
||||
ipcMain.handle(`hermes:hud-modifier:${name}`, async (event, value) => {
|
||||
if (!trusted(event)) {
|
||||
throw new Error('HUD gesture request from an untrusted frame')
|
||||
}
|
||||
|
||||
return handler(value)
|
||||
})
|
||||
}
|
||||
|
||||
// Never complete a chord across a sleep/lock boundary.
|
||||
const suspend = () => {
|
||||
stop()
|
||||
publish()
|
||||
}
|
||||
|
||||
const resume = () => {
|
||||
if (enabled && !disposed) {
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
powerMonitor.on('suspend', suspend)
|
||||
powerMonitor.on('lock-screen', suspend)
|
||||
powerMonitor.on('resume', resume)
|
||||
powerMonitor.on('unlock-screen', resume)
|
||||
|
||||
const dispose = () => {
|
||||
disposed = true
|
||||
stop()
|
||||
Object.keys(handlers).forEach(name => ipcMain.removeHandler(`hermes:hud-modifier:${name}`))
|
||||
powerMonitor.removeListener('suspend', suspend)
|
||||
powerMonitor.removeListener('lock-screen', suspend)
|
||||
powerMonitor.removeListener('resume', resume)
|
||||
powerMonitor.removeListener('unlock-screen', resume)
|
||||
app.removeListener('will-quit', dispose)
|
||||
}
|
||||
|
||||
app.once('will-quit', dispose)
|
||||
|
||||
if (enabled) {
|
||||
start()
|
||||
}
|
||||
|
||||
return dispose
|
||||
}
|
||||
@@ -268,6 +268,7 @@ import { cursorPointInWindow } from './hud-cursor'
|
||||
import { startHudGameOverlayWatch } from './hud-game-overlay'
|
||||
import { applyHudResetBounds, defaultHudBounds } from './hud-geometry'
|
||||
import { registerHudIpc } from './hud-ipc'
|
||||
import { installHudModifierTap } from './hud-modifier'
|
||||
import { applyHudElectronOverlay, promoteHudOverlay } from './hud-overlay'
|
||||
import { snapHudBounds } from './hud-snap'
|
||||
import { createHudSnapShortcut } from './hud-snap-shortcut'
|
||||
@@ -18681,6 +18682,14 @@ app.whenReady().then(() => {
|
||||
// here and surfaced in Settings via the IPC state (never silent).
|
||||
applyQuickEntrySettings(readQuickEntrySettings())
|
||||
installCommandScreenshot({ rendererUrl: DEV_SERVER || pathToFileURL(resolveRendererIndex()).toString() })
|
||||
installHudModifierTap({
|
||||
rendererUrl: DEV_SERVER || pathToFileURL(resolveRendererIndex()).toString(),
|
||||
summon: () => {
|
||||
if (!isQuittingForHandoff && !backendShutdown.hasStarted()) {
|
||||
openHudWindow(null, null)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (IS_MAC) {
|
||||
const reposition = () => wakeIndicatorController.reposition()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron'
|
||||
|
||||
import type { DesktopProfileRoute } from './desktop-profile'
|
||||
import type { HudModifierApi, HudModifierStatus } from './hud-modifier-types'
|
||||
import { customWindowControlsEnabled } from './window-controls'
|
||||
|
||||
// Which translucency the OS can back. Asked synchronously because the renderer
|
||||
@@ -181,6 +182,17 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
return () => ipcRenderer.removeListener('hermes:hud:game-overlay', listener)
|
||||
}
|
||||
},
|
||||
hudModifier: {
|
||||
getSettings: () => ipcRenderer.invoke('hermes:hud-modifier:settings:get'),
|
||||
setEnabled: enabled => ipcRenderer.invoke('hermes:hud-modifier:settings:set', enabled),
|
||||
openPermissionSettings: () => ipcRenderer.invoke('hermes:hud-modifier:permission'),
|
||||
onStatus: callback => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, status: HudModifierStatus) => callback(status)
|
||||
ipcRenderer.on('hermes:hud-modifier:status', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:hud-modifier:status', listener)
|
||||
}
|
||||
} satisfies HudModifierApi,
|
||||
// macOS native screenshot gesture; captures require a main-issued request.
|
||||
screenshot:
|
||||
process.platform === 'darwin'
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import { en } from '@/i18n/en'
|
||||
|
||||
import type { HudModifierStatus } from '../../../electron/hud-modifier-types'
|
||||
|
||||
import { HudModifierSettings } from './hud-modifier-settings'
|
||||
|
||||
vi.mock('@/i18n', () => ({ useI18n: () => ({ t: en }) }))
|
||||
const copy = en.settings.hudModifier
|
||||
const common = en.settings.screenshot
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function bridge() {
|
||||
let emit = (_status: HudModifierStatus) => {}
|
||||
const unsubscribe = vi.fn()
|
||||
|
||||
const api = {
|
||||
getSettings: vi.fn<() => Promise<HudModifierStatus>>().mockResolvedValue({ enabled: false, state: 'disabled' }),
|
||||
setEnabled: vi.fn<(enabled: boolean) => Promise<HudModifierStatus>>(),
|
||||
openPermissionSettings: vi.fn().mockResolvedValue(undefined),
|
||||
onStatus: vi.fn((listener: (status: HudModifierStatus) => void) => {
|
||||
emit = listener
|
||||
|
||||
return unsubscribe
|
||||
})
|
||||
}
|
||||
|
||||
vi.stubGlobal('hermesDesktop', { hudModifier: api })
|
||||
|
||||
return { api, emit: (value: HudModifierStatus) => emit(value), unsubscribe }
|
||||
}
|
||||
|
||||
it('requires explicit opt-in and native readiness, with permission recovery and stale-read protection', async () => {
|
||||
const { api, emit, unsubscribe } = bridge()
|
||||
let resolve!: (value: HudModifierStatus) => void
|
||||
api.getSettings.mockReturnValueOnce(
|
||||
new Promise(yes => {
|
||||
resolve = yes
|
||||
})
|
||||
)
|
||||
const view = render(<HudModifierSettings />)
|
||||
const toggle = screen.getByRole('switch', { name: copy.title })
|
||||
expect(toggle).toHaveProperty('disabled', true)
|
||||
expect(api.setEnabled).not.toHaveBeenCalled()
|
||||
await act(async () => {
|
||||
emit({ enabled: false, state: 'disabled' })
|
||||
resolve({ enabled: true, state: 'ready' })
|
||||
})
|
||||
expect(toggle).toHaveProperty('ariaChecked', 'false')
|
||||
api.setEnabled.mockResolvedValueOnce({ enabled: true, state: 'input-permission' })
|
||||
await act(async () => fireEvent.click(toggle))
|
||||
expect(api.setEnabled).toHaveBeenLastCalledWith(true)
|
||||
expect(screen.getByText(copy.permission)).toBeTruthy()
|
||||
expect(screen.queryByText(copy.ready)).toBeNull()
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: common.openSettings })))
|
||||
expect(api.openPermissionSettings).toHaveBeenCalledOnce()
|
||||
api.setEnabled.mockResolvedValueOnce({ enabled: true, state: 'starting' })
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: common.retry })))
|
||||
expect(api.setEnabled).toHaveBeenLastCalledWith(true)
|
||||
expect(screen.queryByText(copy.ready)).toBeNull()
|
||||
await act(async () => emit({ enabled: true, state: 'ready' }))
|
||||
expect(screen.getByText(copy.ready)).toBeTruthy()
|
||||
api.setEnabled.mockResolvedValueOnce({ enabled: false, state: 'disabled' })
|
||||
await act(async () => fireEvent.click(toggle))
|
||||
expect(toggle).toHaveProperty('ariaChecked', 'false')
|
||||
view.unmount()
|
||||
expect(unsubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rereads after an unconfirmed write and keeps failed reads recoverable', async () => {
|
||||
const { api } = bridge()
|
||||
api.getSettings.mockRejectedValueOnce(new Error('IPC unavailable'))
|
||||
render(<HudModifierSettings />)
|
||||
expect(await screen.findByText(common.loadFailed)).toBeTruthy()
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: common.retry })))
|
||||
api.setEnabled.mockRejectedValueOnce(new Error('unconfirmed'))
|
||||
await act(async () => fireEvent.click(screen.getByRole('switch', { name: copy.title })))
|
||||
expect(screen.getByText(common.saveFailed)).toBeTruthy()
|
||||
api.getSettings.mockResolvedValueOnce({ enabled: true, state: 'unavailable' })
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: common.retry })))
|
||||
expect(api.setEnabled).toHaveBeenCalledOnce()
|
||||
expect(screen.getByText(copy.unavailable)).toBeTruthy()
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
import type { HudModifierStatus } from '../../../electron/hud-modifier-types'
|
||||
|
||||
import { ListRow, ToggleRow } from './primitives'
|
||||
import { useDeepLinkHighlight } from './use-deep-link-highlight'
|
||||
|
||||
const isHudModifierSetting = (target: string) => target === 'hud-modifier'
|
||||
const hudModifierElementId = () => 'setting-hud-modifier'
|
||||
|
||||
export function HudModifierSettings() {
|
||||
const { t } = useI18n()
|
||||
const copy = t.settings.hudModifier
|
||||
const common = t.settings.screenshot
|
||||
const api = window.hermesDesktop?.hudModifier
|
||||
const [status, setStatus] = useState<HudModifierStatus | null>(null)
|
||||
const [busy, setBusy] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const request = useRef(0)
|
||||
const revision = useRef(0)
|
||||
useDeepLinkHighlight({ param: 'setting', ready: isHudModifierSetting, elementId: hudModifierElementId })
|
||||
|
||||
const refresh = useCallback(
|
||||
async (enabled?: boolean) => {
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = ++request.current
|
||||
const version = revision.current
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const next = await (enabled === undefined ? api.getSettings() : api.setEnabled(enabled))
|
||||
|
||||
if (id === request.current && version === revision.current) {
|
||||
setStatus(next)
|
||||
}
|
||||
} catch {
|
||||
if (id === request.current) {
|
||||
setError(enabled === undefined ? common.loadFailed : common.saveFailed)
|
||||
}
|
||||
} finally {
|
||||
if (id === request.current) {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[api, common.loadFailed, common.saveFailed]
|
||||
)
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- native IPC subscription, not a store mirror.
|
||||
useEffect(() => {
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
|
||||
const unsubscribe = api.onStatus(next => {
|
||||
revision.current += 1
|
||||
setStatus(next)
|
||||
setError(null)
|
||||
})
|
||||
|
||||
void refresh()
|
||||
|
||||
return () => {
|
||||
request.current += 1
|
||||
unsubscribe()
|
||||
}
|
||||
}, [api, refresh])
|
||||
|
||||
if (!api) {
|
||||
return null
|
||||
}
|
||||
|
||||
const openPermissionSettings = async () => {
|
||||
try {
|
||||
await api.openPermissionSettings()
|
||||
} catch {
|
||||
setError(common.permissionFailed)
|
||||
}
|
||||
}
|
||||
|
||||
const descriptions: Record<HudModifierStatus['state'], string> = {
|
||||
disabled: '',
|
||||
starting: common.starting,
|
||||
ready: copy.ready,
|
||||
'input-permission': copy.permission,
|
||||
unavailable: copy.unavailable
|
||||
}
|
||||
|
||||
const canRetry = error || status?.state === 'input-permission' || status?.state === 'unavailable'
|
||||
|
||||
return (
|
||||
<div id={hudModifierElementId()}>
|
||||
<ToggleRow
|
||||
checked={status?.enabled ?? false}
|
||||
description={copy.description}
|
||||
disabled={!status || busy}
|
||||
label={copy.title}
|
||||
onChange={enabled => void refresh(enabled)}
|
||||
/>
|
||||
{(busy || error || (status?.enabled && status.state !== 'disabled')) && (
|
||||
<ListRow
|
||||
action={
|
||||
busy ? (
|
||||
<Loader className="size-5" />
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{status?.state === 'input-permission' && (
|
||||
<Button onClick={() => void openPermissionSettings()} size="sm" variant="secondary">
|
||||
{common.openSettings}
|
||||
</Button>
|
||||
)}
|
||||
{canRetry && (
|
||||
<Button
|
||||
onClick={() => void refresh(error ? undefined : status?.enabled)}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
{common.retry}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
description={<span aria-live="polite">{error ?? descriptions[status?.state ?? 'disabled']}</span>}
|
||||
title={copy.statusTitle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
resetBinding
|
||||
} from '@/store/keybinds'
|
||||
|
||||
import { HudModifierSettings } from './hud-modifier-settings'
|
||||
import { SettingsBreadcrumbContext, SettingsContent } from './primitives'
|
||||
import { ScreenshotSettings } from './screenshot-settings'
|
||||
|
||||
@@ -137,6 +138,11 @@ function ShortcutSettings({ includeScreenshot }: { includeScreenshot: boolean })
|
||||
<ScreenshotSettings />
|
||||
)}
|
||||
|
||||
{(!isSearching ||
|
||||
`${t.settings.hudModifier.title} ${t.settings.hudModifier.description}`
|
||||
.toLowerCase()
|
||||
.includes(query.toLowerCase())) && <HudModifierSettings />}
|
||||
|
||||
<div className="pb-3">
|
||||
<SearchField
|
||||
aria-label={k.search}
|
||||
|
||||
@@ -290,7 +290,22 @@ export function useSettingsSearchCatalog(enabled: boolean) {
|
||||
)
|
||||
|
||||
return {
|
||||
subpageEntries,
|
||||
subpageEntries: [
|
||||
...subpageEntries,
|
||||
...(window.hermesDesktop?.hudModifier
|
||||
? [
|
||||
{
|
||||
context: t.keybinds.title,
|
||||
icon: Settings2,
|
||||
id: 'setting:hud-modifier',
|
||||
keywords: ['HUD', 'summon', 'modifier', 'tap', 'Ctrl', 'Alt', 'Command', 'Option'],
|
||||
label: t.settings.hudModifier.title,
|
||||
description: t.settings.hudModifier.description,
|
||||
target: { view: 'keybinds' as const, subpage: 'shortcuts', setting: 'hud-modifier' }
|
||||
}
|
||||
]
|
||||
: [])
|
||||
],
|
||||
appearanceEntries,
|
||||
configEntries,
|
||||
credentialEntries,
|
||||
|
||||
Vendored
+2
@@ -2,6 +2,7 @@ import type { GatewayWsUrlResult } from '@hermes/shared'
|
||||
import type { TranslucencyState } from '@hermes/shared/translucency'
|
||||
|
||||
import type { ScreenshotApi } from '../electron/command-screenshot-types'
|
||||
import type { HudModifierApi } from '../electron/hud-modifier-types'
|
||||
import type { HermesNotification } from '../electron/notification-types'
|
||||
import type { PoolLimits } from '../electron/pool-limits'
|
||||
|
||||
@@ -180,6 +181,7 @@ declare global {
|
||||
}
|
||||
// macOS native screenshot gesture; absent on other platforms.
|
||||
screenshot?: ScreenshotApi
|
||||
hudModifier?: HudModifierApi
|
||||
// Quick Entry: a global-hotkey mini composer window. Main owns the OS
|
||||
// shortcut registration + the persisted preference (it must restore the
|
||||
// shortcut on a cold launch without the renderer visiting Settings), so
|
||||
|
||||
@@ -899,6 +899,18 @@ export const ar = defineLocale({
|
||||
imported: 'تم استيراد الإعدادات',
|
||||
invalidJson: 'JSON غير صالح'
|
||||
},
|
||||
hudModifier: {
|
||||
title: 'استدعاء HUD بضغطة',
|
||||
description:
|
||||
'اضغط ثم حرّر ⌘ + Option على Mac، أو Ctrl + Alt على Windows/Linux، لإظهار HUD من أي تطبيق. معطّل افتراضيًا ويُطبّق على هذا الجهاز فقط.',
|
||||
statusTitle: 'حالة إيماءة HUD',
|
||||
ready:
|
||||
'جاهزة. حرّر المفتاحين خلال نصف ثانية دون الضغط على مفتاح آخر أو استخدام الفأرة. تبقى الاختصارات الأخرى كما هي.',
|
||||
permission:
|
||||
'اسمح لـ Hermes في إعدادات النظام ← الخصوصية والأمان ← مراقبة الإدخال، ثم أعد المحاولة. لا تسجّل هذه الإيماءة ضغطات المفاتيح ولا تلتقط الشاشة.',
|
||||
unavailable:
|
||||
'هذه الإيماءة غير متاحة هنا. يتطلب Linux جلسة X11؛ لا يتيح Wayland رصد ضغطات مفاتيح التعديل على مستوى النظام. يظل اختصار HUD الحالي يعمل داخل Hermes.'
|
||||
},
|
||||
screenshot: {
|
||||
enabledTitle: 'اختصار لقطة الشاشة',
|
||||
enabledDesc:
|
||||
|
||||
@@ -1029,6 +1029,18 @@ export const en: Translations = {
|
||||
attachmentSizeLabel: 'Max preview / image load size in megabytes',
|
||||
showOptions: 'Show options'
|
||||
},
|
||||
hudModifier: {
|
||||
title: 'Tap to summon HUD',
|
||||
description:
|
||||
'Tap and release ⌘ + Option on Mac, or Ctrl + Alt on Windows/Linux, to bring the HUD forward from any app. Off by default; applies only to this device.',
|
||||
statusTitle: 'HUD gesture status',
|
||||
ready:
|
||||
'Ready. Release both keys within half a second, without another key or mouse action. Other shortcuts stay unchanged.',
|
||||
permission:
|
||||
'Allow Hermes in System Settings → Privacy & Security → Input Monitoring, then retry. This gesture does not record keystrokes or capture your screen.',
|
||||
unavailable:
|
||||
'This gesture is unavailable here. Linux requires an X11 session; Wayland does not expose global modifier taps. The existing HUD shortcut still works inside Hermes.'
|
||||
},
|
||||
screenshot: {
|
||||
enabledTitle: 'Screenshot shortcut',
|
||||
enabledDesc:
|
||||
|
||||
@@ -983,6 +983,18 @@ export const ja = defineLocale({
|
||||
keepAwakeTitle: 'コンピューターをスリープさせない',
|
||||
keepAwakeDesc: '本体のスリープを防ぎ、長時間や夜通しの実行を継続します。画面は暗転できます。'
|
||||
},
|
||||
hudModifier: {
|
||||
title: 'キーをタップして HUD を呼び出す',
|
||||
description:
|
||||
'Mac では ⌘ + Option、Windows/Linux では Ctrl + Alt を押して離すと、どのアプリからでも HUD を前面に表示できます。初期設定はオフで、このデバイスにのみ適用されます。',
|
||||
statusTitle: 'HUD ジェスチャーの状態',
|
||||
ready:
|
||||
'使用できます。他のキーやマウスを操作せず、0.5 秒以内に両方のキーを離してください。他のショートカットは変わりません。',
|
||||
permission:
|
||||
'システム設定 → プライバシーとセキュリティ → 入力監視で Hermes を許可し、再試行してください。このジェスチャーはキー入力の記録や画面の撮影を行いません。',
|
||||
unavailable:
|
||||
'ここではこのジェスチャーを使用できません。Linux では X11 セッションが必要です。Wayland はグローバルな修飾キーのタップを公開しません。Hermes 内の既存の HUD ショートカットは引き続き使用できます。'
|
||||
},
|
||||
screenshot: {
|
||||
enabledTitle: 'スクリーンショットのショートカット',
|
||||
enabledDesc:
|
||||
|
||||
@@ -1079,6 +1079,18 @@ export const ru = defineLocale({
|
||||
attachmentSizeUnit: 'МБ',
|
||||
attachmentSizeLabel: 'Макс. размер превью / загрузки изображений в мегабайтах'
|
||||
},
|
||||
hudModifier: {
|
||||
title: 'Вызов HUD коротким нажатием',
|
||||
description:
|
||||
'Нажмите и отпустите ⌘ + Option на Mac или Ctrl + Alt на Windows/Linux, чтобы вызвать HUD из любого приложения. По умолчанию выключено; действует только на этом устройстве.',
|
||||
statusTitle: 'Состояние жеста HUD',
|
||||
ready:
|
||||
'Готово. Отпустите обе клавиши в течение полусекунды, не нажимая другие клавиши и не используя мышь. Остальные сочетания не меняются.',
|
||||
permission:
|
||||
'Разрешите Hermes в Системных настройках → Конфиденциальность и безопасность → Мониторинг ввода, затем повторите попытку. Жест не записывает нажатия клавиш и не снимает экран.',
|
||||
unavailable:
|
||||
'Жест здесь недоступен. В Linux требуется сеанс X11: Wayland не предоставляет глобальные события нажатия модификаторов. Существующее сочетание HUD по-прежнему работает внутри Hermes.'
|
||||
},
|
||||
screenshot: {
|
||||
enabledTitle: 'Сочетание клавиш для снимка окна',
|
||||
enabledDesc:
|
||||
|
||||
@@ -844,6 +844,14 @@ export interface Translations {
|
||||
attachmentSizeLabel: string
|
||||
showOptions: string
|
||||
}
|
||||
hudModifier: {
|
||||
title: string
|
||||
description: string
|
||||
statusTitle: string
|
||||
ready: string
|
||||
permission: string
|
||||
unavailable: string
|
||||
}
|
||||
screenshot: {
|
||||
enabledTitle: string
|
||||
enabledDesc: string
|
||||
|
||||
@@ -1021,6 +1021,16 @@ export const zhHant = defineLocale({
|
||||
keepAwakeDesc: '阻止本機睡眠,讓長時間或整夜執行持續進行。螢幕仍可變暗。',
|
||||
showOptions: '顯示選項'
|
||||
},
|
||||
hudModifier: {
|
||||
title: '輕按叫出 HUD',
|
||||
description:
|
||||
'在 Mac 上按下並放開 ⌘ + Option,在 Windows/Linux 上按下並放開 Ctrl + Alt,即可從任何應用程式將 HUD 帶到前景。預設關閉,僅適用於此裝置。',
|
||||
statusTitle: 'HUD 手勢狀態',
|
||||
ready: '已就緒。請在半秒內放開兩個按鍵,期間不要按其他鍵或操作滑鼠。其他快速鍵不受影響。',
|
||||
permission: '請在系統設定 → 隱私權與安全性 → 輸入監控中允許 Hermes,然後重試。此手勢不會記錄按鍵或擷取畫面。',
|
||||
unavailable:
|
||||
'此處無法使用這個手勢。Linux 需要 X11 工作階段;Wayland 不提供全域修飾鍵輕按事件。Hermes 內原有的 HUD 快速鍵仍可使用。'
|
||||
},
|
||||
screenshot: {
|
||||
enabledTitle: '螢幕截圖快速鍵',
|
||||
enabledDesc:
|
||||
|
||||
@@ -1240,6 +1240,16 @@ export const zh = defineLocale({
|
||||
attachmentSizeLabel: '预览 / 图片加载大小上限(MB)',
|
||||
showOptions: '显示选项'
|
||||
},
|
||||
hudModifier: {
|
||||
title: '轻按唤出 HUD',
|
||||
description:
|
||||
'在 Mac 上按下并松开 ⌘ + Option,在 Windows/Linux 上按下并松开 Ctrl + Alt,即可从任意应用将 HUD 置于前台。默认关闭,仅适用于此设备。',
|
||||
statusTitle: 'HUD 手势状态',
|
||||
ready: '已就绪。请在半秒内松开两个键,期间不要按其他键或操作鼠标。其他快捷键不受影响。',
|
||||
permission: '请在系统设置 → 隐私与安全性 → 输入监控中允许 Hermes,然后重试。此手势不会记录按键或截取屏幕。',
|
||||
unavailable:
|
||||
'此处无法使用该手势。Linux 需要 X11 会话;Wayland 不提供全局修饰键轻按事件。Hermes 内原有的 HUD 快捷键仍可使用。'
|
||||
},
|
||||
screenshot: {
|
||||
enabledTitle: '截图快捷键',
|
||||
enabledDesc:
|
||||
|
||||
@@ -174,6 +174,7 @@ Talk to Hermes and hear it back, the same [voice mode](./features/voice-mode.md)
|
||||
- **Moving the bar** — on macOS and Windows, **press and hold** anywhere on the composer for a beat, then drag. On Linux/X11, hold **Ctrl** and drag with the primary mouse button for an immediate grab (including over selected text); press-and-hold remains available too. Keep the grab held while invoking your desktop switch shortcut to carry the HUD onto another virtual desktop. On native Wayland the composer bar is a compositor drag handle (the only way to move it, because an app cannot place its own window).
|
||||
- **Resizing** — drag any edge or corner of the bar; the opposite edge stays anchored. Native Wayland exposes the right and bottom edges because the compositor does not allow apps to position top-level windows themselves.
|
||||
- **Reset layout** — the discard control on the bar restores the default size and (on X11 / macOS / Windows) position. Use this if a persisted size leaves the HUD unusable.
|
||||
- **Tap to summon** — enable **Settings → Keyboard Shortcuts → Tap to summon HUD**, then tap and release **⌘+Option** on macOS or **Ctrl+Alt** on Windows/Linux X11. Release both keys within half a second without another key or mouse action. On Windows, use left Alt; right Alt is reserved for AltGr layouts. This opens or focuses the HUD from another app; it does not toggle it closed, record audio, or send a message. Off by default and saved only on this device. macOS requires Input Monitoring permission; the settings row offers permission recovery and reports when the listener is ready. Linux Wayland does not expose this gesture, so keep using the in-app HUD shortcut there.
|
||||
- **Snap to pointer** — **⌘/Ctrl+Shift+G** (a global hotkey, works from any app) jumps the HUD to wherever your cursor is. On native Wayland this is a no-op — the compositor owns placement.
|
||||
- **Exiting** — click the exit button on the bar, press **⌘/Ctrl+Shift+H** again, or press **⌘/Ctrl+W** while the HUD has focus. The app window comes back in front with your session and the caret in its composer.
|
||||
|
||||
@@ -704,6 +705,8 @@ npm run pack # unpacked app under release/ (no installer)
|
||||
|
||||
macOS/Windows signing and notarization run automatically when the relevant credentials are present in the environment (`CSC_LINK` / `CSC_KEY_PASSWORD` / `APPLE_*` for macOS, `WIN_CSC_*` for Windows).
|
||||
|
||||
The optional HUD modifier-tap helper is built with the Electron bundle and packaged outside ASAR. macOS uses the existing Xcode command-line tools prerequisite. Linux builds need a C compiler and X11/XInput development headers (`libx11-dev` and `libxi-dev` on Debian/Ubuntu); Windows builds need Clang and the Windows SDK. Build on the target OS and architecture. If those optional Windows/Linux prerequisites are absent, packaging continues without modifier-tap support and Settings reports it unavailable. Installed users do not need a compiler.
|
||||
|
||||
### macOS permissions and local rebuilds (TCC)
|
||||
|
||||
**Silence every folder prompt with one switch.** macOS prompts per-category
|
||||
|
||||
Reference in New Issue
Block a user