feat(desktop): ctx.os — the curated OS door for plugins

Fold ctx.notifyNative into a ctx.os namespace so every way a plugin
reaches outside the app window lives behind one attributed door instead
of accreting one top-level ctx method per capability:

- ctx.os.notify — the native-notification door from the previous commit,
  unchanged semantics (plugin kind pref, away-gating, per-plugin throttle).
- ctx.os.openExternal / ctx.os.revealPath / ctx.os.writeClipboard — the
  existing window.hermesDesktop bridge capabilities, now sanctioned and
  result-shaped: each resolves false (never throws) when the bridge or
  member is missing, so a plugin branches on the result instead of
  sniffing the preload surface or crashing on an older shell.

No new Electron surface: everything routes through bridge members the
app already ships; the notification path keeps every existing gate.
This commit is contained in:
Brooklyn Nicholson
2026-08-04 11:33:25 -06:00
parent 5d24594ab3
commit e8ccb4a2ea
6 changed files with 113 additions and 21 deletions
+31 -2
View File
@@ -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
}
})
})
+57 -5
View File
@@ -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<boolean>
/** Reveal a path in the OS file manager (Finder / Explorer). Resolves
* false when unavailable. */
revealPath: (path: string) => Promise<boolean>
/** Write text to the system clipboard. Resolves false when unavailable. */
writeClipboard: (text: string) => Promise<boolean>
}
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<typeof window.hermesDesktop>) => Promise<boolean>) => {
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: <T>(path: string, opts?: PluginRestOptions) => pluginRest<T>(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)
}
+1
View File
@@ -201,6 +201,7 @@ export type {
PluginContext,
PluginContribution,
PluginNativeNotificationInput,
PluginOs,
PluginRestOptions,
PluginStorage
} from '@/contrib/plugin'
@@ -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
@@ -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
@@ -168,8 +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
/** The curated OS door: native notification, open-external, reveal-in-file-manager, clipboard. */
os: PluginOs
/** Plugin-scoped JSON persistence (keys live under `hermes.plugin.<id>.`). */
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<boolean>
ctx.os.revealPath(path) // reveal in Finder / Explorer → Promise<boolean>
ctx.os.writeClipboard(text) // system clipboard → Promise<boolean>
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` |