Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,11 @@ import reads that back. All Notes shows a `done/total` count per note.
over a full-screen space.
- **Autosave** 250 ms after you stop typing, and again on close.
- **Settings** (`⌘,`, the cog under the deck's `+`, or right-click the pill) —
four tabs. *Shortcuts* rebinds all twelve. *Deck* covers style, size, which
five tabs. *Shortcuts* rebinds all twelve. *Deck* covers style, size, which
display carries it, the edge, how far from it the pointer wakes the deck, and
whether the tabs stay out. *Notes* has the face, text size, note size and
Markdown. *Updates* shows the version, when it last checked, and checks now.
Markdown. *Sync* mirrors notes into iCloud Drive (off by default). *Updates*
shows the version, when it last checked, and checks now.
Everything applies immediately.
- **Open on hover.** Off by default: turn it on and resting the pointer on a tab
opens that note without a click.
Expand Down Expand Up @@ -181,9 +182,44 @@ import reads that back. All Notes shows a `done/total` count per note.
a single document, or a `.stickies` archive that preserves colours, archived
state and dates. **Import** reads `.stickies` back, and will also take loose
`.md` / `.txt` files.
- **Sync to iPhone and iPad through iCloud Drive.** Settings → Sync. Each note
becomes one Markdown file in iCloud Drive with a small header carrying its
colour, dates and identity; open the folder in Files on the phone and edit
them in any Markdown editor. Edits travel both ways, new files written on the
phone become notes, and deletions propagate. When both sides changed the newer
edit wins and the other version is kept in `Noty/Conflicts/`, never discarded.
- Right-click the pill for the full menu: new note, windows, edge side, launch at
login, export, import, quit.

### On the phone

Reading needs nothing: tap a file in **Files** and Quick Look renders the
Markdown. Editing needs an editor that writes **in place**, because sync reads
the file's own modification date to notice a change. [Taio][taio] is free and a
good starting point; [Runestone][runestone] (free), [Textastic][textastic] and
Obsidian (*Open folder as vault* → `iCloud Drive/Noty`) all edit in place too.

Avoid anything that takes a *copy* through an "Open in…" share sheet. Saving
then leaves a second file carrying the same `noty-id`, which is not what you
meant and not something Noty can make sense of. Opening the file from the
editor's own Files browser avoids this.

To write a note on the phone, make an ordinary `.md` file in the `Noty` folder —
no header, one line of text is enough. The Mac picks it up on its next pass,
generates an id, writes the header back, and the note joins the deck under the
filename you chose, titled by its first line. Tasks written as `- [ ]` / `- [x]`
become `☐` / `☑` on the deck and come back as Markdown. A first line of `---` is
safe; it is not mistaken for a header.

Two things worth knowing. The sync engine lives in the Mac app, so a file
written on the phone is picked up when the Mac is running, or next time it
starts. And `Noty/Conflicts/` is an archive of versions that lost a conflict —
it is there to be read and pruned by hand, and never syncs back.

[taio]: https://taio.app
[runestone]: https://runestone.app
[textastic]: https://www.textasticapp.com

## Your notes stay on your Mac

- Local SQLite database in `~/Library/Application Support/Noty/`.
Expand All @@ -195,6 +231,14 @@ import reads that back. All Notes shows a `done/total` count per note.
newer version exists. Nothing about your notes is sent — it is a plain GET of
a public XML file. Turn it off with *Check automatically* in the pill's menu,
and it never fires again.
- **iCloud sync is off until you switch it on.** With it off, none of the above
changes: nothing is written outside `~/Library/Application Support/Noty/`.
Switched on (Settings → Sync), Noty mirrors each note into
`~/Library/Mobile Documents/com~apple~CloudDocs/Noty/` as a plain Markdown
file so it can be read on an iPhone or iPad. **Those files are not encrypted** —
they cannot be, or nothing on the phone could open them. The SQLite database on
this Mac stays AES-GCM encrypted either way. It uses the iCloud Drive folder
macOS already syncs; there is no Noty server, no account and no third party.
- No Accessibility permission, no Screen Recording, no system permissions.

Verify it yourself:
Expand Down Expand Up @@ -307,6 +351,11 @@ Sources/
NoteEditor.swift NSTextView bridge, find, 250 ms autosave
LibraryWindow.swift All Notes / Archive
ExportImport.swift md / txt / single file / .stickies
NoteDocument.swift the front-matter markdown form of a note
CloudFolder.swift the iCloud Drive folder: paths, names, coordinated I/O
CloudSyncIndex.swift what the last sync pass saw
SyncPlan.swift pure decision table: notes + files + index → actions
CloudSync.swift runs the actions, schedules the passes
UndoToast.swift the ten-second undo after a delete
```

Expand All @@ -330,6 +379,11 @@ Set `NOTY_DEBUG_DECK=1` in the environment to trace deck state transitions on st
- **Not sandboxed**, so data lives in `~/Library/Application Support/Noty/`
rather than `~/Library/Containers/`. Sandboxing needs a provisioning profile,
which needs Xcode and a developer account.
- Sync goes through the iCloud Drive **folder**, not CloudKit. CloudKit needs the
`com.apple.developer.icloud-container-identifiers` entitlement, which needs a
provisioning profile, which needs Xcode and a paid developer account — the same
wall that keeps the app unsandboxed. The folder is a plain path any
non-sandboxed app may use, and macOS syncs it.
- The AES key is a `0600` file beside the database. The Keychain is the right
home for it in a distributed build, but an ad-hoc signature changes on every
rebuild, which makes the Keychain re-prompt or deny each time.
Expand Down
13 changes: 13 additions & 0 deletions Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,19 @@
"settings.shortcuts.in_note" = "In an open note";
"settings.shortcuts.tab" = "Shortcuts";

"settings.sync.caption" = "Mirror your notes into iCloud Drive so they can be read and edited on iPhone and iPad.";
"settings.sync.enable" = "Sync notes through iCloud Drive";
"settings.sync.enable_help" = "Synced notes are written to iCloud Drive as plain Markdown files. They leave the encrypted database — anyone who can reach your iCloud account can read them. The database on this Mac stays encrypted either way.";
"settings.sync.folder" = "Folder";
"settings.sync.reveal" = "Show in Finder";
"settings.sync.status" = "Status";
"settings.sync.status_last" = "Last synced %@";
"settings.sync.status_never" = "Waiting for the first sync";
"settings.sync.status_off" = "Off";
"settings.sync.status_unavailable" = "iCloud Drive is not available";
"settings.sync.sync_now" = "Sync Now";
"settings.sync.tab" = "Sync";
"settings.sync.unavailable_help" = "Sign in to iCloud and turn on iCloud Drive in System Settings.";
"settings.updates.automatic" = "Check for updates automatically";
"settings.updates.caption" = "Noty fetches one file to see whether a newer version exists.";
"settings.updates.check_now" = "Check Now";
Expand Down
13 changes: 13 additions & 0 deletions Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,19 @@
"settings.shortcuts.in_note" = "在打开的便笺中";
"settings.shortcuts.tab" = "快捷键";

"settings.sync.caption" = "将便签镜像到 iCloud 云盘,以便在 iPhone 和 iPad 上阅读与编辑。";
"settings.sync.enable" = "通过 iCloud 云盘同步便签";
"settings.sync.enable_help" = "同步的便签会以纯文本 Markdown 文件写入 iCloud 云盘,它们将离开加密数据库——任何能访问你 iCloud 账户的人都能读取。无论是否开启,这台 Mac 上的数据库始终保持加密。";
"settings.sync.folder" = "文件夹";
"settings.sync.reveal" = "在访达中显示";
"settings.sync.status" = "状态";
"settings.sync.status_last" = "上次同步于 %@";
"settings.sync.status_never" = "等待首次同步";
"settings.sync.status_off" = "已关闭";
"settings.sync.status_unavailable" = "iCloud 云盘不可用";
"settings.sync.sync_now" = "立即同步";
"settings.sync.tab" = "同步";
"settings.sync.unavailable_help" = "请在“系统设置”中登录 iCloud 并开启 iCloud 云盘。";
"settings.updates.automatic" = "自动检查更新";
"settings.updates.caption" = "Noty 会获取一个文件,用于检查是否有新版本。";
"settings.updates.check_now" = "现在检查";
Expand Down
9 changes: 9 additions & 0 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// was opened, so a user who never right-clicked was never offered an
// update however long the app ran.
_ = Updater.shared

// Off unless the user asked for it; reload() is a no-op when it is off.
CloudSync.shared.reload()
}

func applicationWillTerminate(_ notification: Notification) {
HotKeys.shared.unregisterAll()
}

/// Coming back to the Mac is the likeliest moment for the phone's edits to
/// be waiting, and the poll may be up to its full interval away.
func applicationDidBecomeActive(_ notification: Notification) {
CloudSync.shared.syncNow()
}

func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false }

// MARK: Actions
Expand Down
229 changes: 229 additions & 0 deletions Sources/CloudFolder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import Foundation

/// The iCloud Drive folder Noty mirrors notes into.
///
/// This is a plain path, not a ubiquity container: a non-sandboxed app may read
/// and write it with no iCloud entitlement, and the system's own daemon does the
/// syncing. CloudKit and ubiquity containers both need an entitlement that needs
/// a paid developer account — see
/// docs/superpowers/specs/2026-09-06-icloud-drive-sync.md.
enum CloudFolder {
static let folderName = "Noty"
static let fileExtension = "md"

static var driveRoot: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Mobile Documents/com~apple~CloudDocs",
isDirectory: true)
}

static var url: URL { driveRoot.appendingPathComponent(folderName, isDirectory: true) }

/// iCloud Drive only has a root when the user is signed in and Drive is on.
/// Every destructive decision in the sync engine is gated on this: with no
/// folder, a missing file means nothing at all.
static var isAvailable: Bool {
var isDirectory: ObjCBool = false
let exists = FileManager.default.fileExists(atPath: driveRoot.path,
isDirectory: &isDirectory)
return exists && isDirectory.boolValue
}

@discardableResult
static func ensureFolder() -> Bool {
guard isAvailable else { return false }
do {
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return true
} catch {
NSLog("Noty cloud: cannot create \(url.path) — \(error.localizedDescription)")
return false
}
}

// MARK: Names

/// Identity lives in the front-matter, so a filename is free to follow the
/// title around. `avoiding` holds lowercased names already claimed in this
/// pass, since the folder is case-insensitive on a stock Mac.
static func fileName(for note: Note, avoiding taken: Set<String>) -> String {
// Deliberately not `displayTitle`: its empty case is the localized
// "Untitled", which would rename every untitled note's file the moment
// the app language changed.
let source = note.hasCustomTitle ? note.title : Note.derivedTitle(from: note.body)
let cleaned = source
.components(separatedBy: CharacterSet(charactersIn: "/\\:*?\"<>|\n\r\t"))
.joined(separator: "-")
.replacingOccurrences(of: "-{2,}", with: "-", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
var base = String(cleaned.prefix(80))
// A leading dot hides the file from Finder and the Files app, which is the
// one thing this feature exists to avoid.
if base.isEmpty || base.hasPrefix(".") { base = "note-" + note.id.prefix(8) }

var candidate = "\(base).\(fileExtension)"
var suffix = 2
while taken.contains(candidate.lowercased()) {
candidate = "\(base)-\(suffix).\(fileExtension)"
suffix += 1
}
return candidate
}

/// Conflict copies live in their own subdirectory. A marker inside the
/// filename could always be forged by a note title — and was: a note called
/// "Refactor (conflict resolution)" filtered itself out of the folder and
/// was then deleted as remotely-missing. A directory cannot be forged,
/// because `fileName(for:)` never produces a path separator.
static let conflictsFolderName = "Conflicts"

static var conflictsURL: URL {
url.appendingPathComponent(conflictsFolderName, isDirectory: true)
}

static func conflictName(for original: String, at date: Date) -> String {
let base = (original as NSString).deletingPathExtension
return base + " (conflict " + Fmt.fileStamp.string(from: date) + ").\(fileExtension)"
}

@discardableResult
static func writeConflict(_ text: String, named fileName: String,
in directory: URL = CloudFolder.url) -> Bool {
let folder = directory.appendingPathComponent(conflictsFolderName, isDirectory: true)
do {
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
} catch {
NSLog("Noty cloud: cannot create the conflicts folder — \(error.localizedDescription)")
return false
}
return write(text, to: folder.appendingPathComponent(conflictName(for: fileName, at: Date())))
}

// MARK: Contents

/// An evicted iCloud item is stored as `.Shopping.md.icloud` — hidden, and
/// with a different extension. A listing that skipped hidden files and kept
/// only `.md` never saw it, so eviction was indistinguishable from deletion
/// and the note was deleted. Resolve both spellings to one document name.
static func resolvedName(of url: URL) -> (name: String, downloaded: Bool)? {
let raw = url.lastPathComponent
let suffix = "." + fileExtension
if raw.hasPrefix("."), raw.hasSuffix(".icloud") {
let inner = String(raw.dropFirst().dropLast(".icloud".count))
guard inner.lowercased().hasSuffix(suffix) else { return nil }
return (inner, false)
}
guard !raw.hasPrefix("."), raw.lowercased().hasSuffix(suffix) else { return nil }
return (raw, true)
}

/// One pass's view of the folder: what could be read, and what is there but
/// not readable yet. A placeholder gets a download kicked off so a later
/// pass can see it; it is never treated as absent.
static func scan(in directory: URL = CloudFolder.url) -> FolderScan {
guard let items = try? FileManager.default.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: [.contentModificationDateKey],
options: [.skipsSubdirectoryDescendants]) else { return FolderScan() }

var out = FolderScan()
for url in items.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
guard let resolved = resolvedName(of: url) else { continue }
guard resolved.downloaded else {
try? FileManager.default.startDownloadingUbiquitousItem(
at: directory.appendingPathComponent(resolved.name))
out.unresolved.insert(resolved.name)
continue
}
guard ensureDownloaded(url), let date = modificationDate(of: url) else {
out.unresolved.insert(resolved.name)
continue
}
out.documents[resolved.name] = date
}
return out
}

/// Documents whose contents are here. Kept for the Settings pane and for
/// anything that only needs the readable set.
static func documentURLs(in directory: URL = CloudFolder.url) -> [URL] {
scan(in: directory).documents.keys
.sorted()
.map { directory.appendingPathComponent($0) }
}

// MARK: I/O

/// iCloud evicts file contents and leaves a placeholder. Ask for the bytes
/// back and report whether they are actually here yet — a caller that gets
/// `false` must skip the file this pass rather than treat it as empty.
@discardableResult
static func ensureDownloaded(_ file: URL) -> Bool {
guard let values = try? file.resourceValues(
forKeys: [.ubiquitousItemDownloadingStatusKey]),
let status = values.ubiquitousItemDownloadingStatus else { return true }
if status == .current { return true }
try? FileManager.default.startDownloadingUbiquitousItem(at: file)
return false
}

static func read(_ file: URL) -> String? {
guard ensureDownloaded(file) else { return nil }
var text: String?
var coordinationError: NSError?
NSFileCoordinator().coordinate(readingItemAt: file, options: [],
error: &coordinationError) { url in
text = try? String(contentsOf: url, encoding: .utf8)
}
if let coordinationError {
NSLog("Noty cloud: read \(file.lastPathComponent) — \(coordinationError.localizedDescription)")
}
return text
}

@discardableResult
static func write(_ text: String, to file: URL) -> Bool {
var ok = false
var coordinationError: NSError?
NSFileCoordinator().coordinate(writingItemAt: file, options: .forReplacing,
error: &coordinationError) { url in
do {
try text.write(to: url, atomically: true, encoding: .utf8)
ok = true
} catch {
NSLog("Noty cloud: write \(url.lastPathComponent) — \(error.localizedDescription)")
}
}
if let coordinationError {
NSLog("Noty cloud: write \(file.lastPathComponent) — \(coordinationError.localizedDescription)")
}
return ok
}

@discardableResult
static func remove(_ file: URL) -> Bool {
var ok = false
var coordinationError: NSError?
NSFileCoordinator().coordinate(writingItemAt: file, options: .forDeleting,
error: &coordinationError) { url in
do {
try FileManager.default.removeItem(at: url)
ok = true
} catch {
NSLog("Noty cloud: delete \(url.lastPathComponent) — \(error.localizedDescription)")
}
}
if let coordinationError {
NSLog("Noty cloud: delete \(file.lastPathComponent) — \(coordinationError.localizedDescription)")
}
return ok
}

static func modificationDate(of file: URL) -> Date? {
(try? file.resourceValues(forKeys: [.contentModificationDateKey]))?
.contentModificationDate
}

// MARK: Pass view
}
Loading