Skip to content

Sync notes to iPhone and iPad through the iCloud Drive folder - #33

Merged
aimen08 merged 3 commits into
aimen08:devfrom
vasyakrg:icloud-drive-sync
Sep 9, 2026
Merged

Sync notes to iPhone and iPad through the iCloud Drive folder#33
aimen08 merged 3 commits into
aimen08:devfrom
vasyakrg:icloud-drive-sync

Conversation

@vasyakrg

@vasyakrg vasyakrg commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Opt-in, two-way sync between the deck and a folder in iCloud Drive. Each note
becomes one Markdown file with a small front-matter header; the folder opens in
Files on a phone and the notes edit in any Markdown editor. Edits travel both
ways, files written on the phone become notes, and deletions propagate.

No Apple Developer account, no entitlements, no iOS app, no server.

Targets dev, per the branch policy in the README.

Why the iCloud Drive folder and not CloudKit

CloudKit was the first thing I tried to justify, and it does not fit this
project:

  • CKContainer(identifier:) requires the identifier to be listed in the app's
    com.apple.developer.icloud-container-identifiers entitlement.
  • Adding the iCloud capability requires an active Apple Developer Program
    membership, an Xcode project and a provisioning profile.
  • This repository has no .entitlements file, no Xcode project, and signs
    ad-hoc by default (build.sh, IDENTITY="${CODESIGN_IDENTITY:--}").
  • An unentitled call fails at runtime with CKError.missingEntitlement.

That is the same wall that keeps the app unsandboxed, and it would also require
writing an iOS app from scratch — a separate product, not a feature of this one.

~/Library/Mobile Documents/com~apple~CloudDocs/ is an ordinary directory. A
non-sandboxed app reads and writes it with no entitlement, and the system's own
daemon does the syncing. That is the whole mechanism.

The file format

---
noty-id: 8E3C1F0A-...
color: 3
created: 2026-09-06T10:00:00Z
modified: 2026-09-06T10:05:00Z
archived: false
pinned: false
order: -3
direction: automatic
title: A custom title
---

# First line of the body

- [ ] an open task
- [x] a finished task

Identity lives in the header, never in the filename, so renaming a file on the
phone does not create a duplicate. title is written only when the note has a
custom one — an empty title still means "derive it from the first line", which
is the model's own rule. Tasks are stored as Markdown task syntax on disk and
converted to the in-app / prefixes on the way in.

A file with no header is a note somebody wrote on their phone: it is adopted,
given an id, and the header is written back.

How it works

  • SyncPlan.actions is pure. (notes, folder contents, last-sync index) → [Action]. No file system, no clock, no NoteStore. The whole decision table
    is unit-tested.
  • CloudSync performs the actions through a SyncFolderGateway and a
    NoteStoring protocol, so the runner is driven in tests by in-memory fakes.
  • CloudSyncIndex records what the last pass saw. It lives in Application
    Support, never in the synced folder, so a second Mac cannot fight over it.
  • Deletion follows from evidence. A note is only deleted when its document
    is confirmed absent from the folder listing. An unreadable file, a failed
    read, an evicted .Name.md.icloud placeholder — none of those count as
    absence, and placeholders get a download requested instead.
  • Conflicts keep both versions. The newer modified wins; the loser is
    written to Noty/Conflicts/ as a plain document with no identity, so it
    never syncs back and never becomes a second note.
  • A quiet pass is a directory listing. A document whose modification date
    matches the index is described from the index rather than re-read, and the
    index is only rewritten when a pass actually did something.

The privacy trade-off — this is the part worth arguing about

The README currently promises, in its own section, that notes never leave the
Mac. This feature makes that conditional, and I did not want to slip that past
you:

  • It is off by default and takes an explicit switch in Settings → Sync.
  • With it off, nothing is written outside ~/Library/Application Support/Noty/.
  • With it on, the files in iCloud Drive are plaintext. They have to be, or
    nothing on the phone could open them.
  • The SQLite database on the Mac stays AES-GCM encrypted either way.
  • The Settings pane states this in as many words, and the README section is
    corrected in the same change rather than left stale.

If you would rather the app not offer this at all on those grounds, that is a
legitimate call and I would rather hear it before you spend time on the diff.

What this looks like on the phone

Reading needs nothing — Files renders Markdown in Quick Look. Editing needs an
editor that writes in place, since sync notices a change by the file's own
modification date: Taio, Runestone, Textastic and Obsidian all qualify. An
editor that takes a copy through an "Open in…" share sheet leaves a second file
carrying the same noty-id, which is the limitation below.

