feat(desktop): opt in to modifier-tap HUD summoning from Keyboard Shortcuts

This commit is contained in:
brooklyn!
2026-09-21 13:44:49 -05:00
parent 9ff6e071fa
commit 8e806ae1b2
18 changed files with 660 additions and 1 deletions
@@ -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
}
+129
View File
@@ -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' })
})
+168
View File
@@ -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
}
+9
View File
@@ -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()
+12
View File
@@ -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,
+2
View File
@@ -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
+12
View File
@@ -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:
+12
View File
@@ -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:
+12
View File
@@ -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:
+12
View File
@@ -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:
+8
View File
@@ -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
+10
View File
@@ -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:
+10
View File
@@ -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:
+3
View File
@@ -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