The plugin API
The typed ovvi object a plugin's page calls, its events, its state, its versions, and the Swift side behind it
A plugin's entry exports one function. The app calls it with the ovvi
object when the page loads. Everything the page asks of the app goes through
that object. File Explorer and Git use exactly this API.
import type { Ovvi } from "@ovvi/plugin-api"
export default async function main(ovvi: Ovvi) {
const { text } = await ovvi.files.read(ovvi.card.arg)
document.body.textContent = text
}- The card is ready when
mainreturns, or when the page callsovvi.card.ready()first. Until then the card shows its loader, or its still picture after a drop. - An error thrown from
mainstops the card: "‹name› stopped", the message and its first stack line, with Reload (Errors and logs). - The app starts a page's calls in the order the page made them: of two
controls.setcalls to one control, the second one wins. - Every call works only on the card's own machine. On that machine a page can use the whole API (What a plugin can do).
Types
The API is host-provided, as pi's is for its packages. The app gives the
ovvi object to the page at run time, so a plugin never bundles it:
- A plugin lists
@ovvi/plugin-apiinpeerDependenciesas"*", and marks itoptionalinpeerDependenciesMeta. The name is not on the npm registry, and npm 7 or later installs a peer that is not optional. - At launch the app writes the types package (
package.jsonandovvi.d.ts) to~/.config/ovvi/plugins/node_modules/@ovvi/plugin-api/when its copy differs.tscfinds it from any package in the plugins folder, sonpm run checkworks there. A package elsewhere (a local path) needs its own copy or link. import typegoes away in the build, so nothing of the package is bundled.
The types, from web/ovvi/ovvi.d.ts in the repository:
export interface Ovvi {
/** The API version the app gives this page: the manifest's `api`. */
readonly api: number
readonly card: Card
readonly files: Files
readonly git: Git
readonly state: State
readonly controls: Controls
readonly theme: Theme
readonly pages: Pages
readonly settings: Settings
readonly clipboard: { write(text: string): Promise<void> }
/** The native context menu at the pointer. Resolves with the picked item's value, or undefined. */
readonly menu: { show(items: MenuItem[]): Promise<string | undefined> }
/** Choose Folder…: the Mac's folder panel, a folder field on a remote machine. Resolves with the folder's path, or
* undefined. */
readonly pick: { folder(from?: string): Promise<string | undefined> }
/** Runs argv on the card's machine, in the pane's folder unless `cwd` says otherwise: no shell, 30 s unless
* `timeout` (ms) says otherwise, 4 MiB of stdout, two at a time per card. */
run(argv: string[], options?: { cwd?: string; stdin?: string; timeout?: number }): Promise<RunResult>
/** An open of a file, a folder (ends in `/`), a URL or a plugin's argument: in this card, in a new tab right after
* this card's tab (the default), in a pane to its side, or (`mac`, a ⌘⌥-click) in the Mac's default app for it,
* outside Ovvi (a remote machine's file or folder shows a notice). No `plugin`: the file's default plugin, the
* browser for a web address. `at`: the new card's landing (`card.at`). */
open(arg: string, options?: {
plugin?: string
how?: "card" | "tab" | "side" | "mac"
at?: string
/** A new tab or pane opens behind: the selected tab and the keyboard stay where they are (a ⌘- or
* ⌥-click). */
background?: boolean
}): Promise<void>
/** One line on glass at the top of the card, with `buttons` (the second the default) or OK. Resolves with the title
* picked, or undefined when another toast took its place. `destructive`: the main button deletes or discards (a
* Revert, a Delete), drawn red. */
toast(text: string, buttons?: [other: string, main: string], options?: { destructive?: boolean }): Promise<string | undefined>
/** An SF Symbol, or the app's `source.branch` or `text.wrap`, as a PNG data URL, `size` points tall (16 by default),
* black on clear: use it as a mask. */
symbol(name: string, size?: number): Promise<string>
/** A header control or a command, as the user changed it: a click (no value), a toggle, a segment, a menu item, a
* field's text (`submit` on Return), a path text's linked part (`part`, its index). */
on(event: "control", fn: (e: { id: string; value?: string | boolean; submit?: boolean; part?: number }) => void): Off
/** The card moved to another place: after `card.go`, Back, Forward, or a rename from elsewhere. After Back, `at`
* is the place it came back from: that place's landing, else its argument. */
on(event: "place", fn: (place: { arg: string; at?: string }) => void): Off
/** ⌘S, ⌘F, ⌘G, ⇧⌘G over the card, with `handles` in the manifest. */
on(event: "save" | "find" | "findNext" | "findPrevious", fn: () => void): Off
on(event: "visible" | "keyboard", fn: (yes: boolean) => void): Off
on(event: "theme", fn: (theme: Theme) => void): Off
/** The user changed settings of this plugin on its page in Settings: the new values by id. Every open card
* of the plugin hears it, hidden ones too. */
on(event: "settings", fn: (changed: Record<string, SettingValue>) => void): Off
}
/** A toggle's value is a boolean, a number's a number, a choice's, a text's and a path's a string. */
export type SettingValue = boolean | number | string
/** The values of the manifest's `settings`, which the user sets on the plugin's page in Settings. Read only:
* a card keeps its own choices in `state`. */
export interface Settings {
/** The value the user set, else the manifest's default. There at the page's first load, with no wait. */
get<T extends SettingValue>(id: string): T
/** Opens Settings at this plugin's page. */
open(): void
}
/** Stops the handler or the watch. Every one ends with the page. */
export type Off = () => void
/** A card of many places draws each in a page of its own, and keeps the last five pages it showed, hidden,
* so Back and Forward show a place at once, as the user left it: its scroll, its open rows, its header controls. */
export interface Pages {
/** Draws the card's places from this call on, starting with its current place: resolves once that page shows. A
* move to a place with a kept page shows that page at once; to another place, `draw` fills a new page, which shows
* when its promise ends, and the shown page stays until then. A page left with unsaved edits is dropped. From this
* call on ⌘← and ⌘→ go Back and Forward (manifest `history`), ahead of the page's own keys, unless a text field has
* the keyboard. */
route(draw: (page: Page) => Promise<void>): Promise<void>
}
export interface Page {
readonly arg: string
/** The landing of the move that drew it. */
readonly at?: string
/** Over the whole card (`.ovvi-page`); hidden pages are `inert`. */
readonly element: HTMLElement
/** The page's own `ovvi`: its `on` handlers run only while it shows (`theme`, `visible` and `settings` always), its
* watches call back while it shows (a change while hidden calls once when it shows again), its header controls apply
* while it shows and come back when it shows again; all end when it drops. Its `place` fires when the card moves to
* this page again (Back, Forward, the same place with a landing). */
readonly ovvi: Ovvi
readonly shown: boolean
/** Gone: work that ends after does nothing. */
readonly dropped: boolean
/** Runs when the page drops: a view's teardown. */
keep(off: Off): void
/** Draws the place again in a new page, which takes this one's place once drawn; a hidden page only drops. */
redraw(): void
}
export interface Card {
/** The instance id from the label: stable for the pane's life. */
readonly id: string
readonly plugin: { id: string; version: string }
readonly machine: { id: string; name: string; remote: boolean }
/** The place: the label's argument (a path, a root, "<root> <source>", a URL, or ""). */
readonly arg: string
/** The pane's folder on its machine. */
readonly cwd: string
/** A linked open's landing, on the first load only: a 1-based line, or a file of a diff. */
readonly at?: string
readonly visible: boolean
readonly keyboard: boolean
/** Moves the card to another place: the label follows, the app keeps Back and Forward. With unsaved edits the app
* asks first. The page hears `place` once it moved; it is not loaded again. */
go(arg: string, options?: { at?: string }): void
/** Ends the card's loader. Also when `main` returns. */
ready(): void
/** The Edited dot after the title. A dirty card is never dropped; a move or a close asks Discard or Keep Editing. */
setDirty(dirty: boolean): void
}
export interface Files {
/** UTF-8 text up to 16 MiB. `hash` is what `write` checks. `writable` is false over 4 MiB on a remote machine. */
read(path: string): Promise<{ text: string; hash: string; writable: boolean }>
/** Bytes up to 16 MiB, with the MIME type from the name. */
readBytes(path: string): Promise<{ data: Uint8Array; type: string; hash: string }>
/** The info view's facts: the size in bytes, the kind's name, the system's icon as a PNG data URL; and the hash
* `read` gives (none for a folder), to tell a change without reading the file. */
info(path: string): Promise<{ name: string; size: number; kind: string; icon: string; folder: boolean; hash: string }>
/** A folder's entries but `.git`, folders first, by name as the Finder sorts; `ignored` when git ignores it. */
list(folder: string): Promise<{ name: string; folder: boolean; ignored: boolean }[]>
/** Every file under the root, from it: the files git does not ignore in a repository, else every file, up to 8 MiB
* of names. */
paths(root: string): Promise<string[]>
/** Writes a new file beside the path and moves it over. With `over`, fails with `changed` if the file's hash
* differs. */
write(path: string, data: string | Uint8Array, options?: { over?: string }): Promise<{ hash: string }>
/** Renames or moves a file or a folder; never replaces: fails when something is at `to`. */
move(path: string, to: string): Promise<void>
/** Calls fn after a change under path, only while the card shows, and once on showing again. Under a folder, what
* git ignores (a build's output) and git's objects call nothing, and calls come at most every 2 s. */
watch(path: string, fn: () => void): Off
}
/** Writes run one at a time per card, and never ask for a password (they fail with git's message). */
export interface Git {
root(folder: string): Promise<string | null>
/** The changed files from the root, each untracked file on its own: the index's letter for a file it adds or
* renames, else the work tree's, else the index's. */
status(root: string): Promise<Change[]>
/** The changes as the index keeps them: staged (the index against HEAD) and unstaged (untracked included); with
* `status`'s list and `head`'s answer plus the commit HEAD names ("" before the first), all from one `git status`. */
changes(root: string): Promise<{
staged: Change[]
unstaged: Change[]
status: Change[]
head: { branch: string; ahead: number; behind: number; commit: string } | null
}>
/** The checkout's branch (the short hash when detached) and how far it is from its upstream; null outside a
* repository. */
head(root: string): Promise<{ branch: string; ahead: number; behind: number } | null>
/** The local branches, then the remote ones with no local branch of their name. */
branches(root: string): Promise<{ name: string; remote: boolean; current: boolean }[]>
/** The last `limit` commits (500 by default) of every branch, remote branch and tag, and HEAD, newest first, with the
* graph's lanes. */
log(root: string, options?: { limit?: number }): Promise<Commit[]>
/** The patch as git prints it: "uncommitted" (staged, unstaged and untracked against HEAD) or a commit against its
* first parent. Up to 16 MiB. */
diff(root: string, source: string): Promise<string>
/** A commit's page, by its hex hash (not a ref name): its subject (git's `%s`, as `log` gives it) and body (`%b`, trimmed), author, email, date in
* milliseconds since 1970, and the full hashes of its parents. */
show(root: string, hash: string): Promise<{
hash: string; parents: string[]; author: string; email: string; date: number; subject: string; body: string
}>
/** `git add -A` of the paths (literal, not patterns), or of everything. */
stage(root: string, paths?: string[]): Promise<void>
unstage(root: string, paths?: string[]): Promise<void>
/** The paths (literal) back as HEAD has them, in the index and the work tree; untracked ones are deleted. With a
* tracked path it fails in a repository with no commits. */
revert(root: string, paths: string[]): Promise<void>
commit(root: string, message: string): Promise<void>
fetch(root: string): Promise<void>
/** git's own way (the user's `pull.rebase`). */
pull(root: string): Promise<void>
/** A branch with no upstream is published under its name. */
push(root: string): Promise<void>
/** A remote branch (`origin/x`) makes a local one that tracks it. */
switch(root: string, branch: string): Promise<void>
init(folder: string): Promise<void>
/** Calls fn after a change of git's own files of the root: its `HEAD`, the repository's refs and its config (a
* commit, a checkout, a fetch, a new branch or tag, an upstream), as the sidebar's branch follows them. Only while
* the card shows, and once on showing again. */
watch(root: string, fn: () => void): Off
}
export interface Change {
/** M, T (the type changed), A, D, R, C (a conflict), U (untracked). */
letter: string
path: string
oldPath?: string
}
export interface Commit {
hash: string
subject: string
author: string
/** As git's `%D` with `--decorate=full`: `HEAD -> refs/heads/main`, `refs/remotes/origin/main`, `tag: refs/tags/v1`. */
refs: string[]
/** Milliseconds since 1970. */
date: number
lane: number
children: number[]
parents: number[]
through: number[]
}
export interface RunResult {
code: number
stdout: string
/** The last 8 KiB. */
stderr: string
/** stdout passed 4 MiB: the command stops there. */
truncated: boolean
}
export interface State {
/** The card's state as last set: given at load, also after a drop, a reload or a relaunch. */
get<T>(): T | undefined
/** JSON, at most 256 KiB. Kept at once in the app; written to the card's machine 500 ms after the last set. */
set<T>(value: T): void
}
export interface Controls {
get(id: string): string | boolean | string[] | undefined
/** Changes a header control or a command. `badge` "" or 0 clears; `busy` shows a spinner in a button or a menu and
* disables the control. A field's value changes as the user types: set it only to replace or clear it (after
* submit), never to echo it. */
set(id: string, change: {
value?: string | boolean | string[]
title?: string
/** An SF Symbol: a button's or a toggle's icon, or drawn ahead of a text's last part; "" clears. */
symbol?: string
/** What VoiceOver reads, when not the title ("Push, 1 ahead"); "" clears. */
label?: string
badge?: string | number
/** A path text: its first parts are buttons, which send `control` with their `part`. */
links?: number
items?: MenuItem[]
hidden?: boolean
enabled?: boolean
checked?: boolean
busy?: boolean
/** A plain field (not a path field) takes the keyboard (the page's ⌘F). */
focus?: boolean
/** A path field shakes and keeps the typed path: it names no place to go. */
refused?: boolean
}): void
}
/** An item, a section heading (no value) or a divider. */
export type MenuItem = { value: string; title: string; symbol?: string } | { title: string } | "-"
export interface Theme {
dark: boolean
style: "glass" | "classic"
/** The app's text size in points (⌘+ ⌘− ⌘0). */
textSize: number
/** The app's tokens as CSS colors; also CSS variables on the root (`--text`, …), with `--app-text-size`. The app
* sets the root's inline style anew with each theme: put the page's own variables in a style sheet. `card` is an
* opaque stand-in for the card's fill (the keyboard card's while it is one: `theme` fires as the card gains or
* loses the keyboard), for a band that text scrolls under. */
colors: Record<
"text" | "dim" | "divider" | "card" | "accent" | "on-accent" | "working" | "needs-you" | "finished" | "error",
string
>
}
/** What every failed call rejects with. */
export interface OvviError extends Error {
code: "notFound" | "notText" | "tooLarge" | "changed" | "timeout" | "closed" | "failed"
}What each part does
| Part | What it does | Behind it (Swift) |
|---|---|---|
card | who the card is: its iid, plugin, machine, place, folder and landing; go, ready, setDirty | the label and the pane in the snapshot; Plugin |
files | read, list, write and watch on the card's machine | HerdrKit's Files (read, list, paths, save by hash, watch) over the machine's Exec, in PluginCalls |
git | the root, status, changes, branch, branches, history with lanes, patches, and the everyday writes | HerdrKit's Git, in PluginCalls; on This Mac only once git passed its check (Git status) |
run | any command on the card's machine | Exec.run inside the envelope: argv only, no shell; 30 s by default; 4 MiB of stdout, then truncated; stderr's last 8 KiB; a last line break added to stdin; two at a time per card |
state | the card's state for drops, reloads and relaunches | State |
controls | the header row and the card's commands | the native header (Header, HeaderView) |
theme | colors, light or dark, glass or classic, the text size | the Look tokens |
settings | the values the user set on the plugin's page in Settings, else the manifest's defaults; open shows that page | PluginSettings, settings.json (Settings) |
open | an open in this card, a new tab right after this card's tab (the default) or a pane to its side, in front or in the background | Page.openFromPlugin (a notice for a file gone since the page drew it), then Page.open(_:arg:at:from:how:background:) |
toast | the card's toast | Toast and its view |
menu.show | the native context menu at the pointer | NSMenu |
pick.folder | Choose Folder…: the folder panel on This Mac, a "Go to Folder" field on a remote machine, which shows "No folder ‹path›" for a folder that is not there | NSOpenPanel, or an NSAlert with a field |
symbol | an SF Symbol as a PNG, to use as a mask | NSImage(systemSymbolName:) |
clipboard.write | text on the clipboard | NSPasteboard |
console | logs | each console line and every uncaught error go to the plugin's log |
- Events go from the app to the page.
onreturns a function that stops the handler. Every handler and watch ends with the page, so a reload needs no cleanup code. - Errors. A failed call rejects with an
OvviErrorwhosecodesays why:notFound,notText(not UTF-8),tooLarge(over 16 MiB),changed(the hash given towritediffers),timeout,closed(the card went, or its plugin was removed),failed(anything else, with git's or the command's message). - Keyboard and focus.
keyboardis true while the card has the keyboard. The page focuses its own field then (an editor puts its caret back), on its window'sfocusevent. Only the app gives a card the keyboard: while the card does not have it, an element'sfocus()does nothing, because WebKit would make the page take the keyboard from the pane in use. A press in the page is the exception: during its event,focus()works, so a widget that focuses itself onmousedowngets the first click. Keys that no menu item takes reach the page. A plugin cannot take the app's ⌘ keys: the menu goes first (D93). Its own keys go incommands. - Theme. Before the first paint, the app sets the colors as CSS
variables on the root (
--text,--dim,--divider,--card,--accent,--on-accent,--working,--needs-you,--finished,--error),--app-text-sizeandcolor-scheme. It sets them again on a change and then sendstheme. The app'scard.cssloads before the plugin's CSS: system fonts at 13 px (--font-size), no margin, a clear background, so the card's fill draws behind the page. It also has the classovvi-sidebarfor a sidebar that slides in and out as the system's do (0.25 s, CSSease, none with Reduce Motion): put it on a flex child with--sidebar-width, and set the attributedata-closedto slide it out of its row (its parent clips withoverflow: hidden). The content beside it takes the room. The classendputs it on the right. - Visible. A card is visible while it is in the shown tab or in a
floating window. A card that is not gets
visiblefalse and its watches stop. Itsruncalls go on. When it shows again, each watch runs once, changed or not.
One plugin, many places
One card can show several places: File Explorer's tree, File, Preview and Markdown, or Git's home and a diff. The plugin draws every place itself:
ovvi.card.go(arg, { at })moves the card to a new place. The label follows, so a reload or a card made again comes back there. The page is not loaded again. With unsaved edits, the app asks first ("‹name› has unsaved edits.", Discard and Keep Editing).- With
"history": truein the manifest, the app keeps Back and Forward for the card and draws them at the left of row 2 (chevron.left⌃-,chevron.right⌃⇧-). A page that usespages.routealso goes Back and Forward on ⌘← and ⌘→, unless a text field in the page has the keyboard. The runtime takes these keys before the page's own handlers. on("place")fires on every move: the page's owncard.go, Back, Forward, or a new argument from elsewhere (an agent'sherdr pane rename). So a page draws its places in one handler, or inpages.route. After Back,atis the place it came back from: that place's landing, else its argument. So the tree shows the row of the file it opened.- The page's
document.title, when it is not empty, is the card's and the tab's title in place of the one from the label (Git's commit subject). A place that has no title of its own sets it back to "". ovvi.pages.route(draw)draws each place in a page of its own, and the card keeps the last five pages it showed. A page is an element over the whole card. A hidden page stays in the document,inertand invisible, so it keeps its scroll, its open rows and its selection. Back and Forward show a kept page at once. A new place draws in a new page, and the page before stays on screen until the new one has drawn.- Each page has its own
ovvi(page.ovvi). Itsonhandlers run only while the page shows, excepttheme,visibleandsettings. Its watches call back only while it shows: a change while it is hidden calls back once when it shows again. Its header controls apply while it shows. When it shows again, the controls come back as the page left them, the user's changes too. Itsplaceevent fires when the card moves to the page again, with the move's landing. The tab's title comes back with the page. A hidden page's audio and video pause. - A page left with unsaved edits (the user chose Discard) is dropped, so it
draws anew next time.
page.redraw()draws the shown place again in a new page (File Explorer, when a file becomes read only). - A heavy place loads with a dynamic
import(), so a tree card does not load CodeMirror or pdf.js. A bundler links no CSS for a dynamic chunk, so the entry imports all the CSS, scoped per place.
State
Each card has one state value, which the plugin sets. Use it for what the user misses after a reload: the sort, the selected row, a draft. Data that every device must see belongs in files on the machine.
- In the app:
setkeeps the value at once, next to the header controls' values. A reload or a move gives the page the same value inget. - On the machine: 500 ms after the last change of the state or of a
header value, the app writes both to the instance state file, as the
browser does:
$HOME/.local/state/ovvi/<session>/<plugin-id>/<iid>.json. The app reads it once, before the card's first load. So a relaunch, another device, a card made again after a drop, or a cold restart of herdr starts where it was. A missing file means no state. - The file's rules are the browser's: written atomically (a temporary file, then a rename), at most 256 KiB, last writer wins. Nothing is written after the pane closed. Closing a plugin pane removes its file. On connect the app removes files not touched for 30 days.
{ "controls": { "diff.layout": "split" }, "state": { "draft": "Fix the login", "list": false } }- An open card changes only when it is made. Another device's write does not move it.
Versions
- The manifest's
apiis the version the plugin was written for, an integer. This page is version 3, and the app gives version 3. - The number goes up when a plugin written for it would fail on an older app, as with a new call. A new event, manifest key or control field keeps it: an older app ignores what it does not know.
- The app runs a plugin whose
apiis at most its own. - A plugin whose
apiis newer than the app's shows "‹name› needs a newer Ovvi" in its card. - The bridge messages are the app's own and are not part of the API. Only
the
ovviobject is.
The Swift side
| Piece | Job |
|---|---|
PluginManifest (HerdrKit) | the ovvi key, decoded leniently, with its rules and the file-pattern rank; unit tested |
Registry | every manifest: the browser's and each package's; the chooser, ⌘K, Open With and opens read it (Where the registry looks) |
PluginScheme | serves ovvi-plugin://<id>/ from the entry's folder, with the CSP |
Plugin | one per card, made from its label and kept by terminal id: the label, the place, Back and Forward, the header and the web card. A dropped card loses it and reads its state file again |
WebCard | the card's WKWebView, the navigation lock, the context script at document start, the theme |
Bridge | one per card: the page's calls, the events, the state and its file, watches, two runs at a time, git writes one at a time |
PluginCalls (HerdrKit) | the calls on the card's machine (files, git, run); unit tested against real files, a repository with a bare remote and real processes |
Header, HeaderView | the manifest's controls with the page's changes, drawn in a pane and in a floating window |
PluginInstalls, PluginPackages (HerdrKit) | sources, install, update, remove and settings.json (Packages and installs) |
PluginBuilds | runs a package's build script; the "not built" check; the types package; the log |
DefaultPlugins | the defaults' first-launch install, update and reset |
PluginTrust | trust.json (the confirmed ids) and the confirmation in a card |
PluginSettings | the plugins' values in settings.json, and the settings event to every open card of the plugin |
- The bridge is one script message handler with replies
(
WKScriptMessageHandlerWithReply), namedovvi, behind a weak relay. A message is{ call, args }, answered with a JSON string or rejected with<code>: <text>. The app callswindow.ovvi_receive(name, data)for an event. - The card is bound to its machine, session and plugin when it is made. No message carries a machine or a card.
- Calls that wait for the user (a toast) answer through an event, so no Swift task hangs on a page that never hears back.
Not in version 3
Network from the page beyond its own files and its manifest's local connect servers, streams
from run, clipboard read, a setting per machine, a page that sets a
setting, and the herdr snapshot as an event.