A Capacitor shell that runs the ZenNotes product core (packages/app-core from
the zennotes monorepo) inside a WKWebView, backed
by a local-first vault on the device filesystem. Implements the architecture in
docs/specs/mobile/ (Phase 0 + the on-device parts of Phase 1).
The shell consumes immutable, compiled @zennotes/app-core,
@zennotes/bridge-contract, and @zennotes/shared-domain archives. The exact
archives are vendored under vendor/zennotes/ with their source identity and
checksums (manifest.json), and package-lock.json pins the complete install.
A clean checkout installs them with npm ci, without a source clone or sibling
repository. The vendored set is the core release
core-2.76.0-core.h076cc71aa1ce0bbe
(desktop commit 61628218, clean tree).
npm run boundaries:check verifies the pins, installed versions, singleton
React/CodeMirror peers, and public export usage. npm run core:adopt -- <core-tag>
swaps in a new release (see "Adopting a newer core" below). Native storage,
iCloud, stable vault identities, keyboard behavior, sync, and preferences remain
in this repo.
src/
bootstrap.ts pre-app-core prefs/theme seeding (must import first)
main.tsx install bridge → open vault → renderZenNotesApp()
bridge/
mobile-bridge.ts the third ZenBridge (window.zen) implementation
vault-fs.ts desktop vault.ts semantics over Capacitor Filesystem
vault-core.ts pure helpers ported 1:1 (folder map, meta extraction,
naming, search scoring) — keep in sync with desktop
native-fs.ts Capacitor Filesystem wrapper (atomic writes, file URLs)
events.ts VaultChangeEvent emitter (in-app writes + rescan)
ui-mobile/
MobileShell.tsx bottom nav (capture ⊕ / search / sidebar / palette),
phone drawer behavior via public core snapshots/actions
mobile.css safe areas, overlay drawers, keyboard handling
ios/ Capacitor-generated Xcode project (appId md.zennotes)
Host decisions:
hostKind: 'ios',runtime: 'web'— the bridge contract has no'mobile'runtime yet. Every desktop-only affordance in app-core gates onruntime === 'desktop', so'web'+ the capability flags produces correct mobile behavior. When the contract gains'mobile'+ the new capability flags (spec 02), flip it here.- Vault location —
Documents/ZenNotes/<vault>in the app container (visible in the Files app viaUIFileSharingEnabled). First run createsMy Vaultseeded with the official demo tour (imported read-only from@zennotes/shared-domain/demo-tour-data). - On-disk contract is byte-compatible with desktop: same folder layout
(
inbox|quick|archive|trash,assets/, legacyattachements/recognized — the misspelling is intentional and load-bearing), same.zennotes/metadata (vault.json, workspace.json, comments/), same naming/collision rules, same NoteMeta extraction regexes. Includes desktop 2.20'ssystemFolderPathsremaps (vault.json can pointinboxat01 - Entry/etc.) — classification, walking, capture targets, the drawer, and database path composition all resolve through@zennotes/shared-domain/system-folder-paths, so a remapped vault synced from a Mac files notes identically here. - Desktop 2.20 features on mobile: renaming a note carries its leading
# headingalong (runs in the shared store — nothing to port, verified on sim). Workflows are not offered (bridge stubs mirror the web client: empty lists, honest rejections; the Settings sub-tab is hidden by the mobilizer). Custom TextMate code languages are capability-gated off (supportsCustomCodeLanguages: false— the Settings tab shows the desktop-only notice). DOCX export is desktop-gated upstream. - Desktop 2.21/2.22 features on mobile (all shared-source, sim-verified):
in-progress task state (
- [/], agreed across editor/list/kanban; set via long-press → Mark in progress), parent-task subtask rollups, archived notes retiring their tasks, inline mermaid in the editor (theme-integrated, off the boot path — see chunking note below), text replacements (->→→, Settings → Editor → Text replacements), configurable tab size, manual kanban card order (kanbanCardOrderpasses through the mobile vault.json layer verbatim). Remote reads use the shared absence-aware reader (@zennotes/shared-domain/remote-absence): a 500 from a schema read surfaces as an error instead of adopting-and-overwriting the database sidecar; pre-2.20.2 servers that answer 500 for missing files are probed once per connection. - TikZ is capability-gated off (no WASM TeX on device); blocks show the source with a "renders on ZenNotes desktop" notice, per spec 05.
- Vim mode defaults off on first run (soft keyboard; spec 06) — Settings can re-enable it. The desktop onboarding wizard is skipped; the vault is auto-provisioned.
npm ci
npm run boundaries:check
npm run sync # vite build + cap sync ios
npx cap open ios # open in Xcode, or:
xcodebuild -workspace ios/App/App.xcworkspace -scheme App \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' buildDev loop against a browser (no simulator): npm run dev — note Capacitor
plugins are absent in a plain browser, so vault I/O won't work; use the
simulator for real testing.
npm run core:adopt -- <core-tag> --source <desktop-commit> downloads a core
release from the desktop repo (core-X.Y.Z-core.h…, drafts work with an
authenticated gh), verifies every archive's SHA-256 and SHA-512 against its
provenance, the packed package versions, that all three archives record the
same clean source commit, then swaps the archives into vendor/zennotes,
rewrites manifest.json, the three file: dependencies, and the sentence above,
runs npm install, and runs the boundary check. Add --dry-run to verify and
print the plan without writing anything, or --from <dir> for a local
prepare-boundary-release.mjs core build. The script runs no git command:
review the diff, then stage package.json, package-lock.json, vendor/zennotes,
and README.md by path. Run npm run upstream, npm test, npm run sync, and
a simulator pass before changing the pin. Never resolve a mutable branch at
build time.
-
Vault CRUD: list/read/write/create/rename (with inbound wikilink rewrite)/ duplicate/move/trash/restore/archive, folders, favorites, comments, templates, vault settings, workspace (session) restore
-
Rendering: KaTeX, Mermaid, JSXGraph, function-plot, highlight.js, images; TikZ graceful fallback
-
Search: fuzzy per-line text search (builtin backend semantics) + title quick switcher; tags view; tasks view (parser shared from shared-domain)
-
Quick capture from the bottom nav (creates in
quick, opens immediately) -
Relaunch restores your place (issue #2): a cold launch lands back in the note (or virtual view) that was open when iOS killed the app; leaving from Home relaunches to Home. Mobile's Home persists as
activeTab: nullwith tabs kept open behind it — a state the store's restore sanitizer coerces to "first tab active" — so the shell reads the raw workspace.json witness before restore runs and re-deselects when it says Home. Vault switches still always land on Home (the 1.2 behavior). -
Mobile chrome (iOS-native pass, all phone-width only — iPad keeps the desktop-like layout per spec 07):
- desktop chrome hidden: window title bar, tab strip, editor icon toolbar, word-count status bar, Split mode
- bottom nav: sidebar · back · capture ⊕ · search · ••• (all actions run through the shared command registry, same as the palette)
- ••• opens an iOS action sheet: Edit/Read segment + note actions (outline, rename, move, copy wikilink, archive, trash) + app actions (open notes, vault text search, settings)
- Settings: the desktop two-pane dialog becomes a full-screen paged flow (section list → detail with ‹ back, Done to close; MCP/CLI hidden) via the "mobilizer" in MobileShell — CSS state + two injected buttons, no app-core changes
- dialogs render as bottom sheets; palettes go full-width; side panels (outline/connections/comments) overlay instead of squeezing the editor
- overlay sidebar drawer + backdrop with slide animation, safe areas, keyboard show/hide handling, theme pre-boot (no flash), haptic on capture
- keyboard-hint strips hidden on phones (vim hints in Tasks list/calendar/ kanban and Tags footers, palette "Ctrl+N/P · esc" rows, focused-row key chips, the Home "Sidebar ⌘1" chip); collection headers compacted (Refresh/Close dropped, filter narrowed, header wraps); the Assets table collapses to name + used-in on phone width
-
Databases (
.baseCSV) using the same shared logic as the web client -
The spec-06 editing toolbar docked above the soft keyboard (undo/redo, checkbox, bullet, heading cycle, bold/italic/highlight/code, link, wikilink, tag, indent/outdent, dismiss) — drives the shared editor through named public commands; auto-hides with a hardware keyboard
-
Long-press context menus: a 450ms press on chrome surfaces synthesizes the
contextmenuevent the desktop handlers already listen for (the finger-lift's synthetic mouse burst is suppressed or the menu would close instantly); disabled inside the editor to preserve text selection. Covers the task surfaces (list rows, kanban cards, calendar day cells) since the 2.22 sync — the shared task menu (Mark in progress, due dates, priority, forward) is otherwise unreachable without a right mouse button -
Archive/unarchive round-trip (subpath preserved); the ••• sheet is contextual (Unarchive in archive, Restore in trash)
-
iPad: ≥768px keeps the desktop-like layout per spec 07 (persistent sidebar, no bottom nav) — verified on the iPad Pro 11" simulator
-
Share Extension ("Share to ZenNotes" from any app): the native extension (
ios/App/ShareExtension/) writes shared text/URLs into thegroup.md.zennotesApp Group; the app drains them into quick notes on launch/foreground via the app-localShareInboxCapacitor plugin (ShareInboxPlugin.swift, registered byZNViewController). URL-only shares get a hostname title. The target is wired bytooling/add-share-extension.rb(xcodeproj gem via Homebrew CocoaPods) — re-run it if the Xcode project is ever regenerated. Device builds need the App Group registered with the Apple Developer account (automatic signing handles it with a paid membership); the simulator doesn't enforce it. -
Edge-swipe gestures: swipe right from the left edge opens the drawer, swipe left on the drawer closes it (edge-start only, so editor selection and kanban scrolling are untouched)
-
iCloud Drive vault tier (spec 03): ••• → iCloud Sync moves the vault into the app's ubiquity container (
iCloud Drive › ZenNotes) via Apple'ssetUbiquitousmigration; the OS syncs it across devices, and a Mac can open~/Library/Mobile Documents/iCloud~md~zennotes/Documents/ZenNotes/…as a desktop vault. Never combined with any other sync on the same vault (enable = move, not copy). Placeholder discipline:.name.icloudeviction stubs are mapped to logical entries inNativeFs.readdir, reads request a download + retry, andensureDownloadedsweeps the tree at vault open and on every foreground — evicted files must never read as deleted (the documented Obsidian data-loss class). Graceful fallback to local storage when iCloud is signed out. Native side:ICloudVaultPlugin.swift; entitlements: CloudDocuments +iCloud.md.zennotescontainer;NSUbiquitousContainersmakes the folder visible in the Files app. Simulator/device testing requires a signed-in Apple ID (and a paid developer team on real devices — iCloud isn't in free provisioning).
Prefs seeding + theme attributes live in an inline classic <script> in
index.html, not a module: the app-core store reads
localStorage['zen:prefs:v2'] at module-evaluation time, and Rollup chunk
hoisting (manualChunks) runs the store chunk before any entry-chunk module —
an imported "bootstrap.ts" silently ran too late on fresh installs.
There is deliberately no manualChunks rule for mermaid/cytoscape/dagre
(upstream 2.20 finding): naming that chunk hoisted it into the entry's static
graph, so every cold start fetched and evaluated ~2.5MB of diagram code (plus
vendor-markdown, which it imports) before a note was even open. Left to
Rollup, mermaid splits into async per-diagram chunks fetched the first time a
diagram renders. Same idea: /@xyflow/ is excluded from the vendor-react
substring match so React Flow stays inside the never-loaded WorkflowsView
chunk. Check dist/index.html's modulepreloads after touching the config —
the entry must not statically import mermaid, markdown, or highlight chunks.
On-device vaults can connect to the optional ZenNotes Cloud service from Settings → Cloud. The mobile bridge stores the account token in the native keychain, links or creates a cloud vault, runs the shared offline-first sync engine, and exposes backups, note-level restore, publishing, and automatic sync on app foreground and local changes. Local vaults and iCloud continue to work without an account or subscription. Files larger than the 5 MiB inline limit use Cloud's signed object-storage upload flow, with the account token kept off the object-storage request and a five-minute mobile transfer timeout.
Settings → Cloud → Sync in the background (on by default, per device) lets a
sync finish after you leave the app. The sync engine runs in the WebView,
which iOS suspends seconds after the app leaves the screen, so the app asks
for UIKit's background task (about 30 seconds) as it leaves, finishes a sync
in it, and stops its Cloud requests once that sync ends or iOS ends the time
(src/bridge/background-sync.ts, ios/App/App/BackgroundSyncPlugin.swift).
Nothing runs on a timer while the app is away: changes from other devices
arrive when it opens. With the setting off, leaving stops Cloud requests at
once, as before.
Pull requests and main run bridge tests, an installed-package typecheck,
production dependency audits, a Capacitor sync, and an
Xcode build-for-testing of the app and Cloud UI-test targets. Dependabot
opens weekly npm and GitHub Actions updates.
The scheduled Cloud direct-upload E2E workflow verifies the deployed API,
signed object upload, completion, manifest, and cleanup with a deterministic
6 MiB file. Configure these repository secrets before enabling it:
ZENNOTES_CLOUD_E2E_BASE_URL— the HTTPS production or staging origin.ZENNOTES_CLOUD_E2E_TOKEN— a dedicated active-device token withsync:readandsync:write, Cloud Sync access, and room for one temporary vault. Rotate it independently from human accounts.
- Home-screen widget / App Shortcuts capture entry points
- iPad split view (two notes side by side); Android (Phase 2)
- Store distribution work (signing, TestFlight, App Store listing — spec 08)
For device-level package checks, see native boundary validation.