Skip to content

Repository files navigation

ZenNotes for iPhone

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 zennotes repo is consumed read-only at the exact commit in .zennotes-commit. npm run source:prepare checks that commit out under the ignored .zennotes-source/ directory and installs its locked dependencies. Every typecheck and release build verifies the pin; no ambient sibling checkout can silently change a mobile binary.

Architecture

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 the shared Zustand store
    mobile.css            safe areas, overlay drawers, keyboard handling
ios/                      Capacitor-generated Xcode project (appId md.zennotes)

Key decisions (all forced by "don't modify the zennotes repo"):

  • runtime: 'web' — the bridge contract has no 'mobile' runtime yet. Every desktop-only affordance in app-core gates on runtime === '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 locationDocuments/ZenNotes/<vault> in the app container (visible in the Files app via UIFileSharingEnabled). First run creates My Vault seeded with the official demo tour (imported read-only from apps/desktop/src/main/demo-tour-data.ts).
  • On-disk contract is byte-compatible with desktop: same folder layout (inbox|quick|archive|trash, assets/, legacy attachements/ 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's systemFolderPaths remaps (vault.json can point inbox at 01 - Entry/ etc.) — classification, walking, capture targets, the drawer, and database path composition all resolve through @shared/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 # heading along (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 (kanbanCardOrder passes through the mobile vault.json layer verbatim). Remote reads use the shared absence-aware reader (@shared/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.

Build & run

npm install
npm run sync          # prepare pinned source + 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' build

Dev 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.

To adopt a newer ZenNotes core, update .zennotes-commit to a reviewed full commit SHA and run npm run upstream. Commit the pin with the mobile changes that depend on it.

What works today (verified on the iPhone 17 Pro simulator)

  • 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: null with 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 (.base CSV) 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 via the store's editorViewRef + app-core's lib/cm-format.ts; auto-hides with a hardware keyboard

  • Long-press context menus: a 450ms press on chrome surfaces synthesizes the contextmenu event 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 the group.md.zennotes App Group; the app drains them into quick notes on launch/foreground via the app-local ShareInbox Capacitor plugin (ShareInboxPlugin.swift, registered by ZNViewController). URL-only shares get a hostname title. The target is wired by tooling/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's setUbiquitous migration; 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.icloud eviction stubs are mapped to logical entries in NativeFs.readdir, reads request a download + retry, and ensureDownloaded sweeps 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.zennotes container; NSUbiquitousContainers makes 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).

Boot-order gotcha (load-bearing)

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.

Boot-path chunking (load-bearing)

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.

ZenNotes Cloud

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.

Release verification

Pull requests and main run bridge tests, a pinned-source typecheck, production dependency audits for both repositories, 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 with sync:read and sync:write, Cloud Sync access, and room for one temporary vault. Rotate it independently from human accounts.

Not yet built (per the spec's phasing)

  • 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)

About

ZenNotes iOS App

Resources

Stars

7 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages