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)
  }
}