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:
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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` |
|
||||
|
||||
Reference in New Issue
Block a user