Native macOS desktop companion for Obsidian users (one vault or many). An always-on-top pixel-art treasure-chest creature ("Chestnut") that reacts to writing activity and acts as a control surface across vaults. Free app funded by GitHub Sponsors, no license mechanism, no paywall, no network calls. Current release is VERSION in the Makefile (0.9.0), shipped as a DMG and a Homebrew cask (gapmiss/tap/chestnut, a separate repo — see RELEASING.md).
Rationale lives next to the code it governs. The dense "why is it like this" prose is in doc comments on the types themselves, not here. This file carries orientation, the rules that must never break, and an index pointing at the rest.
| Document | Covers |
|---|---|
ARCHITECTURE.md |
Design that spans files: layers, settings split, menu structure, keyboard reachability, drop routing, containment, website mirroring, distribution |
PLUGINS.md |
Plugin authoring: manifest, env vars, output modes, JSON envelope |
CONTRIBUTING.md |
Ground rules and code style for outside PRs |
RELEASING.md |
Release process, Homebrew tap, notarization status |
| Doc comments | Everything else — see the tripwire index below |
CLI-first: SPM + Makefile. No Xcode project — don't generate one.
make build # swift build (CONFIG=debug|release)
make bundle # build -> .build/Chestnut.app (Info.plist + ad-hoc codesign)
make run # bundle + open the .app
make dmg # release build -> .build/Chestnut.dmg (drag-to-Applications)
make icon # regenerate Resources/AppIcon.icns from sprite data
make site # regenerate docs/sprites.js + favicons from Swift sources
make check # runtime checks (Checks/main.swift) — run before committing
make clean
pkill -x Chestnut # quit the app (no Dock icon; use right-click menu)- Swift 6 language mode, strict concurrency. UI types are
@MainActor. - Min deployment target: macOS 14.
LSUIElementapp — no Dock icon, no main menu.- Version source of truth:
VERSIONin Makefile, stamped into bundle plist.
Testing: this machine has Command Line Tools only (no Xcode) — no XCTest or Swift Testing. make check compiles Checks/main.swift directly against source files and runs assertions (registry parsing, FSEvents, URL building, courier traversal, sprite drift checks). Extend it when adding testable logic. In-app invariants stay as runtime preconditions.
Bash output truncation: the harness silently truncates long stdout. Never rely on seeing full output. Always pipe through tail -n N or grep. For make check:
make check > /tmp/chk.txt 2>&1; echo "EXIT: $?"; tail -5 /tmp/chk.txtRedirect to a file and read make's own exit status. Do not pipe make check into grep and report $? — that is grep's status, not make's, and the two disagree in the case that matters. ALL CHECKS PASSED is printed by the Swift binary partway through the target; the version-drift, tripwire and site checks all run after it and fail the build on their own. A grep for ALL CHECKS finds that line while make is exiting non-zero, which has already produced a confident "checks pass" over a failing tree.
Sources/Chestnut/
main.swift # entry point
AppDelegate.swift # app lifecycle, menu, panel coordination
Pet/ # SpriteKit rendering + state machine
PetWindow.swift # NSWindow host, right-click menu, drag-drop
PetScene.swift # SKScene, state-driven animation, fps management
PetController.swift # pure state machine (idle/writing/delivery)
PetGeometry.swift # window size/origin maths, screens injected (testable)
PetFrames.swift # hand-coded pixel frame matrices
Sprites.swift # frame -> SKTexture pipeline (.nearest filtering)
SpriteTheme.swift # color palettes (built-in + user custom themes)
Vaults/ # vault discovery + filesystem observation
VaultRegistry.swift # parses obsidian.json, keyed by path (never name)
VaultWatcher.swift # FSEvents per-vault file watcher
Actions/ # user-initiated operations
ObsidianBridge.swift # obsidian:// URLs, CLI eval, vault/note/file opening
Courier.swift # note delivery across vaults (move/copy + attachments)
Capture.swift # quick-capture to daily note or inbox
Panels/ # SwiftUI palettes hosted in NSPanel
VaultPalette.swift # vault hopper + courier destination picker
CapturePanel.swift # quick-capture editor with formatting toolbar
NoticePanel.swift # speech-bubble feedback (anchored to sprite)
PetPanel.swift # shared panel hosting utilities
Plugins/ # user-extensible shell-script plugins
PluginManifest.swift # manifest.json parsing, PluginEnvelope (api: 1)
PluginRegistry.swift # discovery + FSEvents hot-reload of plugins dir
PluginRunner.swift # Process execution, timeout, output interpretation
PluginDispatch.swift # pasteboard → input type classification
DropRouter.swift # pure drop routing (testable)
PluginPalette.swift # picker UI for multiple matching plugins
Support/
Config.swift # JSON config (user-owned, hand-edited)
AppState.swift # JSON state (app-owned, menu-driven)
Hotkeys.swift # global hotkey registration (Carbon)
Journal.swift # courier/capture operation journal for undo
ObsidianCLI.swift # trusted-path CLI lookup (/opt/homebrew, /usr/local)
AppInfo.swift # version, URLs (GitHub releases, GitHub Sponsors)
Scripts/ # code generators (no runtime dependency)
Resources/ # Info.plist, AppIcon.icns
Checks/main.swift # runtime test assertions (make check)
docs/ # hand-written landing page (GitHub Pages root)
Breaking any of these is a bug, not a trade-off.
- Never modify Obsidian's files or settings. Read-only observation of
obsidian.json, vault dirs,.obsidian/*.json. Writes only as explicit user-initiated actions (courier move, capture append), never to.obsidian/. - Key vaults by path, never by name. Names collide.
obsidian://open?path=…throughout. - No network calls, no telemetry. "Check for Updates" opens the GitHub releases page in a browser.
- The
obsidianCLI is optional. Every CLI call needs a direct-filesystem fallback; everything works with Obsidian closed or the CLI missing. Trusted-path lookup only, never$PATH. - The courier never overwrites. Name conflicts get Obsidian-style suffixes, and every operation is journaled for undo.
- The app never writes
config.jsonexceptcreateIfMissing()on first run. Anything gaining a UI moves toAppState. - Vault containment is lexical, and that is the whole promise. Plugin writes refuse paths that lexically resolve outside the vault root. Symlinks are not resolved. Do not "harden" this into a
realpathcheck without re-reading the threat model: every write-side caller is fed by a plugin envelope, plugins are shell scripts already running as the user with full filesystem access, and the only way a symlink enters a vault is if the user put it there. Resolving would break a symlinked attachment folder to buy a guarantee thatcpbypasses anyway. - No image assets. Sprites are hand-coded matrices;
docs/sprites.jsis generated bymake siteandmake checkfails on drift. - No reuse of Obsidian's gem logo. "for Obsidian" nominative phrasing only.
- Cross-Vault Search is permanently out of scope. Decided early, won't build it.
- Undo depth is not a feature to grow. Undo is for "take back what I just did".
Each line is a verdict you can act on without opening anything, plus where the full account lives. Read the cited comment before changing the thing it describes — every one of these records a fix that was measured, or an approach that was tried and reverted.
- Palettes are sized once, at open; measure before clearing
sizingOptionsor the panel opens invisible →Sources/Chestnut/Panels/PetPanel.swift:host - The row a palette opens on is announced separately from a move; the 1800ms delay is tuned by ear and moving the sentence to
.accessibilityLabelwas tried and is silent →Sources/Chestnut/Panels/PetPanel.swift:announceOnOpen - Selection moves are announced explicitly; row traits alone are silent while focus stays in the filter field →
Sources/Chestnut/Panels/VaultPalette.swift:announceSelection - Hover must not arm until the pointer moves, and the selection half must use
.onContinuousHover, never.onHover→Sources/Chestnut/Panels/PetPanel.swift:HoverArming - Every spoken announcement posts through one site →
Sources/Chestnut/Panels/PetPanel.swift:announceToVoiceOver - A caller's own cleanup rides on
afterDismiss, never chained ontoonClose—presentPaletteclearsonClosewhen one palette supersedes another, and a plugin save lost its whole output that way →Sources/Chestnut/Panels/PetPanel.swift:afterDismiss
Changes here can only be verified with VoiceOver actually running (⌘F5). Nothing in make check can hear. Panels/ is outside the check target.
The pointer vanishing when a palette opens under it is AppKit, not us — a key event with a focused text field calls NSCursor.setHiddenUntilMouseMoves(true). Checked against released 0.7.0, which contains none of the arming code, and the cursor vanishes identically. Do not "fix" it.
- Reduce Motion freezes the effect, not the frame rate; the menu can add stillness but never take it away →
Sources/Chestnut/Pet/PetScene.swift:setMotionFrozen - A saved position no screen intersects is reset; one merely partly off is clamped, and the rescue deliberately does not persist →
Sources/Chestnut/Pet/PetGeometry.swift:validatedOrigin - The menu flips above the sprite when it won't fit below →
Sources/Chestnut/Pet/PetGeometry.swift:menuOrigin - The
menuhotkey is unregistered for as long as the menu tracks, or Carbon queues every press and replays them →Sources/Chestnut/Support/Hotkeys.swift:setMenuHotkeyEnabled - A failed menu binding raises a notice; the other four only log.
InstallEventHandlerfailing means register nothing →Sources/Chestnut/Support/Hotkeys.swift:onMenuHotkeyFailure
Courier.undoattempts every transfer exactly once; failures are collected, not fatal →Sources/Chestnut/Actions/Courier.swift:partiallyUndone- A failed note read is thrown, never coerced to
""— the coercion moved notes without their attachments and reported success →Sources/Chestnut/Actions/Courier.swift:unreadableNote - The two containment variants differ on the vault root, deliberately →
Sources/Chestnut/Actions/Courier.swift:isContainedDirectory last()andremoveLast()must resolve the top record the same way, past any line that won't decode →Sources/Chestnut/Support/Journal.swift:topIndex- An oversized record sheds its copy payload, never its undo instruction; the two record types answer differently and the asymmetry is the design →
Sources/Chestnut/Support/Journal.swift:JournalShedding - Undo rows name their record in the subtitle, not the title —
NSMenusizes to its widest row →Sources/Chestnut/Actions/Courier.swift:undoMenuSubtitle - The plugin-save Undo row is hidden, not dimmed, for a user with no plugins — but a record keeps it visible after the plugin is deleted, or an undo goes unreachable →
Sources/Chestnut/Plugins/PluginSave.swift:showsUndoRow - Plugin-save undo trashes without asking whether the note was edited, and that is safe because the save never overwrote; the size check guards a re-occupied path, not a user's edits →
Sources/Chestnut/Plugins/PluginSave.swift:noteChanged - The direct-FS capture append can race Obsidian's debounced save; the CLI path is already preferred where it can be trusted →
Sources/Chestnut/Actions/Capture.swift:appendDirectly - All three undos follow the vault's own
trashOption, but"none"is deliberately not honored — it is clamped to the system Trash, because an undo reverses something Chestnut did rather than something the user chose to delete →Sources/Chestnut/Support/VaultTrash.swift:honored - The
obsidianCLI'sdeletedoes not honortrashOption(tested twice against a"local"vault); undo is filesystem-only →Sources/Chestnut/Support/VaultTrash.swift:VaultTrash
- The ignore list filters
registry.vaults;registry.allVaultsstays unfiltered and exists for one question — whether a vault name is unique enough to hand to theobsidianCLI. Obsidian resolves that name against its own list, so an ignored vault still collides →Sources/Chestnut/Vaults/VaultRegistry.swift:allVaults removingIgnorednormalizes both sides. Normalizing only the vault path made~/Vaults/Work/match fromstartand match nothing from any other caller — a fence that fails open, and silently →Sources/Chestnut/Vaults/VaultRegistry.swift:removingIgnored- The ignore list arrives as a
start(ignoring:)argument, not a settable property, so it cannot be installed after the first reload has already published an unfiltered list →Sources/Chestnut/Vaults/VaultRegistry.swift:start - Ignoring is
config.jsonand stays there: a menu toggle would need somewhere to list ignored vaults so they could come back, putting the vault back in the interface it was added to stay out of →Sources/Chestnut/Support/Config.swift:ignoredVaultPaths
- After a
brew upgrade, do not tell the maintainer the app will be Gatekeeper-blocked or to runxattr -dr. Homebrew re-stampscom.apple.quarantineon every cask download andspctl -a -vvsaysrejectedfor any ad-hoc signature — neither predicts a prompt, and an approval already given survives the upgrade. Corrected three times now, twice from the quarantine attribute and once fromspctl→ARCHITECTURE.md, "Distribution" - The upgrade defers the block to the next restart rather than skipping it, so neither "an upgrade never prompts" nor "a restart always prompts" is the rule on its own. Two boots on macOS 15 the same day: the first after an upgrade was blocked, the second on the identical bundle was not →
ARCHITECTURE.md, "Distribution"
Routehas no plugin-only case, and making it unrepresentable is what stops a refactor from restoring the shadowing →Sources/Chestnut/Plugins/DropRouter.swift:Route- A folder dragged from Obsidian's explorer arrives as a bare basename; divert before
classifyDrag→Sources/Chestnut/Plugins/DropRouter.swift:isPathlessObsidianDrag - A multi-select arrives as one run-on string; split on the scheme rather than parsing the payload once →
Sources/Chestnut/Actions/ObsidianBridge.swift:ObsidianOpenLink - Pasted images pick PNG over TIFF, and the extension comes from the same decision as the bytes →
Sources/Chestnut/Plugins/PluginDispatch.swift:imagePayload - Filenames are one grammar; the envelope used to bypass it →
Sources/Chestnut/Plugins/PluginRunner.swift:sanitizedFilename - On
capture, only attachments the submitted note refers to are copied →Sources/Chestnut/Actions/Capture.swift:partitionAttachmentsByReference - The registry creates the plugins directory before watching it; deleting that line costs hot-reload for the whole session →
Sources/Chestnut/Plugins/PluginRegistry.swift:start - Courier candidacy is passed in from the drop site and must never be re-derived, or ⌃⌥C offers delivery of a temp file that gets deleted →
Sources/Chestnut/AppDelegate.swift:discardPendingPluginTemp - The chewing pose is derived from the set of live runs, never toggled per run; two overlapping plugins used to leave the pet still while one was still working →
Sources/Chestnut/Plugins/PluginRunRegistry.swift:PluginRunRegistry - The Running Plugins submenu is hidden at zero runs, not shown empty — same rule and reasoning as the plugin-save Undo row →
Sources/Chestnut/Plugins/PluginRunRegistry.swift:showsRunningSubmenu - Nothing signals a process group without first asking the kernel whether that PID is still our child. Foundation reaps the child before the termination handler runs (measured 3/3), so holding the
Processobject does not reserve the PID →Sources/Chestnut/Plugins/PluginRunner.swift:isOurChild - Streaming acts on
notifyonly; everything that writes waits for exit 0, which is what keeps "a plugin that fails writes nothing" true →Sources/Chestnut/Plugins/PluginRunner.swift:StreamCollector - Streaming is opt-in, because an existing
structuredplugin pretty-prints one envelope across many lines and line-splitting by default would break every one →Sources/Chestnut/Plugins/PluginManifest.swift:stream - A plugin result arriving more than a minute after the drop never takes focus; a late
captureparks its draft behind a clickable notice, and a latesaveneeding a vault parks the same way — measured, that picker caught a Return meant for another app and wrote a note to a vault nobody chose →Sources/Chestnut/AppDelegate.swift:unattendedRunSeconds - A waiting capture draft badges the Capture… row, because a notice fades and the draft does not →
Sources/Chestnut/Pet/PetWindow.swift:hasCaptureDraft - Nothing may exist only inside a notice. A waiting plugin save is state with a menu row of its own; the bubble is an announcement that costs nothing to miss. Holding the offer in the bubble's closure made every way a bubble can end a way to lose a plugin's work, and crashed the app once →
Sources/Chestnut/AppDelegate.swift:PendingPluginSave - A notice handler may show another notice:
dismisstakes and clears the handler before running it →Sources/Chestnut/Panels/NoticePanel.swift:dismiss
- The pet is "Chestnut" in copy, never "the chestnut". Generic noun: "a pixel-art treasure chest creature".
- No em-dashes in site copy (
docs/). Code comments there are exempt. - Don't hard-wrap prose in Markdown; let it soft-wrap.
- When you fix something subtle, put the account in a doc comment beside the code and add a one-line verdict to the tripwire index above.
make checkverifies every pointer here still resolves.