feat(desktop): rich plugin OS notifications with deeplink activation
Extends ctx.os.notify (the curated plugin OS door from #78685) with icon, action buttons, and a serializable `activate` target. Body/action clicks focus the window and navigate to the plugin's screen; activation paths share one resolver (hermes-open-target.ts) with hermes:// OS deep links, so `hermes://index-network/intent/1`, `/index-network/intent/1`, and { path, params } all land on the same hash-router route. Approval notifications keep their existing session-scoped channel. Salvaged from PR #84192 by @serefyarar (net diff of the PR branch applied onto current main; branch carried merge commits so a single authored commit preserves attribution).
This commit is contained in:
@@ -13636,11 +13636,13 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
|
||||
// Action buttons render only on signed macOS builds; elsewhere they're dropped
|
||||
// and the body click still works.
|
||||
const actions = Array.isArray(payload?.actions) ? payload.actions : []
|
||||
const icon = typeof payload?.icon === 'string' && payload.icon.trim() ? payload.icon.trim() : undefined
|
||||
|
||||
const notification = new Notification({
|
||||
title: payload?.title || 'Hermes',
|
||||
body: payload?.body || '',
|
||||
silent: Boolean(payload?.silent),
|
||||
...(icon ? { icon } : {}),
|
||||
actions: actions.map(action => ({ type: 'button', text: String(action?.text || '') }))
|
||||
})
|
||||
|
||||
@@ -13654,6 +13656,16 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
|
||||
if (payload?.sessionId) {
|
||||
mainWindow.webContents.send('hermes:focus-session', payload.sessionId)
|
||||
}
|
||||
|
||||
// Plugin / session-less activation — serializable path (+ optional notifyId
|
||||
// for renderer callbacks). Same vocabulary as hermes://index-network/….
|
||||
if (payload?.activate || payload?.notifyId) {
|
||||
mainWindow.webContents.send('hermes:notification-activate', {
|
||||
activate: payload?.activate,
|
||||
notifyId: payload?.notifyId,
|
||||
tag: payload?.tag
|
||||
})
|
||||
}
|
||||
})
|
||||
notification.on('action', (_actionEvent, index) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
@@ -13662,9 +13674,24 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
|
||||
|
||||
const action = actions[index]
|
||||
|
||||
if (action?.id) {
|
||||
mainWindow.webContents.send('hermes:notification-action', { sessionId: payload?.sessionId, actionId: action.id })
|
||||
if (!action?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
// Approvals keep the existing session-scoped channel.
|
||||
if (payload?.sessionId && !payload?.notifyId && !payload?.activate) {
|
||||
mainWindow.webContents.send('hermes:notification-action', { sessionId: payload.sessionId, actionId: action.id })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
focusWindow(mainWindow)
|
||||
mainWindow.webContents.send('hermes:notification-activate', {
|
||||
actionId: action.id,
|
||||
activate: action.activate || payload?.activate,
|
||||
notifyId: payload?.notifyId,
|
||||
tag: payload?.tag
|
||||
})
|
||||
})
|
||||
notification.show()
|
||||
|
||||
|
||||
@@ -341,6 +341,12 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:notification-action', listener)
|
||||
},
|
||||
onNotificationActivate: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:notification-activate', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:notification-activate', listener)
|
||||
},
|
||||
onPreviewFileChanged: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:preview-file-changed', listener)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { requestMcpInstallFromDeepLink } from '@/store/mcp-deeplink-install'
|
||||
import { _resetLegacyDiscardForTests } from '@/store/session'
|
||||
import type * as WindowsStore from '@/store/windows'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
@@ -12,6 +13,10 @@ import { useDesktopIntegrations } from './use-desktop-integrations'
|
||||
// coverage exercising the real main-window path.
|
||||
const { hudWindowMock } = vi.hoisted(() => ({ hudWindowMock: vi.fn(() => false) }))
|
||||
|
||||
vi.mock('@/store/mcp-deeplink-install', () => ({
|
||||
requestMcpInstallFromDeepLink: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/store/windows', async importOriginal => {
|
||||
const actual = await importOriginal<typeof WindowsStore>()
|
||||
|
||||
@@ -56,6 +61,7 @@ describe('useDesktopIntegrations', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
_resetLegacyDiscardForTests()
|
||||
vi.mocked(requestMcpInstallFromDeepLink).mockClear()
|
||||
navigate = vi.fn()
|
||||
// Every test starts as a main window; only the HUD describe flips this.
|
||||
hudWindowMock.mockReturnValue(false)
|
||||
@@ -68,6 +74,7 @@ describe('useDesktopIntegrations', () => {
|
||||
onOpenUpdatesRequested: vi.fn(),
|
||||
onFocusSession: vi.fn(),
|
||||
onNotificationAction: vi.fn(),
|
||||
onNotificationActivate: vi.fn(),
|
||||
onDeepLink: vi.fn(),
|
||||
signalDeepLinkReady: vi.fn(),
|
||||
onClosePreviewRequested: vi.fn(),
|
||||
@@ -466,4 +473,57 @@ describe('useDesktopIntegrations', () => {
|
||||
expect(window.localStorage.getItem('hermes.desktop.lastSessionId.profile.default')).toBe('other-session')
|
||||
})
|
||||
})
|
||||
|
||||
describe('notification activate + plugin deep links', () => {
|
||||
it('navigates when a plugin notification activate payload arrives', () => {
|
||||
let activate: ((payload: { activate?: string }) => void) | undefined
|
||||
desktopWindow.hermesDesktop = {
|
||||
...desktopWindow.hermesDesktop,
|
||||
onNotificationActivate: (cb: (payload: { activate?: string }) => void) => {
|
||||
activate = cb
|
||||
|
||||
return () => undefined
|
||||
}
|
||||
} as unknown as Window['hermesDesktop']
|
||||
|
||||
render({ profileReady: true, sessions: [] })
|
||||
activate?.({ activate: '/index-network/intent/1' })
|
||||
expect(navigate).toHaveBeenCalledWith('/index-network/intent/1')
|
||||
})
|
||||
|
||||
it('navigates hermes://index-network/intent/1 deep links through the same path vocabulary', () => {
|
||||
let deepLink: ((payload: { kind: string; name: string; params: Record<string, string> }) => void) | undefined
|
||||
desktopWindow.hermesDesktop = {
|
||||
...desktopWindow.hermesDesktop,
|
||||
onDeepLink: (cb: (payload: { kind: string; name: string; params: Record<string, string> }) => void) => {
|
||||
deepLink = cb
|
||||
|
||||
return () => undefined
|
||||
},
|
||||
signalDeepLinkReady: vi.fn()
|
||||
} as unknown as Window['hermesDesktop']
|
||||
|
||||
render({ profileReady: true, sessions: [] })
|
||||
deepLink?.({ kind: 'index-network', name: 'intent/1', params: {} })
|
||||
expect(navigate).toHaveBeenCalledWith('/index-network/intent/1')
|
||||
})
|
||||
|
||||
it('routes hermes://mcp/install to the pending-install dialog, not navigation', () => {
|
||||
let deepLink: ((payload: { kind: string; name: string; params: Record<string, string> }) => void) | undefined
|
||||
desktopWindow.hermesDesktop = {
|
||||
...desktopWindow.hermesDesktop,
|
||||
onDeepLink: (cb: (payload: { kind: string; name: string; params: Record<string, string> }) => void) => {
|
||||
deepLink = cb
|
||||
|
||||
return () => undefined
|
||||
},
|
||||
signalDeepLinkReady: vi.fn()
|
||||
} as unknown as Window['hermesDesktop']
|
||||
|
||||
render({ profileReady: true, sessions: [] })
|
||||
deepLink?.({ kind: 'mcp', name: 'install', params: { name: 'context7' } })
|
||||
expect(requestMcpInstallFromDeepLink).toHaveBeenCalledWith({ name: 'context7' })
|
||||
expect(navigate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,10 +3,16 @@ import { useEffect, useRef } from 'react'
|
||||
import { closeActiveTab } from '@/app/chat/close-tab'
|
||||
import { commandFocusedPreview } from '@/app/chat/right-rail/preview-nav'
|
||||
import { openSession } from '@/app/open-session'
|
||||
import { pathFromHermesDeepLink } from '@/lib/hermes-open-target'
|
||||
import { storedSessionIdForNotification } from '@/lib/session-ids'
|
||||
import { requestMcpInstallFromDeepLink } from '@/store/mcp-deeplink-install'
|
||||
import { startMcpHealthChecker, stopMcpHealthChecker } from '@/store/mcp-health'
|
||||
import { respondToApprovalAction } from '@/store/native-notifications'
|
||||
import {
|
||||
clearPluginNotifyHandlers,
|
||||
invokePluginNotifyAction,
|
||||
invokePluginNotifyActivate,
|
||||
respondToApprovalAction
|
||||
} from '@/store/native-notifications'
|
||||
import { openFolderAsProject } from '@/store/projects'
|
||||
import {
|
||||
getRememberedRoute,
|
||||
@@ -194,12 +200,39 @@ export function useDesktopIntegrations({
|
||||
return () => unsubscribe?.()
|
||||
}, [])
|
||||
|
||||
// hermes:// deep links -> a reviewable /blueprint command in the composer,
|
||||
// or (hermes://mcp/install) a pending MCP install awaiting explicit
|
||||
// confirmation in McpInstallDeepLinkDialog. Never auto-installs.
|
||||
// Plugin OS notification body/action → optional callback + navigate. Activation
|
||||
// is user-driven (click), so this is offer-not-hijack. Paths share the
|
||||
// hermes://index-network/intent/1 vocabulary with deep links.
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.hermesDesktop?.onNotificationActivate?.(payload => {
|
||||
if (!payload) {
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.actionId) {
|
||||
invokePluginNotifyAction(payload.notifyId, payload.actionId)
|
||||
} else {
|
||||
invokePluginNotifyActivate(payload.notifyId)
|
||||
}
|
||||
|
||||
if (payload.activate) {
|
||||
navigate(payload.activate)
|
||||
}
|
||||
|
||||
clearPluginNotifyHandlers(payload.notifyId)
|
||||
})
|
||||
|
||||
return () => unsubscribe?.()
|
||||
}, [navigate])
|
||||
|
||||
// hermes:// deep links:
|
||||
// - mcp/install?… → pending MCP install (explicit confirm, never auto-install)
|
||||
// - <plugin>/<path>?… → in-app navigate (e.g. index-network/intent/1)
|
||||
// - open/<path>?… → in-app navigate (generic)
|
||||
// - blueprint/<name>?… → reviewable /blueprint command in the composer
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.hermesDesktop?.onDeepLink?.(payload => {
|
||||
if (!payload) {
|
||||
if (!payload?.kind) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -209,27 +242,37 @@ export function useDesktopIntegrations({
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.kind !== 'blueprint' || !payload.name) {
|
||||
if (payload.kind === 'blueprint') {
|
||||
if (!payload.name) {
|
||||
return
|
||||
}
|
||||
|
||||
const slots = Object.entries(payload.params || {})
|
||||
.map(([k, v]) => {
|
||||
const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v
|
||||
|
||||
return `${k}=${sval}`
|
||||
})
|
||||
.join(' ')
|
||||
|
||||
const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}`
|
||||
requestComposerInsert(command, { mode: 'block', target: 'main' })
|
||||
requestComposerFocus('main')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const slots = Object.entries(payload.params || {})
|
||||
.map(([k, v]) => {
|
||||
const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v
|
||||
const path = pathFromHermesDeepLink(payload.kind, payload.name || '', payload.params || {})
|
||||
|
||||
return `${k}=${sval}`
|
||||
})
|
||||
.join(' ')
|
||||
|
||||
const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}`
|
||||
requestComposerInsert(command, { mode: 'block', target: 'main' })
|
||||
requestComposerFocus('main')
|
||||
if (path) {
|
||||
navigate(path)
|
||||
}
|
||||
})
|
||||
|
||||
void window.hermesDesktop?.signalDeepLinkReady?.()
|
||||
|
||||
return () => unsubscribe?.()
|
||||
}, [])
|
||||
}, [navigate])
|
||||
|
||||
// ⌘W via the macOS menu accelerator → close the focused tab; if nothing is
|
||||
// closeable, fall back to closing the window (so ⌘W still works as the
|
||||
|
||||
@@ -21,7 +21,11 @@ import { registry } from './registry'
|
||||
import type { Contribution } from './types'
|
||||
|
||||
export type { PluginRestOptions } from '@/hermes'
|
||||
export type { PluginNativeNotificationInput } from '@/store/native-notifications'
|
||||
export type { HermesOpenTarget } from '@/lib/hermes-open-target'
|
||||
export type {
|
||||
PluginNativeNotificationInput,
|
||||
PluginNotificationAction
|
||||
} 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. */
|
||||
@@ -44,7 +48,9 @@ export interface PluginOs {
|
||||
/** 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.
|
||||
* Throttled per plugin; reserve it for genuinely notable events. */
|
||||
* Throttled per plugin; reserve it for genuinely notable events.
|
||||
* Supports `icon`, `activate` (e.g. `hermes://index-network/intent/1`),
|
||||
* action buttons, and renderer `onActivate` / `onAction` callbacks. */
|
||||
notify: (input: PluginNativeNotificationInput) => void
|
||||
/** Open a URL with the OS default handler (browser, mail client, custom
|
||||
* schemes like `spotify:`). Resolves false when the shell can't. */
|
||||
|
||||
Vendored
+16
-1
@@ -362,6 +362,15 @@ declare global {
|
||||
onWindowStateChanged?: (callback: (payload: HermesWindowState) => void) => () => void
|
||||
onFocusSession?: (callback: (sessionId: string) => void) => () => void
|
||||
onNotificationAction?: (callback: (payload: { actionId: string; sessionId?: string }) => void) => () => void
|
||||
/** Plugin (and other session-less) notification body/action activation. */
|
||||
onNotificationActivate?: (
|
||||
callback: (payload: {
|
||||
actionId?: string
|
||||
activate?: string
|
||||
notifyId?: string
|
||||
tag?: string
|
||||
}) => void
|
||||
) => () => void
|
||||
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
|
||||
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
|
||||
// Soft gateway-mode apply: primary backend was torn down without a window
|
||||
@@ -1052,7 +1061,13 @@ export interface HermesNotification {
|
||||
sessionId?: string
|
||||
/** Dedupe discriminator for session-less notifications (e.g. plugin id). */
|
||||
tag?: string
|
||||
actions?: { id: string; text: string }[]
|
||||
/** Absolute icon path for Electron `Notification`. */
|
||||
icon?: string
|
||||
/** Resolved hash-router path opened on body click (plugin / deeplink-compatible). */
|
||||
activate?: string
|
||||
/** Renderer handle for onActivate / onAction callbacks. */
|
||||
notifyId?: string
|
||||
actions?: { id: string; text: string; activate?: string }[]
|
||||
}
|
||||
|
||||
export interface HermesPreviewTarget {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
normalizeHermesOpenString,
|
||||
pathFromHermesDeepLink,
|
||||
pathFromOpenDeepLink,
|
||||
resolveHermesOpenPath
|
||||
} from './hermes-open-target'
|
||||
|
||||
describe('normalizeHermesOpenString', () => {
|
||||
it('accepts hash-router paths and strips a leading hash', () => {
|
||||
expect(normalizeHermesOpenString('/index-network/intent/1')).toBe('/index-network/intent/1')
|
||||
expect(normalizeHermesOpenString('#/index-network/intent/1')).toBe('/index-network/intent/1')
|
||||
})
|
||||
|
||||
it('maps plugin-scoped hermes:// deep links to the same path', () => {
|
||||
expect(normalizeHermesOpenString('hermes://index-network/intent/1')).toBe('/index-network/intent/1')
|
||||
expect(normalizeHermesOpenString('hermes://index-network/intent/1?focus=true')).toBe(
|
||||
'/index-network/intent/1?focus=true'
|
||||
)
|
||||
})
|
||||
|
||||
it('maps hermes://open/… deep links by stripping the open host', () => {
|
||||
expect(normalizeHermesOpenString('hermes://open/index-network/intent/1')).toBe('/index-network/intent/1')
|
||||
expect(normalizeHermesOpenString('hermes://open/settings/plugins')).toBe('/settings/plugins')
|
||||
})
|
||||
|
||||
it('rejects reserved hermes kinds and unsafe paths', () => {
|
||||
expect(normalizeHermesOpenString('hermes://blueprint/morning-brief')).toBeNull()
|
||||
expect(normalizeHermesOpenString('hermes://plugin/install')).toBeNull()
|
||||
expect(normalizeHermesOpenString('https://example.com/x')).toBeNull()
|
||||
expect(normalizeHermesOpenString('/../etc/passwd')).toBeNull()
|
||||
expect(normalizeHermesOpenString('index-network')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveHermesOpenPath', () => {
|
||||
it('merges structured path + params', () => {
|
||||
expect(resolveHermesOpenPath({ path: '/index-network/intent/1', params: { focus: 'true' } })).toBe(
|
||||
'/index-network/intent/1?focus=true'
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves href the same as a bare string', () => {
|
||||
expect(resolveHermesOpenPath({ href: 'hermes://index-network/intent/1' })).toBe('/index-network/intent/1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pathFromHermesDeepLink', () => {
|
||||
it('builds the navigate path from a plugin-scoped deep-link payload', () => {
|
||||
expect(pathFromHermesDeepLink('index-network', 'intent/1')).toBe('/index-network/intent/1')
|
||||
})
|
||||
|
||||
it('builds the navigate path from hermes://open/… payloads', () => {
|
||||
expect(pathFromOpenDeepLink('index-network/intent/1')).toBe('/index-network/intent/1')
|
||||
expect(pathFromHermesDeepLink('open', 'agent/42')).toBe('/agent/42')
|
||||
})
|
||||
|
||||
it('ignores reserved kinds', () => {
|
||||
expect(pathFromHermesDeepLink('blueprint', 'morning-brief')).toBeNull()
|
||||
expect(pathFromHermesDeepLink('plugin', 'install')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Shared resolver for in-app navigation targets that must stay compatible with
|
||||
* `hermes://` deep links and `host.navigate('/path?…')`.
|
||||
*
|
||||
* Notification activation, deep-link delivery, and plugin `activate` payloads
|
||||
* all funnel through here so a toast click and an OS deep link land on the
|
||||
* same hash-router path.
|
||||
*
|
||||
* Supported deep-link shapes:
|
||||
* - `hermes://index-network/intent/1` → `/index-network/intent/1` (plugin-scoped)
|
||||
* - `hermes://open/my-page?item=x` → `/my-page?item=x` (generic open)
|
||||
* - `/my-page?item=x` / `#/my-page?item=x` (hash-router paths)
|
||||
*/
|
||||
|
||||
export type HermesOpenTarget =
|
||||
| string
|
||||
| { href: string }
|
||||
| { path: string; params?: Record<string, string> }
|
||||
|
||||
const HERMES_PROTOCOL = 'hermes:'
|
||||
|
||||
/** Hostnames owned by core deep-link handlers — never treated as plugin routes. */
|
||||
const RESERVED_DEEP_LINK_KINDS = new Set([
|
||||
'blueprint',
|
||||
'chat',
|
||||
'install',
|
||||
'mcp',
|
||||
'open',
|
||||
'plugin',
|
||||
'plugin-agent',
|
||||
'plugin-desktop',
|
||||
'settings'
|
||||
])
|
||||
|
||||
function appendSearch(path: string, params: URLSearchParams | Record<string, string> | undefined): string {
|
||||
if (!params) {
|
||||
return path
|
||||
}
|
||||
|
||||
const search =
|
||||
params instanceof URLSearchParams
|
||||
? params
|
||||
: new URLSearchParams(Object.entries(params).filter(([, v]) => v != null && v !== ''))
|
||||
|
||||
const qs = search.toString()
|
||||
|
||||
if (!qs) {
|
||||
return path
|
||||
}
|
||||
|
||||
return path.includes('?') ? `${path}&${qs}` : `${path}?${qs}`
|
||||
}
|
||||
|
||||
function isSafeAppPath(path: string): boolean {
|
||||
if (!path.startsWith('/') || path.startsWith('//')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Block traversal and scheme smuggling in the path segment.
|
||||
if (path.includes('..') || path.includes('\\') || path.includes(':')) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function isPluginDeepLinkHost(host: string): boolean {
|
||||
return /^[a-z0-9][a-z0-9-]*$/.test(host) && !RESERVED_DEEP_LINK_KINDS.has(host)
|
||||
}
|
||||
|
||||
/** Normalize a string target to a hash-router path, or null. */
|
||||
export function normalizeHermesOpenString(raw: string): string | null {
|
||||
const trimmed = raw.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('hermes://') || trimmed.startsWith(`${HERMES_PROTOCOL}//`)) {
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
const host = url.hostname || ''
|
||||
const rest = decodeURIComponent((url.pathname || '').replace(/^\//, ''))
|
||||
|
||||
// hermes://open/<path>?… → /<path>?…
|
||||
if (host === 'open') {
|
||||
if (!rest) {
|
||||
return null
|
||||
}
|
||||
|
||||
const path = `/${rest}`
|
||||
|
||||
if (!isSafeAppPath(path.split('?')[0] ?? path)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return appendSearch(path, url.searchParams)
|
||||
}
|
||||
|
||||
// hermes://index-network/intent/1 → /index-network/intent/1
|
||||
if (!isPluginDeepLinkHost(host) || !rest) {
|
||||
return null
|
||||
}
|
||||
|
||||
const path = `/${host}/${rest}`
|
||||
|
||||
if (!isSafeAppPath(path.split('?')[0] ?? path)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return appendSearch(path, url.searchParams)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const path = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed
|
||||
|
||||
if (!isSafeAppPath(path.split('?')[0] ?? path)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
/** Resolve any supported activate/open target to a hash-router path, or null. */
|
||||
export function resolveHermesOpenPath(target: HermesOpenTarget | null | undefined): string | null {
|
||||
if (target == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof target === 'string') {
|
||||
return normalizeHermesOpenString(target)
|
||||
}
|
||||
|
||||
if (typeof target !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
if ('href' in target && typeof target.href === 'string') {
|
||||
return normalizeHermesOpenString(target.href)
|
||||
}
|
||||
|
||||
if ('path' in target && typeof target.path === 'string') {
|
||||
const base = normalizeHermesOpenString(target.path)
|
||||
|
||||
if (!base) {
|
||||
return null
|
||||
}
|
||||
|
||||
return appendSearch(base, target.params)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a navigate path from a parsed deep-link payload
|
||||
* (`hermes://<kind>/<name>?…` → kind/name/params).
|
||||
*/
|
||||
export function pathFromHermesDeepLink(
|
||||
kind: string,
|
||||
name: string,
|
||||
params: Record<string, string> = {}
|
||||
): string | null {
|
||||
if (!kind || !name) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (kind === 'open') {
|
||||
return resolveHermesOpenPath({ path: `/${name.replace(/^\//, '')}`, params })
|
||||
}
|
||||
|
||||
if (!isPluginDeepLinkHost(kind)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return resolveHermesOpenPath({ path: `/${kind}/${name.replace(/^\//, '')}`, params })
|
||||
}
|
||||
|
||||
/** Convenience for `hermes://open/<name>?…` payloads. */
|
||||
export function pathFromOpenDeepLink(name: string, params: Record<string, string> = {}): string | null {
|
||||
return pathFromHermesDeepLink('open', name, params)
|
||||
}
|
||||
@@ -794,6 +794,7 @@ export type {
|
||||
PluginContext,
|
||||
PluginContribution,
|
||||
PluginNativeNotificationInput,
|
||||
PluginNotificationAction,
|
||||
PluginOs,
|
||||
PluginRestOptions,
|
||||
PluginStorage
|
||||
@@ -836,6 +837,7 @@ export { type BudgetedLoop, type BudgetedLoopOptions, createBudgetedLoop } from
|
||||
* through here (1230 → "1.2k", 1_500_000 → "1.5M"). Don't hand-roll `/1000`. */
|
||||
export { compactNumber } from '@/lib/format'
|
||||
export { triggerHaptic as haptic } from '@/lib/haptics'
|
||||
export type { HermesOpenTarget } from '@/lib/hermes-open-target'
|
||||
/** The app's lucide icon set (RefreshCw, LayoutDashboard, Activity, …). */
|
||||
export * as icons from '@/lib/icons'
|
||||
export { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
|
||||
|
||||
@@ -2,8 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $gateway } from './gateway'
|
||||
import {
|
||||
clearPluginNotifyHandlers,
|
||||
dispatchNativeNotification,
|
||||
dispatchPluginNativeNotification,
|
||||
invokePluginNotifyAction,
|
||||
invokePluginNotifyActivate,
|
||||
NATIVE_NOTIFICATION_KINDS,
|
||||
respondToApprovalAction,
|
||||
sendTestNativeNotification,
|
||||
@@ -49,6 +52,8 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearPluginNotifyHandlers()
|
||||
|
||||
if (initialHermesDesktop) {
|
||||
desktopWindow.hermesDesktop = initialHermesDesktop
|
||||
} else {
|
||||
@@ -197,6 +202,54 @@ describe('dispatchPluginNativeNotification', () => {
|
||||
dispatchPluginNativeNotification('plugin-b', { title: 'b' })
|
||||
expect(notify).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('forwards icon, resolved activate path, and action buttons (deeplink-compatible)', () => {
|
||||
// Unique tag (throttle is per plugin id); activate still uses the plugin deep link.
|
||||
dispatchPluginNativeNotification('index-network-alerts', {
|
||||
actions: [
|
||||
{ id: 'open', label: 'Open', activate: 'hermes://index-network/intent/1' },
|
||||
{ id: 'dismiss', label: 'Dismiss', onAction: () => undefined }
|
||||
],
|
||||
activate: 'hermes://index-network/intent/1',
|
||||
body: 'New match',
|
||||
icon: '/tmp/index-network.png',
|
||||
title: 'Opportunity'
|
||||
})
|
||||
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
activate: '/index-network/intent/1',
|
||||
actions: [
|
||||
{ activate: '/index-network/intent/1', id: 'open', text: 'Open' },
|
||||
{ activate: undefined, id: 'dismiss', text: 'Dismiss' }
|
||||
],
|
||||
icon: '/tmp/index-network.png',
|
||||
kind: 'plugin',
|
||||
notifyId: expect.stringMatching(/^index-network-alerts:/),
|
||||
tag: 'index-network-alerts',
|
||||
title: 'Opportunity'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('registers onActivate / onAction handlers keyed by notifyId', () => {
|
||||
const onActivate = vi.fn()
|
||||
const onAction = vi.fn()
|
||||
|
||||
dispatchPluginNativeNotification('handlers-plugin', {
|
||||
activate: 'hermes://index-network/intent/1',
|
||||
onActivate,
|
||||
actions: [{ id: 'dismiss', label: 'Dismiss', onAction }],
|
||||
title: 'Opportunity'
|
||||
})
|
||||
|
||||
const payload = notify.mock.calls[0]?.[0] as { notifyId?: string }
|
||||
expect(payload.notifyId).toBeTruthy()
|
||||
invokePluginNotifyActivate(payload.notifyId)
|
||||
expect(onActivate).toHaveBeenCalledTimes(1)
|
||||
expect(invokePluginNotifyAction(payload.notifyId, 'dismiss')).toBe(true)
|
||||
expect(onAction).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispatchNativeNotification throttle', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { type HermesOpenTarget, resolveHermesOpenPath } from '@/lib/hermes-open-target'
|
||||
import { persistString, storedString } from '@/lib/storage'
|
||||
|
||||
import { $gateway } from './gateway'
|
||||
@@ -7,6 +8,8 @@ import { withinNativeNotifyBaseline } from './notify-baseline'
|
||||
import { clearApprovalRequest } from './prompts'
|
||||
import { $activeSessionId } from './session'
|
||||
|
||||
export type { HermesOpenTarget }
|
||||
|
||||
// Native OS notifications (Electron `Notification`), separate from the in-app
|
||||
// toast feed in `notifications.ts`. Each kind toggles independently.
|
||||
export type NativeNotificationKind =
|
||||
@@ -147,6 +150,8 @@ function shouldFire(kind: NativeNotificationKind, sessionId?: null | string, glo
|
||||
export interface NativeNotificationAction {
|
||||
id: string
|
||||
text: string
|
||||
/** Serializable activate target echoed back on button press (plugin path). */
|
||||
activate?: string
|
||||
}
|
||||
|
||||
export interface NativeNotificationInput {
|
||||
@@ -168,6 +173,15 @@ export interface NativeNotificationInput {
|
||||
* into one another. Never drives click-to-focus like `sessionId` does.
|
||||
*/
|
||||
tag?: string
|
||||
/** Absolute file path for the OS notification icon (Electron). */
|
||||
icon?: string
|
||||
/**
|
||||
* Resolved hash-router path to open on body click when there is no
|
||||
* `sessionId` (plugins). Same vocabulary as `hermes://index-network/intent/1`.
|
||||
*/
|
||||
activate?: string
|
||||
/** Renderer-side handle so click/action can invoke registered callbacks. */
|
||||
notifyId?: string
|
||||
}
|
||||
|
||||
export function dispatchNativeNotification(input: NativeNotificationInput): void {
|
||||
@@ -191,8 +205,11 @@ export function dispatchNativeNotification(input: NativeNotificationInput): void
|
||||
|
||||
void window.hermesDesktop?.notify({
|
||||
actions: input.actions,
|
||||
activate: input.activate,
|
||||
body: input.body,
|
||||
icon: input.icon,
|
||||
kind: input.kind,
|
||||
notifyId: input.notifyId,
|
||||
sessionId: input.sessionId ?? undefined,
|
||||
silent: input.silent,
|
||||
tag: input.tag,
|
||||
@@ -202,10 +219,79 @@ export function dispatchNativeNotification(input: NativeNotificationInput): void
|
||||
|
||||
// -- the plugin door (`ctx.os.notify`) ----------------------------------------
|
||||
|
||||
export interface PluginNotificationAction {
|
||||
id: string
|
||||
label: string
|
||||
/** Navigate here on button press (path or `hermes://index-network/intent/1`). */
|
||||
activate?: HermesOpenTarget
|
||||
/** Renderer callback — only `id` crosses IPC; this stays in-process. */
|
||||
onAction?: () => void
|
||||
}
|
||||
|
||||
export interface PluginNativeNotificationInput {
|
||||
title: string
|
||||
body?: string
|
||||
silent?: boolean
|
||||
/** Absolute filesystem path for the notification icon. */
|
||||
icon?: string
|
||||
/**
|
||||
* Where body-click should land. Accepts a plugin deep link
|
||||
* (`hermes://index-network/intent/1`), a hash path (`/index-network/intent/1`),
|
||||
* or `{ path, params }` — all resolve through the same helper as OS deep links.
|
||||
*/
|
||||
activate?: HermesOpenTarget
|
||||
/** Extra work on body click (runs in addition to `activate` navigation). */
|
||||
onActivate?: () => void
|
||||
actions?: PluginNotificationAction[]
|
||||
}
|
||||
|
||||
interface PendingPluginNotify {
|
||||
onActivate?: () => void
|
||||
actions: Map<string, () => void>
|
||||
}
|
||||
|
||||
const pendingPluginNotify = new Map<string, PendingPluginNotify>()
|
||||
|
||||
function mintNotifyId(pluginId: string): string {
|
||||
return `${pluginId}:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
/** Invoke body-click callback if one was registered for this notify id. */
|
||||
export function invokePluginNotifyActivate(notifyId: string | undefined): void {
|
||||
if (!notifyId) {
|
||||
return
|
||||
}
|
||||
|
||||
const pending = pendingPluginNotify.get(notifyId)
|
||||
pending?.onActivate?.()
|
||||
}
|
||||
|
||||
/** Invoke an action-button callback. Returns true when a handler ran. */
|
||||
export function invokePluginNotifyAction(notifyId: string | undefined, actionId: string | undefined): boolean {
|
||||
if (!notifyId || !actionId) {
|
||||
return false
|
||||
}
|
||||
|
||||
const handler = pendingPluginNotify.get(notifyId)?.actions.get(actionId)
|
||||
|
||||
if (!handler) {
|
||||
return false
|
||||
}
|
||||
|
||||
handler()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** Drop pending handlers (tests / after a click consumed the toast). */
|
||||
export function clearPluginNotifyHandlers(notifyId?: string): void {
|
||||
if (notifyId) {
|
||||
pendingPluginNotify.delete(notifyId)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
pendingPluginNotify.clear()
|
||||
}
|
||||
|
||||
/** Native OS notification on behalf of a plugin. One "Plugin notifications"
|
||||
@@ -214,7 +300,39 @@ export interface PluginNativeNotificationInput {
|
||||
* 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 })
|
||||
const activate = resolveHermesOpenPath(input.activate) ?? undefined
|
||||
const notifyId = input.onActivate || input.actions?.some(a => a.onAction) ? mintNotifyId(pluginId) : undefined
|
||||
|
||||
if (notifyId) {
|
||||
const actions = new Map<string, () => void>()
|
||||
|
||||
for (const action of input.actions ?? []) {
|
||||
if (action.onAction) {
|
||||
actions.set(action.id, action.onAction)
|
||||
}
|
||||
}
|
||||
|
||||
pendingPluginNotify.set(notifyId, { actions, onActivate: input.onActivate })
|
||||
}
|
||||
|
||||
const actions: NativeNotificationAction[] | undefined = input.actions?.map(action => ({
|
||||
activate: resolveHermesOpenPath(action.activate) ?? undefined,
|
||||
id: action.id,
|
||||
text: action.label
|
||||
}))
|
||||
|
||||
dispatchNativeNotification({
|
||||
actions,
|
||||
activate,
|
||||
body: input.body,
|
||||
global: true,
|
||||
icon: input.icon,
|
||||
kind: 'plugin',
|
||||
notifyId,
|
||||
silent: input.silent,
|
||||
tag: pluginId,
|
||||
title: input.title
|
||||
})
|
||||
}
|
||||
|
||||
// Resolve a pending approval from a notification button, mirroring the in-app
|
||||
|
||||
@@ -103,10 +103,15 @@ The ONLY import surface is `@hermes/plugin-sdk` (plus `react` /
|
||||
user's instructions) — it won't discover the name on its own.
|
||||
- `ctx.storage.get/set/remove` — persistence namespaced to your plugin.
|
||||
- `ctx.os` — the curated OS door, attributed to your plugin:
|
||||
`ctx.os.notify({ title, body?, silent? })` posts a native OS notification.
|
||||
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.os.notify({ title, body?, silent?, icon?, activate?, onActivate?, actions? })`
|
||||
posts a native OS notification. 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. `activate` accepts a plugin deep link
|
||||
(`hermes://index-network/intent/1`), a hash path (`/index-network/intent/1`),
|
||||
or `{ path, params }` — same resolver as OS deep links. Action buttons may
|
||||
set their own `activate` or an `onAction`
|
||||
callback (renderer-only; only the action id crosses IPC).
|
||||
`ctx.os.openExternal(url)`, `ctx.os.revealPath(path)`, and
|
||||
`ctx.os.writeClipboard(text)` resolve `false` (never throw) when the
|
||||
capability isn't available.
|
||||
|
||||
@@ -453,7 +453,8 @@ busy flag.
|
||||
```ts
|
||||
host.notify({ kind, message, title?, detail?, action? }) // toast; returns id
|
||||
host.notifyError(error, fallbackMessage) // toast an error
|
||||
ctx.os.notify({ title, body?, silent? }) // native OS notification (attributed to your plugin)
|
||||
ctx.os.notify({ title, body?, silent?, icon?, activate?, onActivate?, actions? })
|
||||
// native OS notification (attributed to your plugin)
|
||||
ctx.os.openExternal(url) // OS default handler (browser, mail, spotify:) → Promise<boolean>
|
||||
ctx.os.revealPath(path) // reveal in Finder / Explorer → Promise<boolean>
|
||||
ctx.os.writeClipboard(text) // system clipboard → Promise<boolean>
|
||||
@@ -558,6 +559,32 @@ approval/turn alerts use. It fires only while the user is away from Hermes
|
||||
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.
|
||||
|
||||
Rich presentation + activation (extends the original `ctx.os` door):
|
||||
|
||||
```ts
|
||||
ctx.os.notify({
|
||||
title: 'New match found',
|
||||
body: 'Someone matched your signal',
|
||||
icon: '/abs/path/to/icon.png', // Electron Notification icon
|
||||
// Body click → focus Hermes + navigate. Same vocabulary as OS deep links:
|
||||
activate: 'hermes://index-network/intent/1',
|
||||
// or: activate: '/index-network/intent/1'
|
||||
// or: activate: { path: '/index-network/intent/1' }
|
||||
onActivate: () => focusLocalState('1'), // optional renderer callback
|
||||
actions: [
|
||||
{ id: 'open', label: 'Open', activate: 'hermes://index-network/intent/1' },
|
||||
{ id: 'dismiss', label: 'Dismiss', onAction: () => dismiss('1') },
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
`activate` is deeplink-compatible: `hermes://index-network/intent/1` and the
|
||||
hash path `/index-network/intent/1` resolve to the same in-app route (and the
|
||||
same `hermes://…` URL works as an OS deep link). Action buttons only render on
|
||||
signed macOS builds; elsewhere the body click still activates. Navigation only
|
||||
happens on user click — never from a background event alone.
|
||||
|
||||
The other doors (`openExternal`, `revealPath`, `writeClipboard`) resolve
|
||||
`false` instead of throwing when the capability isn't available (older desktop
|
||||
shell, plain browser) — branch on the result rather than sniffing the bridge.
|
||||
@@ -812,7 +839,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`, `PluginOs`, `PluginRestOptions`, `PluginNativeNotificationInput`, `Contribution` |
|
||||
| Plugin contract | `HermesPlugin`, `PluginContext`, `PluginContribution`, `PluginStorage`, `PluginOs`, `PluginRestOptions`, `PluginNativeNotificationInput`, `PluginNotificationAction`, `HermesOpenTarget`, `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` |
|
||||
|
||||
Reference in New Issue
Block a user