Sync notes to iPhone and iPad through the iCloud Drive folder - #33
Merged
Conversation
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.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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'scom.apple.developer.icloud-container-identifiersentitlement.membership, an Xcode project and a provisioning profile.
.entitlementsfile, no Xcode project, and signsad-hoc by default (
build.sh,IDENTITY="${CODESIGN_IDENTITY:--}").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. Anon-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
Identity lives in the header, never in the filename, so renaming a file on the
phone does not create a duplicate.
titleis written only when the note has acustom 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.actionsis pure.(notes, folder contents, last-sync index) → [Action]. No file system, no clock, noNoteStore. The whole decision tableis unit-tested.
CloudSyncperforms the actions through aSyncFolderGatewayand aNoteStoringprotocol, so the runner is driven in tests by in-memory fakes.CloudSyncIndexrecords what the last pass saw. It lives in ApplicationSupport, never in the synced folder, so a second Mac cannot fight over it.
is confirmed absent from the folder listing. An unreadable file, a failed
read, an evicted
.Name.md.icloudplaceholder — none of those count asabsence, and placeholders get a download requested instead.
modifiedwins; the loser iswritten to
Noty/Conflicts/as a plain document with no identity, so itnever syncs back and never becomes a second note.
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:
~/Library/Application Support/Noty/.nothing on the phone could open them.
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
.mdfile 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.fromMarkdownandTasks.toMarkdownusedString.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;
toMarkdownwas not anchored at all, so a☐used mid-sentence became listsyntax. Both now go through
NSRegularExpressionwith.anchorsMatchLines, andround-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 releasesigns cleanly.The design exists to make this testable.
SyncPlan.actionsis 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:
cloud-index.jsonis lost or corrupted---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.