Skip to content
RS-P04MakerApr 2026 to presentOngoing

Asteroid

A deliberately small notes and checklist app in pure SwiftUI, with zero third-party dependencies and its own offline-first sync engine.

Overview

Asteroid is my iOS notes and checklist app, and it is deliberately small: you keep lists, check things off, and see them on every surface iOS allows. It surfaces as Home Screen and Lock Screen widgets, as a Live Activity in the Dynamic Island, through Siri and Shortcuts, and through a share extension in the system share sheet. The entire thing is pure SwiftUI with zero third-party dependencies: no SwiftPM, no CocoaPods, no analytics, and no server of mine anywhere in the stack. The interesting engineering is that "everywhere at once" means three separate processes and, with sync, multiple devices, all mutating the same data.

One store across three processes

The main app, the widget extension and the share extension are separate processes that all read and write the same notes. I persist everything as plain JSON files in a shared App Group container instead of UserDefaults, because UserDefaults(suiteName:) inside extension processes can silently detach from cfprefsd, and a write made by the widget then never becomes visible to the app. Four files hold notes, lists, entitlement and settings, with the settings aggregated into one payload so widget reads stay cheap. The models decode with decodeIfPresent fallbacks and no schema version field, so notes saved by early builds keep loading as fields get added. A fixed-UUID Inbox list is synthesised on every load and cannot be deleted, so a note can never end up pointing at a list that does not exist.

Keeping every surface in sync

When you tap a note on a widget, the intent might run in the app process or the widget process, and you do not get to know which. It mutates the file store, reloads all widget timelines, then tries to update the Live Activity directly, which only works from the process that owns it. As a fallback it posts a Darwin notification that the main app observes to reload state and repaint the Dynamic Island, and this dual path is what keeps a Lock Screen tap consistent everywhere. The widget ships six families with per-instance list binding through a configuration intent, sensible display caps per size, and a fallback chain so a freshly dropped widget renders something useful before it has ever been configured. The Live Activity works inside a roughly 160 point Lock Screen budget, adapting font, icon size and padding to the item count and overflowing into a "+N more in app" line.

iCloud sync as a side channel

Premium adds sync through CloudKit's private database, layered on top of the JSON files rather than replacing them: the files stay authoritative and the app works with no account and no network. Every save writes the files first, then schedules a debounced push that diffs the live data against a persisted mirror snapshot and uploads only the delta, chunked to stay under CloudKit's operation cap, with retries on exponential backoff that honour the server's retry hints. Merging is true last write wins by a modifiedAt stamped on every mutation path, with ties going to remote so pulls are idempotent across devices. The clever part is that the mirror is also the offline queue: a failed upload leaves the mirror untouched, so the next push recomputes exactly the same delta, and no separate pending-changes file exists. A network path monitor reflows offline edits the moment connectivity returns, and CloudKit subscriptions deliver silent pushes while the app is backgrounded.

The extensions get stricter rules. The widget's sync read is bounded by a 3 second timeout and a 10 minute staleness check so it can never blow the extension's roughly 6 second budget, and on failure it just renders the cached files. The share extension never touches CloudKit at all: it writes the file, posts the Darwin notification and exits, and the next app foreground picks the new note up through the same mirror diff.

The product side

The free tier is fully offline: the Inbox, 20 notes, the core widget families, no account required. A one-time lifetime unlock through StoreKit 2 adds iCloud sync, unlimited lists, due dates with local reminders, a month-grid calendar, the large and Lock Screen accessory widget families, four Siri intents, and premium themes, fonts and alternate app icons. A 7 day trial bootstraps on first launch and deliberately never resets on update. Notification permission is only requested the first time someone actually sets a due date, never at launch. The whole codebase builds clean under Swift 6 strict concurrency, and the app is in active development heading towards App Store launch.

The hardest problem

Merge correctness, with no server of my own to referee it. Three processes mutate the same files, and premium sync adds a second device editing offline. I settled on true last write wins, driven by a modifiedAt stamped on every mutation path, including the intents that run inside the widget process, because one forgotten stamp means a newer edit silently loses after the next sync. Deletes are the sharp edge: when a record exists locally but not remotely, that is either a remote delete to honour or an offline local edit to protect, and the timestamps alone cannot tell you which. The mirror snapshot disambiguates. If the local copy matches the last-synced mirror, it was deleted remotely, so drop it; if it was edited after the last sync, keep it and push it back. The same mirror doubles as the offline queue, since a failed upload leaves it untouched and the next push recomputes the identical delta.

Start a project

Want something built like this?

Everything on this page was designed, built and hosted by one person. Tell me what you need and I reply with scope, cost and timeline, usually within two working days.

Asteroid · Raafay Siddiqui Projects