feat(desktop): expose native OS notifications to plugins via ctx.notifyNative
Desktop plugins can toast in-app (host.notify) but have no sanctioned way to
reach the OS notification pipeline the app's own approval/turn alerts use, so
a plugin surfacing a genuinely notable background event (e.g. a discovery
plugin finding a match) stays invisible once the user steps away from Hermes.
Add a curated per-plugin door instead of exporting the raw dispatcher:
- ctx.notifyNative({ title, body?, silent? }) on PluginContext — attributed
to the plugin id, routed through dispatchNativeNotification so every
existing gate applies (master + per-kind prefs, post-connect baseline,
away-from-app gating, throttle).
- New 'plugin' native-notification kind with its own Settings ▸ Notifications
toggle (default on), so users silence plugins without losing app alerts.
- New optional `tag` discriminator on the notify payload keys the renderer
throttle and main-process cross-window dedupe per plugin, so two plugins
can't collapse each other's session-less notifications.
Consumer: the Index Network desktop plugin wants background opportunity
alerts; anything in ~/.hermes/desktop-plugins gets the same door.
This commit is contained in:
parent
3fa318a50c
commit
5d24594ab3
|
|
@ -10333,7 +10333,7 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
|
|||
// kind+session can arrive here twice. Collapse it at this single choke point.
|
||||
// Return true (not false): a notification for the event IS being shown by the
|
||||
// first caller, so the settings "send test" success probe stays honest.
|
||||
if (isDuplicateNotification(`${payload?.kind ?? ''}:${payload?.sessionId ?? ''}`)) {
|
||||
if (isDuplicateNotification(`${payload?.kind ?? ''}:${payload?.sessionId ?? payload?.tag ?? ''}`)) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { dispatchPluginNativeNotification } from '@/store/native-notifications'
|
||||
|
||||
import { createPluginContext } from './plugin'
|
||||
|
||||
vi.mock('@/store/native-notifications', () => ({ dispatchPluginNativeNotification: vi.fn() }))
|
||||
|
||||
describe('createPluginContext.onDispose', () => {
|
||||
it('collects arbitrary cleanups so the host runs them on deactivate', () => {
|
||||
const disposers: Array<() => void> = []
|
||||
|
|
@ -19,3 +23,11 @@ describe('createPluginContext.onDispose', () => {
|
|||
expect(cleaned).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createPluginContext.notifyNative', () => {
|
||||
it('dispatches a native notification attributed to the plugin', () => {
|
||||
const ctx = createPluginContext('demo')
|
||||
ctx.notifyNative({ body: 'b', title: 't' })
|
||||
expect(dispatchPluginNativeNotification).toHaveBeenCalledWith('demo', { body: 'b', title: 't' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -15,11 +15,13 @@
|
|||
import { pluginRest, type PluginRestOptions, pluginSocket } from '@/hermes'
|
||||
import { createPluginI18n, type PluginI18n } from '@/i18n'
|
||||
import { readKey, writeKey } from '@/lib/storage'
|
||||
import { dispatchPluginNativeNotification, type PluginNativeNotificationInput } from '@/store/native-notifications'
|
||||
|
||||
import { registry } from './registry'
|
||||
import type { Contribution } from './types'
|
||||
|
||||
export type { PluginRestOptions } from '@/hermes'
|
||||
export type { PluginNativeNotificationInput } from '@/store/native-notifications'
|
||||
|
||||
/** A contribution as a plugin author writes it — provenance + id scoping are
|
||||
* the host's job, so those fields are off-limits here. */
|
||||
|
|
@ -54,6 +56,10 @@ export interface PluginContext {
|
|||
* returned. Resolves to a no-op on OAuth remotes — treat it as an
|
||||
* accelerator over your polling, never a replacement. */
|
||||
socket: (path: string, onMessage: (data: unknown) => void) => () => void
|
||||
/** Native OS notification (Electron), attributed to this plugin. Gated by
|
||||
* Settings ▸ Notifications ▸ "Plugin notifications" and fires only while
|
||||
* the user is away from Hermes — use `host.notify` for the in-app toast. */
|
||||
notifyNative: (input: PluginNativeNotificationInput) => void
|
||||
/** Plugin-scoped persistence. */
|
||||
storage: PluginStorage
|
||||
/** Plugin-scoped i18n: ship + register locale bundles under this plugin,
|
||||
|
|
@ -115,6 +121,7 @@ export function createPluginContext(pluginId: string, onDispose?: (dispose: () =
|
|||
onDispose: fn => void track(fn),
|
||||
rest: <T>(path: string, opts?: PluginRestOptions) => pluginRest<T>(pluginId, path, opts),
|
||||
socket: (path, onMessage) => track(pluginSocket(pluginId, path, onMessage)),
|
||||
notifyNative: input => dispatchPluginNativeNotification(pluginId, input),
|
||||
storage: createPluginStorage(pluginId),
|
||||
i18n: createPluginI18n(pluginId, track)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -769,6 +769,8 @@ export interface HermesNotification {
|
|||
silent?: boolean
|
||||
kind?: string
|
||||
sessionId?: string
|
||||
/** Dedupe discriminator for session-less notifications (e.g. plugin id). */
|
||||
tag?: string
|
||||
actions?: { id: string; text: string }[]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -395,6 +395,10 @@ export const en: Translations = {
|
|||
credits: {
|
||||
label: 'Credit alerts',
|
||||
description: 'Credit access is paused or restored.'
|
||||
},
|
||||
plugin: {
|
||||
label: 'Plugin notifications',
|
||||
description: 'A desktop plugin sent a notification while Hermes was in the background.'
|
||||
}
|
||||
},
|
||||
test: 'Send test notification',
|
||||
|
|
|
|||
|
|
@ -269,6 +269,10 @@ export const ja = defineLocale({
|
|||
credits: {
|
||||
label: 'クレジット通知',
|
||||
description: 'クレジットの利用が停止または復旧しました。'
|
||||
},
|
||||
plugin: {
|
||||
label: 'プラグイン通知',
|
||||
description: 'Hermes がバックグラウンドの間に、デスクトッププラグインが通知を送信しました。'
|
||||
}
|
||||
},
|
||||
test: 'テスト通知を送信',
|
||||
|
|
|
|||
|
|
@ -324,7 +324,7 @@ export interface Translations {
|
|||
enableAllDesc: string
|
||||
focusedHint: string
|
||||
kinds: Record<
|
||||
'approval' | 'backgroundDone' | 'credits' | 'input' | 'turnDone' | 'turnError',
|
||||
'approval' | 'backgroundDone' | 'credits' | 'input' | 'plugin' | 'turnDone' | 'turnError',
|
||||
{ label: string; description: string }
|
||||
>
|
||||
test: string
|
||||
|
|
|
|||
|
|
@ -263,6 +263,10 @@ export const zhHant = defineLocale({
|
|||
credits: {
|
||||
label: '額度提醒',
|
||||
description: '額度存取被暫停或恢復。'
|
||||
},
|
||||
plugin: {
|
||||
label: '外掛通知',
|
||||
description: 'Hermes 在背景時,桌面外掛傳送了通知。'
|
||||
}
|
||||
},
|
||||
test: '傳送測試通知',
|
||||
|
|
|
|||
|
|
@ -387,6 +387,10 @@ export const zh: Translations = {
|
|||
credits: {
|
||||
label: '额度提醒',
|
||||
description: '额度访问被暂停或恢复。'
|
||||
},
|
||||
plugin: {
|
||||
label: '插件通知',
|
||||
description: 'Hermes 在后台时,桌面插件发送了通知。'
|
||||
}
|
||||
},
|
||||
test: '发送测试通知',
|
||||
|
|
|
|||
|
|
@ -200,6 +200,7 @@ export type {
|
|||
HermesPlugin,
|
||||
PluginContext,
|
||||
PluginContribution,
|
||||
PluginNativeNotificationInput,
|
||||
PluginRestOptions,
|
||||
PluginStorage
|
||||
} from '@/contrib/plugin'
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||
import { $gateway } from './gateway'
|
||||
import {
|
||||
dispatchNativeNotification,
|
||||
dispatchPluginNativeNotification,
|
||||
NATIVE_NOTIFICATION_KINDS,
|
||||
respondToApprovalAction,
|
||||
sendTestNativeNotification,
|
||||
|
|
@ -170,6 +171,34 @@ describe('dispatchNativeNotification post-connect baseline', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('dispatchPluginNativeNotification', () => {
|
||||
it('fires while the user is away and tags the plugin id for dedupe', () => {
|
||||
dispatchPluginNativeNotification('index-network', { body: 'New match', title: 'Opportunity' })
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ body: 'New match', kind: 'plugin', tag: 'index-network', title: 'Opportunity' })
|
||||
)
|
||||
})
|
||||
|
||||
it('suppresses while the window is focused (the in-app toast covers foreground)', () => {
|
||||
setWindowState({ focused: true, hidden: false })
|
||||
dispatchPluginNativeNotification('focused-plugin', { title: 'Opportunity' })
|
||||
expect(notify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is gated by the "plugin" kind preference', () => {
|
||||
setNativeNotifyKind('plugin', false)
|
||||
dispatchPluginNativeNotification('muted-plugin', { title: 'Opportunity' })
|
||||
expect(notify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throttles per plugin, so two plugins cannot collapse each other', () => {
|
||||
dispatchPluginNativeNotification('plugin-a', { title: 'a' })
|
||||
dispatchPluginNativeNotification('plugin-a', { title: 'a again' })
|
||||
dispatchPluginNativeNotification('plugin-b', { title: 'b' })
|
||||
expect(notify).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispatchNativeNotification throttle', () => {
|
||||
it('collapses duplicate kind+session within the throttle window', () => {
|
||||
const sessionId = freshSession()
|
||||
|
|
|
|||
|
|
@ -9,7 +9,14 @@ import { $activeSessionId } from './session'
|
|||
|
||||
// Native OS notifications (Electron `Notification`), separate from the in-app
|
||||
// toast feed in `notifications.ts`. Each kind toggles independently.
|
||||
export type NativeNotificationKind = 'approval' | 'backgroundDone' | 'credits' | 'input' | 'turnDone' | 'turnError'
|
||||
export type NativeNotificationKind =
|
||||
| 'approval'
|
||||
| 'backgroundDone'
|
||||
| 'credits'
|
||||
| 'input'
|
||||
| 'plugin'
|
||||
| 'turnDone'
|
||||
| 'turnError'
|
||||
|
||||
export const NATIVE_NOTIFICATION_KINDS: readonly NativeNotificationKind[] = [
|
||||
'approval',
|
||||
|
|
@ -17,7 +24,8 @@ export const NATIVE_NOTIFICATION_KINDS: readonly NativeNotificationKind[] = [
|
|||
'turnDone',
|
||||
'turnError',
|
||||
'backgroundDone',
|
||||
'credits'
|
||||
'credits',
|
||||
'plugin'
|
||||
]
|
||||
|
||||
// Blocking prompts — surface even while focused if they're for another session.
|
||||
|
|
@ -32,7 +40,15 @@ const STORAGE_KEY = 'hermes:native-notifications'
|
|||
|
||||
const DEFAULT_PREFS: NativeNotificationPrefs = {
|
||||
enabled: true,
|
||||
kinds: { approval: true, backgroundDone: true, credits: true, input: true, turnDone: true, turnError: true }
|
||||
kinds: {
|
||||
approval: true,
|
||||
backgroundDone: true,
|
||||
credits: true,
|
||||
input: true,
|
||||
plugin: true,
|
||||
turnDone: true,
|
||||
turnError: true
|
||||
}
|
||||
}
|
||||
|
||||
function readPrefs(): NativeNotificationPrefs {
|
||||
|
|
@ -152,6 +168,12 @@ export interface NativeNotificationInput {
|
|||
global?: boolean
|
||||
silent?: boolean
|
||||
actions?: NativeNotificationAction[]
|
||||
/**
|
||||
* Extra throttle/dedupe discriminator for session-less notifications (e.g.
|
||||
* the plugin id), so unrelated emitters of the same kind don't collapse
|
||||
* into one another. Never drives click-to-focus like `sessionId` does.
|
||||
*/
|
||||
tag?: string
|
||||
}
|
||||
|
||||
export function dispatchNativeNotification(input: NativeNotificationInput): void {
|
||||
|
|
@ -169,7 +191,7 @@ export function dispatchNativeNotification(input: NativeNotificationInput): void
|
|||
return
|
||||
}
|
||||
|
||||
if (throttled(`${input.kind}:${input.sessionId ?? (input.global ? 'global' : '')}`, Date.now())) {
|
||||
if (throttled(`${input.kind}:${input.sessionId ?? input.tag ?? (input.global ? 'global' : '')}`, Date.now())) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -179,10 +201,28 @@ export function dispatchNativeNotification(input: NativeNotificationInput): void
|
|||
kind: input.kind,
|
||||
sessionId: input.sessionId ?? undefined,
|
||||
silent: input.silent,
|
||||
tag: input.tag,
|
||||
title: input.title
|
||||
})
|
||||
}
|
||||
|
||||
// -- the plugin door (`ctx.notifyNative`) -------------------------------------
|
||||
|
||||
export interface PluginNativeNotificationInput {
|
||||
title: string
|
||||
body?: string
|
||||
silent?: boolean
|
||||
}
|
||||
|
||||
/** Native OS notification on behalf of a plugin. One "Plugin notifications"
|
||||
* preference gates all plugins; the plugin id keys throttling/dedupe so two
|
||||
* plugins can't collapse each other's notifications. Fires only while the
|
||||
* user is away from Hermes — the in-app toast (`host.notify`) covers the
|
||||
* foreground case. */
|
||||
export function dispatchPluginNativeNotification(pluginId: string, input: PluginNativeNotificationInput): void {
|
||||
dispatchNativeNotification({ ...input, global: true, kind: 'plugin', tag: pluginId })
|
||||
}
|
||||
|
||||
// Resolve a pending approval from a notification button, mirroring the in-app
|
||||
// Run/Reject bar. Keyed by session id — a background approval has no local guard.
|
||||
export async function respondToApprovalAction(sessionId: null | string, actionId: string): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -71,6 +71,11 @@ The ONLY import surface is `@hermes/plugin-sdk` (plus `react` /
|
|||
(renders below Artifacts, lights up at the route) — and/or a
|
||||
`PALETTE_AREA` command calling `host.navigate('/my-page')`.
|
||||
- `ctx.storage.get/set/remove` — persistence namespaced to your plugin.
|
||||
- `ctx.notifyNative({ title, body?, silent? })` — native OS notification
|
||||
attributed to your plugin. Fires only while the user is away from Hermes
|
||||
(use `host.notify` for the in-app toast); gated by Settings ▸ Notifications ▸
|
||||
"Plugin notifications" and throttled per plugin — reserve it for genuinely
|
||||
notable events.
|
||||
- `ctx.i18n.register({ en, ja, ... })` — ship your OWN locale bundles, scoped
|
||||
to your plugin (never edit core `en.ts`). Values are literal strings or
|
||||
interpolator functions; nested trees are addressed by dot-path. Read them
|
||||
|
|
|
|||
|
|
@ -168,6 +168,8 @@ interface PluginContext {
|
|||
rest: <T>(path: string, opts?: PluginRestOptions) => Promise<T>
|
||||
/** Live WebSocket to this plugin's own namespace. Returns a disposer. */
|
||||
socket: (path: string, onMessage: (data: unknown) => void) => () => void
|
||||
/** Native OS notification (Electron), attributed to this plugin. */
|
||||
notifyNative: (input: { title: string; body?: string; silent?: boolean }) => void
|
||||
/** Plugin-scoped JSON persistence (keys live under `hermes.plugin.<id>.`). */
|
||||
storage: PluginStorage
|
||||
}
|
||||
|
|
@ -370,6 +372,7 @@ host.state.viewport // ReadableAtom<{ width, height, narrow }>
|
|||
|
||||
host.notify({ kind, message, title?, detail?, action? }) // toast; returns id
|
||||
host.notifyError(error, fallbackMessage) // toast an error
|
||||
ctx.notifyNative({ title, body?, silent? }) // native OS notification
|
||||
host.navigate('/route') // hash-route navigation
|
||||
host.onEvent(type, fn) // gateway event stream ('*' = all); returns disposer
|
||||
host.logs(...) // tail an app log file
|
||||
|
|
@ -385,6 +388,14 @@ listener can't affect app dispatch. Every `host` door is async-safe: a sync thro
|
|||
from an internal helper (e.g. no desktop bridge in a plain browser) becomes a
|
||||
rejection your `.catch()` sees, never an error-boundary crash.
|
||||
|
||||
`ctx.notifyNative` (on the plugin context, so the notification is attributed to
|
||||
your plugin) posts a **native OS notification** — the same Electron pipeline the
|
||||
app's own approval/turn alerts use. It fires only while the user is away from
|
||||
Hermes (backgrounded / unfocused); use `host.notify` for the in-app toast when
|
||||
they're looking at the app. Users can silence it per device under Settings ▸
|
||||
Notifications ▸ "Plugin notifications", and repeats from the same plugin are
|
||||
throttled, so treat it as a signal for genuinely notable events — not a log.
|
||||
|
||||
## Data layer — React Query + nanostores
|
||||
|
||||
Plugins share the app's single `QueryClient`, so plugin queries cache, dedupe,
|
||||
|
|
@ -597,7 +608,7 @@ not treat this pipeline as a trust boundary.
|
|||
| Category | Exports |
|
||||
|----------|---------|
|
||||
| Host | `host` (`.state.*`, `.notify`, `.notifyError`, `.navigate`, `.onEvent`, `.logs`, `.status`, `.restartGateway`, `.request`) |
|
||||
| Plugin contract | `HermesPlugin`, `PluginContext`, `PluginContribution`, `PluginStorage`, `PluginRestOptions`, `Contribution` |
|
||||
| Plugin contract | `HermesPlugin`, `PluginContext`, `PluginContribution`, `PluginStorage`, `PluginRestOptions`, `PluginNativeNotificationInput`, `Contribution` |
|
||||
| Area constants | `PANES_AREA`, `ROUTES_AREA`, `SIDEBAR_NAV_AREA`, `STATUSBAR_AREAS`, `TITLEBAR_AREAS`, `PALETTE_AREA`, `KEYBINDS_AREA`, `THEMES_AREA`, `COMPOSER_AREAS` |
|
||||
| Area payloads | `RouteContribution`, `SidebarNavContribution`, `StatusbarItem`, `TitlebarTool`, `PaletteContribution`, `KeybindContribution`, `ComposerMiddleware`, `ComposerAttachmentProvider` |
|
||||
| React / state | `useValue`, `atom`, `computed`, `useQuery`, `useMutation`, `useQueryClient`, `queryClient`, `Contribute` |
|
||||
|
|
|
|||
Loading…
Reference in New Issue