A macOS writing app built with a native shell, two web editors, and a SQLite database. Here's how all the pieces fit together -- no programming experience required.
Five specialized components work together to power FINAL|FINAL. Here's who does what.
Think of FINAL|FINAL like a theater production. Each component has a single, well-defined role. When something breaks, knowing who's responsible tells you where to look.
The native macOS frame: window, toolbar, outline sidebar, status bar. Built with SwiftUI, it orchestrates everything the user sees.
The formatted text editor. Built on ProseMirror, it shows bold as bold, headings as large text. Hides all the markdown symbols. Runs as a web page inside a WKWebView.
The raw markdown editor. Shows the actual symbols (## Heading, **bold**) with syntax highlighting. Also runs inside a WKWebView.
The courier between editors and database. Every 2 seconds it asks "got any changes?" and writes them to storage. Speaks both JavaScript and Swift.
The single source of truth. Every paragraph, heading, and list is a row in a database table. Nothing is "real" until it's saved here. The sidebar watches the database and re-renders automatically when something changes.
Each component has exactly one job. Editor handles text. Sync handles communication. Database handles storage. Sidebar handles display. This pattern -- splitting responsibilities into focused roles -- is one of the most important ideas in software engineering.
Click any component to see what it does:
Here's what happens when you type a new heading. Watch the components talk to each other:
The five actors speak two different languages. Here's the bridge that connects them.
The outer shell (toolbar, sidebar) is written in Swift. The inner editors are written in JavaScript. They run in completely different worlds -- like two diplomats who need a translator.
The bridge is window.FinalFinal -- a shared API that both sides use. Swift calls JavaScript functions to ask questions. JavaScript calls Swift message handlers to send updates back.
Here's what Swift can ask the editors to do:
setContent(md)Load new content into the editorgetContent()Get the current markdown backhasBlockChanges()"Any changes since I last checked?"getBlockChanges()Give me the details: inserts, updates, deletesconfirmBlockIds(map)"Your temp ID is now this permanent UUID"setFocusMode(on)Toggle paragraph dimming for focussetTheme(css)Apply new colors and fontsscrollToLine(n)Scroll to a specific lineTwo communication patterns run simultaneously, each optimized for different kinds of data:
When you type, the editor immediately sends the new content to Swift via postMessage. Used for content that must be saved right away -- you don't want to lose keystrokes.
BlockSyncService wakes up on a timer and asks for structural changes -- new blocks created, blocks deleted, blocks reordered. Changes accumulate between polls, so they're processed in efficient batches.
Why both? Push gives speed for the content itself. Polling gives efficiency for structural metadata. It's like a newsroom: breaking news goes out immediately (push), but the daily summary is compiled on a schedule (poll).
Click "Next Step" to trace what happens when you type a heading:
BlockSyncService uses a repeating 2-second timer to check for structural changes. Here's the function that starts this loop -- it creates a timer that calls pollBlockChanges() on every tick:
func startPolling() {
stopPolling()
pollTimer = Timer.scheduledTimer(
withTimeInterval: pollInterval,
repeats: true
) { [weak self] _ in
Task { @MainActor in
await self?.pollBlockChanges()
}
}
}Stop any existing timer first -- avoid stacking multiple timers on top of each other
Create a new timer that fires every 2 seconds and keeps repeating
Use "weak self" so neither the timer nor the service keeps the other alive forever (prevents a memory leak)
Each tick: run on the main thread and ask the editor for block changes
The editor generates dozens of tiny changes per keystroke. Polling every 2 seconds batches them together -- like collecting mail once a day instead of checking the mailbox after every letter.
When data arrives through the bridge, it's stored in a SQLite database. Here's how.
Your entire project is a single macOS package -- a folder that appears as one file in Finder. You can back it up or share it just by copying:
Your document isn't stored as one big blob of text. Instead, imagine a spreadsheet where each row is a structural element -- a paragraph, a heading, a list, a code block. Each row has a unique ID (a permanent fingerprint), a sort order (a decimal number that determines position), a type, and the actual content.
Here's the blueprint for that spreadsheet -- the database table that holds every block:
CREATE TABLE block (
id TEXT PRIMARY KEY,
sortOrder DOUBLE NOT NULL,
blockType TEXT NOT NULL,
textContent TEXT NOT NULL,
markdownFragment TEXT NOT NULL,
headingLevel INTEGER,
status TEXT,
wordCount INTEGER DEFAULT 0
);id -- a unique fingerprint (UUID) so the app can track this block even if you edit it
sortOrder -- determines position in the document. 5.0 comes before 6.0. Decimals allow insertion without renumbering
blockType -- tells the app how to render it: "heading" gets big text, "bulletList" gets bullets, "paragraph" gets plain text
textContent -- plain text stripped of formatting. Used for fast full-text search and accurate word counting
markdownFragment -- the real content with all formatting symbols (## Heading, **bold**). Used to render the document
headingLevel -- 1 through 6 for headings (# through ######), empty for everything else
status -- workflow tracking: "next", "writing", "waiting", "review", or "final"
wordCount -- how many words in this block
How do you insert a new paragraph between two existing ones without renumbering everything? Use decimal sort orders and pick a number in between:
Heading, sort order 5.0 -- stays put
Gets 5.5 -- halfway between 5.0 and 6.0. No other blocks need to move!
Sort order 6.0 -- unchanged
When you drag a section in the sidebar, the app just updates the sort order numbers on the affected blocks. The text stays in the database untouched -- only the position values change. With thousands of blocks, this is much faster than renumbering every row.
The same content displayed two different ways -- and the state machine that keeps them from colliding.
Milkdown and CodeMirror both display the exact same content from the vault, but in completely different formats. Press Cmd+/ to toggle between them. The database view below is what's actually stored on disk -- both editors are just different lenses on the same data.
WYSIWYG mode: formatted text. Bold is bold, headings are large. Markdown symbols hidden.
Both editors need to know which block is which so changes sync to the right database row. They solve this differently because they have fundamentally different document models:
ProseMirror represents documents as a tree of nodes. Each node carries invisible metadata including a blockId. You can't see it in the text -- it's like an invisible barcode.
Plain text editor -- no concept of "nodes." Block IDs are embedded as HTML comments: <!-- @bid:UUID -->
When you switch editors, the app translates between these systems: extracting node attributes into comment anchors, or vice versa.
Switching editors, zooming into a section, and dragging to reorder all involve replacing the editor's content. If two happen simultaneously, data gets corrupted. The app uses a state machine -- only one transition at a time:
Normal operation. Polling active. Everything calm.
Cmd+/ pressed. Saving cursor, swapping anchors, restoring position. Polling paused.
User double-clicked a section. Loading subset of blocks. Polling paused.
Dragging sections in sidebar. Sync suppressed to prevent races.
What if a transition gets stuck? Every non-idle state has a 5-second watchdog timer. If the app hasn't returned to "idle" in time, the watchdog forces a reset -- like a dead man's switch on a train. Prevents permanent freezes.
FINAL|FINAL connects to three external tools that extend its capabilities.
Your citation library. The app searches it via a local API at localhost:23119. Everything on your computer -- no internet required.
Universal document converter. Takes your markdown and produces Word (.docx), PDF, or ODT. Like a professional typesetter on call.
AI-powered grammar checker. Can run as a free online service or a local server for privacy. Goes beyond macOS built-in spellcheck.
When you type /cite and select a paper:
You type/cite
App queries
Zotero
You pick
a source
Citation key
inserted
Bibliography
auto-generated
Before citations work, the app sends a quick "are you there?" request to Zotero's local server. Here's the code -- it defines a thread-safe checker that connects to Zotero on your own machine and sends a minimal test request:
actor ZoteroChecker {
private let statusEndpoint =
"http://127.0.0.1:23119/
better-bibtex/json-rpc"
func check() async
-> ZoteroStatus {
// Send a minimal request
// to see if Zotero responds
}
}Define this as an actor -- a thread-safe worker (one request at a time, no collisions)
The address to check: on YOUR computer (127.0.0.1), port 23119, at Better BibTeX's endpoint
A function that checks if Zotero is running -- sends a small test request, like knocking on a door to see if anyone's home
Returns a status: running, not running, missing plugin, or timed out
Why it was built this way, and how to find your way when things break.
Every choice was a tradeoff. Understanding why things were built this way is what lets you steer AI coding tools confidently:
Why: Eliminates file watching, manifest sync, undo coordination. Tradeoff: Users can't edit files directly in Finder.
Why: ProseMirror and CodeMirror are battle-tested by millions. Tradeoff: Bridge complexity between Swift and JS.
Why: Surgical updates, easy reordering, per-block metadata. Tradeoff: More complex sync than one big string.
Why: Prevents race conditions during zoom, editor switching, reordering. Tradeoff: More upfront design work.
When something breaks, here's where to look:
Sidebar not updatingCheck BlockSyncService polling + database ValueObservation + contentStateContent lost on switchWas a flush completed before the editor swap? Check state machine.Citations brokenIs Zotero running? Better BibTeX installed? Check port 23119.Export failsIs Pandoc installed? Check PandocLocator. Valid markdown?Editor looks staleClear Derived Data. Did the web build (pnpm build) succeed?Zoom stuck5-second watchdog in EditorViewState. Is contentState returning to idle?FINAL|FINAL combines a hybrid native/web architecture, database-first storage, block-based content, state machines, and external integrations into a cohesive writing tool.
When working with an AI coding assistant on this codebase, you can now say things like: "Put this in the sync service, not the view layer," or "Use a state machine to prevent race conditions," or "Store this in the database and observe it reactively."