File size: 9,430 Bytes
a1b6014 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 | /**
* The plugin authoring contract. A plugin is a file that default-exports a
* `HermesPlugin`; it never touches the registry directly β it receives a
* scoped `PluginContext` whose `register` auto-tags provenance
* (`source: 'plugin:<id>'`) and namespaces the contribution id
* (`<id>:<localId>`), so authors write plain contributions and collisions
* between plugins are impossible.
*
* Bundled plugins live in `src/plugins/<name>/plugin.tsx` and are discovered
* by `discoverBundledPlugins()` (contrib/plugins.ts) β no import, no registry
* edit. Runtime-fetched third-party plugins will drive the SAME contract
* through the plugin host loader (next phase); this is that seam.
*/
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 { 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. */
export type PluginContribution = Omit<Contribution, 'source' | 'id'> & { id: string }
/** Namespaced JSON persistence (the VS Code `globalState` analog). Keys live
* under `hermes.plugin.<id>.` β plugins can't read or clobber each other. */
export interface PluginStorage {
get<T>(key: string, fallback: T): T
set(key: string, value: unknown): void
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.
* 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. */
openExternal: (url: string) => Promise<boolean>
/** Reveal a path in the OS file manager (Finder / Explorer). Resolves
* false when unavailable. */
revealPath: (path: string) => Promise<boolean>
/** Native save dialog. Resolves the chosen path, or null on cancel /
* when unavailable. The path is on the BACKEND's filesystem, so hand it
* to a `rest` call rather than trying to write it from the renderer. */
pickSavePath: (options?: PluginFileDialogOptions) => Promise<null | string>
/** Native open dialog, single file. Resolves the chosen path, or null on
* cancel / when unavailable. */
pickOpenPath: (options?: PluginFileDialogOptions) => Promise<null | string>
/** Write text to the system clipboard. Resolves false when unavailable. */
writeClipboard: (text: string) => Promise<boolean>
}
export interface PluginFileDialogOptions {
defaultPath?: string
filters?: Array<{ extensions: string[]; name: string }>
title?: string
}
export interface PluginContext {
/** The resolved plugin source tag, e.g. `'plugin:cost-meter'`. */
readonly source: string
/** Register one contribution (id namespaced, source stamped). */
register: (c: PluginContribution) => () => void
/** Register several at once; the returned disposer removes all of them. */
registerMany: (cs: PluginContribution[]) => () => void
/** Register an arbitrary cleanup to run on unload/disable β for side effects
* that aren't contributions or sockets (store subscriptions, timers). Runs
* alongside every other disposer when the plugin deactivates. */
onDispose: (fn: () => void) => void
/** REST to this plugin's own backend namespace (`/api/plugins/<id>`); `path`
* is relative ('/board'). The sanctioned door for a plugin that ships a
* `plugin_api.py` β profile-aware, namespace-scoped by construction. Use
* `host.request` for gateway JSON-RPC. */
rest: <T>(path: string, opts?: PluginRestOptions) => Promise<T>
/** Live twin of `rest`: a WebSocket to this plugin's own namespace
* ('/events'), JSON frames to `onMessage`, auto-reconnect, disposer
* 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
/** 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,
* resolved against the app's active locale β no core `en.ts` edit. */
i18n: PluginI18n
}
export interface HermesPlugin {
/** Stable slug β becomes the `plugin:<id>` source and the id namespace. */
id: string
/** Human name for settings / about UI. */
name?: string
/** One-liner for the settings inventory (what the plugin adds). */
description?: string
/** Registers on load when the user hasn't chosen (default true). Set false
* for opt-in plugins: they inventory in Capabilities βΈ Plugins, off until the
* user flips the switch. */
defaultEnabled?: boolean
/** Called once at load; wire contributions through `ctx`. */
register: (ctx: PluginContext) => void
}
function createPluginStorage(pluginId: string): PluginStorage {
const scoped = (key: string) => `hermes.plugin.${pluginId}.${key}`
return {
get(key, fallback) {
const raw = readKey(scoped(key))
if (raw === null) {
return fallback
}
try {
return JSON.parse(raw)
} catch {
return fallback
}
},
set: (key, value) => writeKey(scoped(key), JSON.stringify(value)),
remove: key => writeKey(scoped(key), null)
}
}
// 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
}
}
// Same shape as `attempt`, for the pickers that answer with a path.
const attemptPath = async (run: (bridge: NonNullable<typeof window.hermesDesktop>) => Promise<null | string>) => {
const bridge = typeof window === 'undefined' ? undefined : window.hermesDesktop
if (!bridge) {
return null
}
try {
return await run(bridge)
} catch {
return null
}
}
return {
notify: input => dispatchPluginNativeNotification(pluginId, input),
openExternal: url =>
attempt(async bridge => {
await bridge.openExternal(url)
return true
}),
pickOpenPath: options =>
attemptPath(async bridge => {
const picked = await bridge.selectPaths?.({ ...options, multiple: false })
return picked?.[0] ?? null
}),
pickSavePath: options => attemptPath(async bridge => (await bridge.selectSavePath?.(options)) ?? null),
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 {
const source = `plugin:${pluginId}`
const scope = (c: PluginContribution): Contribution => ({ ...c, id: `${pluginId}:${c.id}`, source })
const track = (dispose: () => void) => {
onDispose?.(dispose)
return dispose
}
return {
source,
register: c => track(registry.register(scope(c))),
registerMany: cs => track(registry.registerMany(cs.map(scope))),
onDispose: fn => void track(fn),
rest: <T>(path: string, opts?: PluginRestOptions) => pluginRest<T>(pluginId, path, opts),
socket: (path, onMessage) => track(pluginSocket(pluginId, path, onMessage)),
os: createPluginOs(pluginId),
storage: createPluginStorage(pluginId),
i18n: createPluginI18n(pluginId, track)
}
}
|