diff --git a/apps/desktop/src/contrib/plugin.test.ts b/apps/desktop/src/contrib/plugin.test.ts index 95bb250458..9f522fe703 100644 --- a/apps/desktop/src/contrib/plugin.test.ts +++ b/apps/desktop/src/contrib/plugin.test.ts @@ -24,10 +24,39 @@ describe('createPluginContext.onDispose', () => { }) }) -describe('createPluginContext.notifyNative', () => { +describe('createPluginContext.os', () => { it('dispatches a native notification attributed to the plugin', () => { const ctx = createPluginContext('demo') - ctx.notifyNative({ body: 'b', title: 't' }) + ctx.os.notify({ body: 'b', title: 't' }) expect(dispatchPluginNativeNotification).toHaveBeenCalledWith('demo', { body: 'b', title: 't' }) }) + + it('resolves false (never throws) when the desktop bridge is missing', async () => { + const ctx = createPluginContext('demo') + + // jsdom has no window.hermesDesktop — the exact older-shell/browser case. + await expect(ctx.os.openExternal('https://example.com')).resolves.toBe(false) + await expect(ctx.os.revealPath('/tmp')).resolves.toBe(false) + await expect(ctx.os.writeClipboard('hi')).resolves.toBe(false) + }) + + it('routes through the bridge and turns a bridge throw into false', async () => { + const bridge = { + openExternal: vi.fn().mockResolvedValue(undefined), + revealPath: vi.fn().mockResolvedValue(true), + writeClipboard: vi.fn().mockRejectedValue(new Error('nope')) + } + + ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = bridge + + try { + const ctx = createPluginContext('demo') + await expect(ctx.os.openExternal('https://example.com')).resolves.toBe(true) + expect(bridge.openExternal).toHaveBeenCalledWith('https://example.com') + await expect(ctx.os.revealPath('/tmp')).resolves.toBe(true) + await expect(ctx.os.writeClipboard('hi')).resolves.toBe(false) + } finally { + delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop + } + }) }) diff --git a/apps/desktop/src/contrib/plugin.ts b/apps/desktop/src/contrib/plugin.ts index 0f9bc849cb..41789af769 100644 --- a/apps/desktop/src/contrib/plugin.ts +++ b/apps/desktop/src/contrib/plugin.ts @@ -35,6 +35,27 @@ export interface PluginStorage { remove(key: string): void } +/** The curated OS door — every way a plugin reaches outside the app window, + * in one attributed namespace instead of the raw `window.hermesDesktop` + * bridge. Every member resolves a result instead of throwing when the + * capability can't apply (no Electron shell, older desktop build), so + * callers branch on the return value rather than sniffing the bridge. */ +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. */ + 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. */ + openExternal: (url: string) => Promise + /** Reveal a path in the OS file manager (Finder / Explorer). Resolves + * false when unavailable. */ + revealPath: (path: string) => Promise + /** Write text to the system clipboard. Resolves false when unavailable. */ + writeClipboard: (text: string) => Promise +} + export interface PluginContext { /** The resolved plugin source tag, e.g. `'plugin:cost-meter'`. */ readonly source: string @@ -56,10 +77,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 + /** The curated OS door: native notification, open-external, reveal-in-file- + * manager, clipboard — attributed to this plugin, result-shaped (never + * throws for a missing capability). */ + os: PluginOs /** Plugin-scoped persistence. */ storage: PluginStorage /** Plugin-scoped i18n: ship + register locale bundles under this plugin, @@ -102,6 +123,37 @@ function createPluginStorage(pluginId: string): PluginStorage { } } +// Never throws for a missing capability: the renderer can outlive an older +// Electron shell (or run in a plain browser), so every door degrades to a +// false result the plugin can branch on. +function createPluginOs(pluginId: string): PluginOs { + const attempt = async (run: (bridge: NonNullable) => Promise) => { + const bridge = typeof window === 'undefined' ? undefined : window.hermesDesktop + + if (!bridge) { + return false + } + + try { + return await run(bridge) + } catch { + return false + } + } + + return { + notify: input => dispatchPluginNativeNotification(pluginId, input), + openExternal: url => + attempt(async bridge => { + await bridge.openExternal(url) + + return true + }), + revealPath: path => attempt(async bridge => (bridge.revealPath ? bridge.revealPath(path) : false)), + writeClipboard: text => attempt(bridge => bridge.writeClipboard(text)) + } +} + /** Build the scoped context handed to a plugin's `register`. `onDispose` * receives every registration's disposer (the loader's unload/reload hook). */ export function createPluginContext(pluginId: string, onDispose?: (dispose: () => void) => void): PluginContext { @@ -121,7 +173,7 @@ export function createPluginContext(pluginId: string, onDispose?: (dispose: () = onDispose: fn => void track(fn), rest: (path: string, opts?: PluginRestOptions) => pluginRest(pluginId, path, opts), socket: (path, onMessage) => track(pluginSocket(pluginId, path, onMessage)), - notifyNative: input => dispatchPluginNativeNotification(pluginId, input), + os: createPluginOs(pluginId), storage: createPluginStorage(pluginId), i18n: createPluginI18n(pluginId, track) } diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 60fa6f48de..ae7d02a420 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -201,6 +201,7 @@ export type { PluginContext, PluginContribution, PluginNativeNotificationInput, + PluginOs, PluginRestOptions, PluginStorage } from '@/contrib/plugin' diff --git a/apps/desktop/src/store/native-notifications.ts b/apps/desktop/src/store/native-notifications.ts index dba7bb58de..df33a30729 100644 --- a/apps/desktop/src/store/native-notifications.ts +++ b/apps/desktop/src/store/native-notifications.ts @@ -206,7 +206,7 @@ export function dispatchNativeNotification(input: NativeNotificationInput): void }) } -// -- the plugin door (`ctx.notifyNative`) ------------------------------------- +// -- the plugin door (`ctx.os.notify`) ---------------------------------------- export interface PluginNativeNotificationInput { title: string diff --git a/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md b/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md index 6e79fefaf6..19e0759bcb 100644 --- a/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md +++ b/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md @@ -71,11 +71,14 @@ 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.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.openExternal(url)`, `ctx.os.revealPath(path)`, and + `ctx.os.writeClipboard(text)` resolve `false` (never throw) when the + capability isn't available. - `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 diff --git a/website/docs/developer-guide/desktop-plugin-sdk.md b/website/docs/developer-guide/desktop-plugin-sdk.md index f77a647391..2e5abb8bec 100644 --- a/website/docs/developer-guide/desktop-plugin-sdk.md +++ b/website/docs/developer-guide/desktop-plugin-sdk.md @@ -168,8 +168,8 @@ interface PluginContext { rest: (path: string, opts?: PluginRestOptions) => Promise /** 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 + /** The curated OS door: native notification, open-external, reveal-in-file-manager, clipboard. */ + os: PluginOs /** Plugin-scoped JSON persistence (keys live under `hermes.plugin..`). */ storage: PluginStorage } @@ -372,7 +372,10 @@ 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 +ctx.os.notify({ title, body?, silent? }) // native OS notification (attributed to your plugin) +ctx.os.openExternal(url) // OS default handler (browser, mail, spotify:) → Promise +ctx.os.revealPath(path) // reveal in Finder / Explorer → Promise +ctx.os.writeClipboard(text) // system clipboard → Promise host.navigate('/route') // hash-route navigation host.onEvent(type, fn) // gateway event stream ('*' = all); returns disposer host.logs(...) // tail an app log file @@ -388,13 +391,17 @@ 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 +`ctx.os` is the curated OS door — every way a plugin reaches outside the app +window, in one namespace attributed to your plugin. `ctx.os.notify` 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. +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. ## Data layer — React Query + nanostores @@ -608,7 +615,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`, `PluginNativeNotificationInput`, `Contribution` | +| Plugin contract | `HermesPlugin`, `PluginContext`, `PluginContribution`, `PluginStorage`, `PluginOs`, `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` |