How it works
Architecture
Ovillo is a web page that runs inside an Electron window on your Mac, backed by a small server that only listens on your own computer. This page is for the curious and for developers: it names each part, says what talks to what, and shows what is on disk.
The parts
| Part | Where | What it does |
|---|---|---|
| The page | index.html, app.mjs, styles.css, src/*.mjs | All the work on your documents: reading, decrypting, the record, search, briefs, review and exports. Plain ES modules, no build step. It is the only place that ever holds your passphrase and the vault key. |
| The local server | server.mjs | Listens on 127.0.0.1 only. Serves the page's own files, stores ciphertext on disk, runs Sign in with ChatGPT, and passes assistant requests on. It cannot read your record. |
| The desktop shell | desktop/main.cjs | An Electron app. It starts the same server.mjs as a utility process on a free loopback port, opens the window, locks down what the window may reach, and checks for updates. |
| The reader helper (optional) | offload, bundled in the Apple silicon app | Reads documents with Apple's on-device models. Only runs on Apple silicon with macOS 27. Everything it does has a built-in fallback. See Reading documents. |
What talks to what
- You work in the page, inside the Electron window.
- The page encrypts and decrypts on its own. It sends ciphertext, and nothing else, to the local server to be saved.
- The local server writes ciphertext files under the data folder and answers the page. For the assistant it forwards the already scrubbed conversation to the provider you chose.
- The reader helper, when present, is started by the server and fed document bytes through a pipe, in memory. It has no network access.
- The desktop shell is the only part that contacts anything else: the update check to GitHub, from its own session, never from the page.
The page and the server are the same code whether you run the desktop app or start the server from a terminal. Families should use the desktop app: only it has the request filter, the helper and updates.
The vault on disk
The vault is a file, not browser storage. The data folder is ~/.health-picture. The name is kept on purpose from before the app was called Ovillo, so existing vaults carry on.
~/.health-picture/
vault.json the first person's encrypted record
blobs/blob-<uuid> that person's encrypted documents and originals
people.json ids and nicknames, in plain text
people/p-<16 hex>/ one folder per further person, with its own vault.json and blobs/
- The record is one JSON envelope: the whole record encrypted in a single piece. Beside the ciphertext it carries the format version and the key derivation settings, none of which are secret. It holds no name, filename, passphrase or key.
- Documents and originals are not inside the record. Each is encrypted separately with the same key and its own random value, then stored as an opaque blob. The blob's id is bound into the encryption, so a file cannot be swapped between ids without failing to decrypt. Limits are 100 MB per file and 64 MB for the record envelope.
- The search index and the extracted text of each document are also encrypted blobs. The record keeps only small metadata that points at them.
people.jsonis the one plain-text file. It holds the nicknames shown on the start screen so you can choose whose record to open. Use a nickname that is not a full name. See Several people.
One vault per person, each with its own passphrase. Records are never merged or moved between people, and the server keeps at most 20 people. Every vault and blob request names the person it is for, and the server refuses a person who is not in people.json.
Files are written whole to a side file, with permissions that only your user can read, and renamed into place, so a crash never leaves half a file. Saves are optimistic: each save carries the fingerprint of the file it was based on. If the file changed in another window, the server answers with a conflict and the page locks and tells you, instead of overwriting. See Privacy and security for the cryptography.
The local server's routes
Static files are answered for GET and HEAD only, from a fixed list of top-level files, the modules directly in src/ (the src/providers/ folder is server-only and never served) and the bundled vendor/ assets. Everything under /api/ is a small fixed set:
| Route | Purpose |
|---|---|
/api/vault | Read, save and delete the encrypted record. |
/api/blobs/<id> | Read, save and delete encrypted document files. |
/api/people, /api/info | The people list, and where the files live. |
/api/agent/step | The only route out to an assistant provider. |
/api/models, /api/byok/check | List models and check an address, for the same two providers. |
/api/auth/chatgpt/* and the loopback sign-in callback | Sign in with ChatGPT. Sign-in attempts live in server memory for ten minutes and are handed over once. Nothing is written to disk. |
/api/read | Passes document bytes to the reader helper, when there is one. |
Any other path or method gets a refusal.
The request filter and the page policy
Three layers keep other software, and other websites, away from the server and the page from reaching out.
- The API guard. Every API route first checks that the
Hostis127.0.0.1orlocalhostwith the right port (this blocks DNS rebinding), that the request carries the app's own header, and, for anything that changes data, that theOriginis the page itself and the body is JSON or binary. The custom header and body type force a browser preflight that the server never answers, so another site cannot call the API. - The page's content security policy. Every response carries one. Scripts, styles, workers, images and connections are allowed only from the app itself (plus
blob:and, for images,data:). Framing and plugins are off, and the referrer is never sent. The page cannot make a request to any other address, which is why even a hostile document could not phone home from the page. - The window's filter. The Electron window cancels every request that is not the local server, a
data:address or one of its ownblob:addresses. Links you click open in your normal browser. Sign in with ChatGPT happens there too.
The only network routes
Ovillo has no account, no analytics and no server of its own. There are exactly two ways data can leave the Mac:
- The update check. In the packaged Mac app, the desktop shell asks
github.com/BraedenBDev/ovillo-releasesfor a newer version at launch and every six hours while the app is open. GitHub sees the app version and your IP address, nothing from your record. A downloaded update installs only if it is signed with the same Developer ID as the running app. This check runs whether or not the vault is unlocked or an assistant is connected, and there is no setting for it. Developers can setHP_NO_UPDATES=1. - The assistant provider. Only if you connect one, and only when you use it. The page sends each step through
/api/agent/stepto OpenAI (Sign in with ChatGPT) or to the HTTPS address you typed for your own key. Addresses with credentials, a query string, or a private or loopback host are refused. What is sent, and how it is scrubbed first, is on Privacy and security.
There are no runtime downloads of fonts or models: the reading engines and models ship inside the app and are loaded from its own files.
Session and locking
While unlocked, the key and the decrypted record live in the page's memory. The page counts "sessions" with an epoch number. Locking raises it, clears the record and key, empties form fields and timers, and revokes local document addresses. Any import, save or restore that was still running from an older session is refused when it tries to finish, so it cannot write into a locked vault.
- The vault locks itself after 5 idle minutes by default. You can change that in Settings.
- Coming back to a hidden window re-checks the idle time and locks if it has passed.
- Locking also stops the reader helper.
- Locked means nothing is readable. The local server and the update check still run while the app is open.
- The browser's own storage holds only per-viewer conveniences: language, auto-lock minutes, the last chosen person and the backup reminder date. No vault, key or record.
How the page draws your documents
Text from documents, filenames and your own notes is put on screen as plain text, never as HTML. The page does not insert document content as markup. The one generated HTML string is the appointment brief you download, and every value in it is escaped. PDFs open in an in-app viewer drawn on a canvas, since the browser's own PDF plugin is not used.
Something unclear or out of date? Tell us on GitHub.