Souspli

Souspli / Docs / How

The bridge

Four functions on a frozen object, exposed through contextBridge, and nothing else (src/preload/index.ts, src/main/bridge.ts).

bridge.getArgs(): ThingArgs                     // sync — render on first paint
bridge.getBlob(name: string): string | null     // sync — returns a thing:// URL
bridge.viewerInfo(): ViewerInfo                 // sync — coarse, non-identifying
bridge.emit(channel: string, data: unknown): void   // fire-and-forget request

The rule every one of them obeys: a function either hands the letter data it already came with, or accepts a request that grants nothing until a person confirms it in the trusted header. The bridge must never become the "somewhere to send it".

getArgs() — a decoded view. Not CBOR. Not the envelope.

interface ThingArgs {
  type: string            // a display hint; never trusted
  args: unknown           // the letter's content, program-defined
  attachments: { name: string; mime: string; size: number }[]
  mode?: 'view' | 'edit'  // which of its two faces the program should draw
}

The manifest was already decoded and validated during admission. Handing a program raw CBOR would put a second decoder inside untrusted code and require the two to agree byte for byte — reintroducing, in the worst possible place, exactly the canonicalisation surface the format works to contain.

Deliberately withheld:

getBlob(name) — a URL, not bytes

Returns thing://<id>/att/<name>, or null. Attachments can be hundreds of megabytes; they do not cross IPC. The program uses the URL as an ordinary src, and the protocol handler streams from the blob store with range support. The bridge moves no attachment bytes at all.

A program can display its attachments but cannot read their bytes back.

viewerInfo()

{ locale, colorScheme }. Enough to format a date and match the theme. Nothing else, because a chatty viewerInfo would quietly turn a letter into a tracker.

emit(channel, data) — a request that grants nothing

Fire-and-forget: the program cannot observe whether the person agreed.

The one channel that matters is draft:

bridge.emit('draft', { type, args, blobs })

A program supplies its own editing interface and streams its working state as the person types. The client validates the shape, enforces per-blob and total size caps (32 MiB and 64 MiB), hashes inline blobs into an attachment table, renders the result as the live preview — and lets the person sign exactly the latest draft by pressing Publish in the header and confirming.

Blob values take three forms:

Value Meaning
raw bytes a freshly picked file, MIME unknown
{ bytes, mime } a typed attachment (survives nosniff serving)
{ carry: true } "keep the attachment of this name that you already hold" — resolved client-side, so megabytes do not cross IPC per keystroke

args and blobs are whole-set replacement: a program re-declares everything it wants to keep on every emit.

There is no emit('publish'). Programs cannot initiate a publish; every control that signs anything lives in the header.

Not there

Request/response over emit, and a file picker initiated by a program (requestFile()), are both deliberately absent for now. Within one editing session a just-picked large image still re-crosses IPC on each emit until the draft is re-mounted; the planned fix is an opaque blob handle (design note).