Writing a note on the phone needs no knowledge of the format: an ordinary .md
file with one line of text is picked up, given an id, and the header is written
back into it. The README covers this.

Also fixes a pre-existing bug

Tasks.fromMarkdown and Tasks.toMarkdown used
String.replacingOccurrences(options: .regularExpression), which cannot request
.anchorsMatchLines. ^ therefore matched only the start of the whole string,
so importing a Markdown file with three tasks converted only the first one;
toMarkdown was not anchored at all, so a used mid-sentence became list
syntax. Both now go through NSRegularExpression with .anchorsMatchLines, and
round-tripping is covered by tests.

This one is independent of sync and I am happy to split it into its own PR if
you would prefer to take it separately.

Testing

./scripts/test-editor.sh — the existing suite, extended with two new files.
The suite passes and ./build.sh release signs cleanly.

The design exists to make this testable. SyncPlan.actions is a pure function,
so the decision table is exercised directly, and the runner talks to the world
through two protocols, so it runs end-to-end against in-memory fakes.

Sync gets a great many chances to destroy somebody's notes, so the destructive
cases are enumerated and pinned by tests rather than left to judgement:

Case Guaranteed behaviour
A file's contents are evicted by "Optimize Mac Storage" The note is untouched; a download is requested
A read fails between listing and reading The note is untouched
Two notes carry the same title Two files; neither overwrites the other
A note's title merely reads like a conflict copy Still a normal note
A file is renamed on the phone The edit lands in the renamed file; no duplicate
A file is duplicated on the phone The copy becomes a second note with its own id
cloud-index.json is lost or corrupted Nothing reverts; no conflict copies are manufactured
A note written on the phone opens with --- Its first lines survive
A note is open in an editor when a pull arrives The editor follows; the phone's edit is not overwritten
iCloud Drive is signed out Sync disables itself and touches nothing

The branch was written first and then reviewed adversarially; ten defects came
out of that review, seven of which lost notes, and each one is fixed with the
test that catches it. The table above is that review's residue.

Scope

20 files, +1979 / -13. No new dependencies; Sparkle remains the only one.

Discussed first in #32.

String.replacingOccurrences(options: .regularExpression) cannot request
.anchorsMatchLines, so ^ matched only the start of the whole string and a
document with several tasks converted just the first one. toMarkdown was not
anchored at all, so a marker used mid-sentence became list syntax.

Both now go through NSRegularExpression with .anchorsMatchLines, and use
[ \t] rather than \s so a match cannot swallow a newline.
Each note becomes one Markdown file in
~/Library/Mobile Documents/com~apple~CloudDocs/Noty/, with a front-matter
header carrying its identity, colour, dates and order. Edits travel both ways,
files written on a phone are adopted as notes, and deletions propagate.

This is the iCloud Drive folder, not CloudKit: CKContainer needs the
com.apple.developer.icloud-container-identifiers entitlement, which needs a
provisioning profile, Xcode and a paid developer account. The folder is a plain
path a non-sandboxed app may use, and the system daemon syncs it.

Off by default. Synced files are plaintext — they have to be readable on the
phone — while the local database stays AES-GCM encrypted.

SyncPlan.actions is a pure function of (notes, folder, last-sync index), so the
decision table is unit-tested; CloudSync reaches the world through two
protocols, so the runner is tested against in-memory fakes. Deletion follows
only from a document confirmed absent: an unreadable file, a failed read or an
evicted .icloud placeholder never delete a note.
@coderabbitai

coderabbitai Bot commented Sep 7, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: bd96e739-eabf-4a07-a7c7-6b55269337a0

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

aimen08 added a commit that referenced this pull request Sep 9, 2026
Resolved against the throwing persistence layer and the #33 sync merge:
the image lifecycle hooks (launch orphan sweep, undo-window cleanup)
graft onto the refactored NoteStore behind its writable latch; archive
image restore folds into decodeArchive so version and color validation
still run first; the editor keeps store-backed bindings.

Thanks-to: @Libeny — inline images (#35)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@aimen08
aimen08 merged commit a45a59a into aimen08:dev Sep 9, 2026
1 check passed
aimen08 added a commit that referenced this pull request Sep 10, 2026
Opt-in iCloud Drive sync (#33), inline images in note bodies (#35), a
hardened persistence layer, animated tab reflow (#34), and an option to
hide the deck's buttons when it is kept open (#36).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants