Ovvi

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 main returns, or when the page calls ovvi.card.ready() first. Until then the card shows its loader, or its still picture after a drop.
  • An error thrown from main stops 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.set calls 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-api in peerDependencies as "*", and marks it optional in peerDependenciesMeta. 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.json and ovvi.d.ts) to ~/.config/ovvi/plugins/node_modules/@ovvi/plugin-api/ when its copy differs. tsc finds it from any package in the plugins folder, so npm run check works there. A package elsewhere (a local path) needs its own copy or link.
  • import type goes 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

PartWhat it doesBehind it (Swift)
cardwho the card is: its iid, plugin, machine, place, folder and landing; go, ready, setDirtythe label and the pane in the snapshot; Plugin
filesread, list, write and watch on the card's machineHerdrKit's Files (read, list, paths, save by hash, watch) over the machine's Exec, in PluginCalls
gitthe root, status, changes, branch, branches, history with lanes, patches, and the everyday writesHerdrKit's Git, in PluginCalls; on This Mac only once git passed its check (Git status)
runany command on the card's machineExec.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
statethe card's state for drops, reloads and relaunchesState
controlsthe header row and the card's commandsthe native header (Header, HeaderView)
themecolors, light or dark, glass or classic, the text sizethe Look tokens
settingsthe values the user set on the plugin's page in Settings, else the manifest's defaults; open shows that pagePluginSettings, settings.json (Settings)
openan 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 backgroundPage.openFromPlugin (a notice for a file gone since the page drew it), then Page.open(_:arg:at:from:how:background:)
toastthe card's toastToast and its view
menu.showthe native context menu at the pointerNSMenu
pick.folderChoose 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 thereNSOpenPanel, or an NSAlert with a field
symbolan SF Symbol as a PNG, to use as a maskNSImage(systemSymbolName:)
clipboard.writetext on the clipboardNSPasteboard
consolelogseach console line and every uncaught error go to the plugin's log
  • Events go from the app to the page. on returns 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 OvviError whose code says why: notFound, notText (not UTF-8), tooLarge (over 16 MiB), changed (the hash given to write differs), timeout, closed (the card went, or its plugin was removed), failed (anything else, with git's or the command's message).
  • Keyboard and focus. keyboard is true while the card has the keyboard. The page focuses its own field then (an editor puts its caret back), on its window's focus event. Only the app gives a card the keyboard: while the card does not have it, an element's focus() 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 on mousedown gets 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 in commands.
  • 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-size and color-scheme. It sets them again on a change and then sends theme. The app's card.css loads 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 class ovvi-sidebar for a sidebar that slides in and out as the system's do (0.25 s, CSS ease, none with Reduce Motion): put it on a flex child with --sidebar-width, and set the attribute data-closed to slide it out of its row (its parent clips with overflow: hidden). The content beside it takes the room. The class end puts 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 visible false and its watches stop. Its run calls 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": true in 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 uses pages.route also 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 own card.go, Back, Forward, or a new argument from elsewhere (an agent's herdr pane rename). So a page draws its places in one handler, or in pages.route. After Back, at is 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, inert and 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). Its on handlers run only while the page shows, except theme, visible and settings. 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. Its place event 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: set keeps the value at once, next to the header controls' values. A reload or a move gives the page the same value in get.
  • 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 api is 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 api is at most its own.
  • A plugin whose api is 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 ovvi object is.

The Swift side

PieceJob
PluginManifest (HerdrKit)the ovvi key, decoded leniently, with its rules and the file-pattern rank; unit tested
Registryevery manifest: the browser's and each package's; the chooser, ⌘K, Open With and opens read it (Where the registry looks)
PluginSchemeserves ovvi-plugin://<id>/ from the entry's folder, with the CSP
Pluginone 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
WebCardthe card's WKWebView, the navigation lock, the context script at document start, the theme
Bridgeone 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, HeaderViewthe 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)
PluginBuildsruns a package's build script; the "not built" check; the types package; the log
DefaultPluginsthe defaults' first-launch install, update and reset
PluginTrusttrust.json (the confirmed ids) and the confirmation in a card
PluginSettingsthe 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), named ovvi, behind a weak relay. A message is { call, args }, answered with a JSON string or rejected with <code>: <text>. The app calls window.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.

On this page