Both platforms share one App Store Connect build-number space (same bundle ID, universal purchase), so the raw commit count would collide. The vendored script partitions on PLATFORM_NAME; watchOS rides with iOS because the embedded watch app's CFBundleVersion must match its host.
13 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
A workout tracking app for iPhone, Apple Watch, and Mac, built with Swift 6 and SwiftUI (iOS 26 / watchOS 26 / macOS 26). The Mac app is a library-management companion (routines, schedules, exercise browser — no workout running). It helps users manage workout splits, track exercise logs, and sync across devices. As of 2.0, persistence is an iCloud Drive document architecture: JSON files in iCloud Drive are the sole source of truth, and a rebuildable SwiftData store is a read-through cache. Core Data, NSPersistentCloudKitContainer, CloudKit, and the App Group were all removed in 2.0.
Development Commands
Project Generation
- The Xcode project is generated by XcodeGen from
project.yml. After editingproject.yml(targets, sources, packages, settings), regenerate withxcodegen generate. - Do not hand-edit
Workouts.xcodeproj— it is generated and your changes will be overwritten.
Building and Running
- Build the project: Open
Workouts.xcodeprojin Xcode and build (Cmd+B) - Run on iOS Simulator: Select the Workouts scheme and run (Cmd+R)
- Run on Apple Watch Simulator: Select the "Workouts Watch App" scheme and run
Architecture
Persistence & Sync (iCloud Drive documents)
- Source of truth: JSON documents in the iCloud Drive container
iCloud.dev.rzen.indie.Workouts. One file per aggregate:Splits/<ULID>.jsonandWorkouts/YYYY/MM/<ULID>.json(month-bucketed; ULIDs sort chronologically). - SwiftData cache:
WorkoutsModelContainer(Shared/Persistence/) builds a local SwiftData store as a rebuildable read-through cache, created withcloudKitDatabase: .none. It is wiped on a schema-version bump or an iCloud account change. Views read it via@Query. - One-way data flow (iPhone & Mac): view →
SyncEngine.save(...)writes a file →MetadataObserver(anNSMetadataQuery) emits a delta batch →CacheMapperupserts SwiftData →@Queryviews refresh. The phone and the Mac both write iCloud Drive; the Mac writes only routine and schedule documents (whole-document last-writer-wins — it must never write workout documents, whose per-log merge exists only on the watch ingest path). - Key types:
SyncEngine(@Observable @MainActor, inWorkouts/Sync/) — the app-specific orchestrator.connect()resolves the ubiquity container and reconciles;save/deletewrite files;handle(_:)applies observer delta batches;ingestFromWatch(_:)writes + upserts the cache directly. ExposesiCloudStatusand anonCacheChangedcallback.- The file-side core comes from the IndieSync SPM package (
https://git.rzen.dev/rzen/indie-sync.git):DocumentFileStore(actor — allNSFileCoordinatorI/O, conflict resolution, eviction-safe reads, placeholder-aware enumeration),TombstoneStore(soft-delete stubs, resurrection veto, grace-period pruning),MetadataObserver(@MainActor— wraps theNSMetadataQuery, emitting added/modified/removed batches as anAsyncStream), plusULID,DocumentCoder,SyncError, and theVersionedDocumentprotocol. See theicloud-sync-engineskill for the architecture invariants.
- Soft deletes: a delete writes a
TombstonetoStubs/<id>.jsonthen removes the live file (so offline devices still learn of the delete); stubs are pruned after a 30-day grace period — except starter-seed stubs, which are exempt (prune(exempting:)) and veto seed resurrection forever. - iCloud is required:
RootGateView(Workouts/Views/) gates the app onSyncEngine.iCloudStatus— there is no local-only mode.
Data Layer (three shapes + a stateless mapper)
All in Shared/Model/:
- Codable documents (
Documents.swift) — the on-disk / wire format:SplitDocument(embeds[ExerciseDocument]) andWorkoutDocument(embeds[WorkoutLogDocument]), conforming to IndieSync'sVersionedDocumentschema gate (quarantines files from newer app versions).TombstoneandDocumentCodercome from IndieSync.WorkoutDocument.relativePathkeeps the app's own local-calendar month bucketing (predates IndieSync's UTCTimeBucketedLayout; changing it would strand existing files). - SwiftData
@Modelcache entities (Entities.swift) —Split,Exercise,Workout,WorkoutLog; each keyed by a stableid: StringULID (@Attribute(.unique)), with cascade relationships and ajsonRelativePathback to its file. - Stateless mappers (
Mappers.swift) —init(from: entity)for cache → document;CacheMapper.upsert…for document → cache (used only by the observer, reconcile, and the watch bridge). - Identifiers (
ULID.swift) — a thin shim over IndieSync'sULID:ULID.make()mints the 26-char Crockford base32 string (chronologically sortable) that documents and entities key on. - Enums (
Enums.swift) —WorkoutStatus,LoadType.
Core Aggregates
- Split → embeds many Exercise (a single document)
- Workout → embeds many WorkoutLog (a single document); references its split only by denormalized
splitID/splitName— there is no live relationship, workouts are independent documents.
App Structure & DI
- iOS:
WorkoutsApp(@main) ownsAppServices(@Observable @MainActor:container,syncEngine,watchBridge,workoutLauncher), injects them via.environment(...)/.modelContainer(...), then runsawait services.bootstrap()(connect + activate). Root isRootGateView→ContentView→WorkoutLogsView(a single screen — there is no TabView). - watchOS:
WorkoutsWatchApp(@main) ownsWatchAppServices(container+bridge; no iCloud, no SyncEngine) and aWatchAppDelegate. Root isContentView→ActiveWorkoutGateView. - macOS:
WorkoutsMacApp(@main, inWorkouts Mac/) ownsMacAppServices(container+syncEngineonly — no watch bridge, HealthKit, Live Activity, or backup). Root isMacRootGateView→MacContentView(aNavigationSplitView: routines sidebar, schedules, exercise browser). Reconciles on window activation (throttled) because iCloud can sync while the app isn't running; flushes pending writes on deactivation. - Views read services with
@Environment(SyncEngine.self)(etc.), read data with@Query, and write withawait sync.save(...)/delete(...).
Watch Sync (WatchConnectivity bridge)
- The watch never touches iCloud Drive; it is a thin remote that round-trips domain documents through the phone, keyed by stable ULIDs.
- Wire format
Shared/Connectivity/WCPayload.swift. Phone→Watch: full state (splits + recent workouts) plus settings (rest seconds, done-countdown seconds) viaupdateApplicationContext(latest-wins). Watch→Phone:workoutUpdate(oneWorkoutDocument) andrequestSync. - Phone
Workouts/Connectivity/PhoneConnectivityBridge.swift— pushes ononCacheChanged; inbound updates go toSyncEngine.ingestFromWatch. - Watch
Workouts Watch App/Connectivity/WatchConnectivityBridge.swift— a local SwiftData cache fed only by phone pushes; applies updates optimistically, then forwards. - Watch HealthKit session (runtime only, not sync):
WorkoutSessionManagerholds anHKWorkoutSessionso the watch stays foregrounded during a workout; the phone launches it viaWorkouts/HealthKit/WorkoutLauncher.swift.
Platforms, Tooling & Entitlements
- iOS 26 / watchOS 26 / macOS 26; Swift 6 with
SWIFT_STRICT_CONCURRENCY: completeon all targets. - Build number,
BuildDate, andBuildHashare stamped by the vendoredScripts/update_build_info.sh(a post-build phase; release tagging is opt-in viaRELEASE_TAGGING=1). Build numbers are platform-partitioned from the git commit count — iOS/watchOS get even (2×commits), macOS gets odd (2×commits+1) — because both platforms share one ASC build-number space and the embedded watch app must match the iOS app'sCFBundleVersion.Scripts/also holds the App Store / TestFlight release pipeline. - Entitlements: iOS uses CloudDocuments + HealthKit; watch uses HealthKit only; macOS uses app sandbox + CloudDocuments (same bundle ID as iOS — universal purchase). No CloudKit service, no App Group.
- SPM packages:
IndieSync(file-side iCloud sync core) andIndieAbout(in-app About / changelog / license UI).
Key Directories
Shared/(compiled into both targets):Model/,Persistence/,Connectivity/,Utils/,Screenshots/Workouts/(iOS):Sync/(the persistence/sync layer),Connectivity/,HealthKit/,Seed/,Views/(Common/,Exercises/,Settings/,Splits/,WorkoutLogs/),Resources/(Info.plist, entitlements,StarterSplits/,ExerciseMotions/)Workouts Watch App/(watchOS):Connectivity/,Views/, plusWorkoutSessionManager,WatchAppDelegate,WatchAppServicesWorkouts Mac/(macOS):WorkoutsMacApp,MacAppServices,Views/(Routines/,Schedules/,Exercises/),Resources/; also compilesWorkouts/Sync/,Workouts/Seed/,Workouts/ExerciseFigure/, and a few portable files fromWorkouts/Views/(seeproject.yml)- Root:
project.yml,Scripts/, andCHANGELOG.md/README.md/LICENSE.md(bundled into the iOS app for IndieAbout)
Starter Data (deterministic seeds)
- Starter splits: shipped as byte-canonical
SplitDocumentJSON inWorkouts/Resources/StarterSplits/*.split.jsonwith fixed ULIDs (shared01DXF6DT00prefix, frozen 2020 timestamp) and fixed content, regenerated only byScripts/generate_starter_splits.swift.SeedLibrary(Workouts/Seed/) loads the catalog; seeds are immutable —SyncEngine.save(split:)transparently clones an edited seed to a fresh ULID and soft-deletes the seed, whose stub is exempt from pruning and vetoes resurrection forever (open views follow the identity swap viasync.currentSplitID(for:)). - Auto-seed & reconcile: after connect + reconcile,
SyncEngine.seedOrReconcile()(deferred, settle-delayed) branches on container state so the two seeders can never both fire. An empty container →autoSeedIfEmpty()writes the verbatim bundle bytes (re-checked after the settle delay). A non-empty container →reconcileSeeds()diffs each bundled seed against its fixed-ULID file via the pureSeedReconcilePlanner(Workouts/Sync/SeedReconcile.swift): a stale revision is overwritten with current bundle bytes (a semantic compare withschemaVersionnormalized — never a byte compare, since older files carry now-removed keys), a newly-shipped seed with no file and no stub is written (unless a live same-name split already exists, or a newer app version wrote the file), and a deleted seed (stub present) is left vetoed. Wrong guesses are harmless — identical bytes make same-path conflicts empty, stubs reap resurrected seeds, and the veto + name guards are re-checked right before each write. This is safe because a fixed-ULID file can never hold user content (every edit forks to a fresh ULID). - Restore Starter Splits (Settings) is the one deliberate veto lift:
SyncEngine.restoreSeeds()removes a deleted seed's stub and rewrites the current bundle bytes (never the stub's old contents), still skipping seeds whose live file already exists (reconcile handles upgrades) or whose name collides with a live split; it returns the count for a brief in-Settings confirmation. - Exercise library: authored in
Exercise Library/at the repo root (per-exerciseinfo.md, SVG visuals, motion rigs, Python render pipeline); the app bundles the exportedWorkouts/Resources/ExerciseMotions/*resources —*.motion.jsonrigs, which double as the exercise picker's list (ExerciseMotionLibrary.exerciseNames), and*.info.mdreference pages, parsed byExerciseInfoand rendered in the Settings → Library → Exercises detail screen. Re-export both withpython3 render.py --export.
Guidelines
Data Model Changes
- A new persisted field must be added in three places: the Codable
…Document(on-disk shape), the@Modelcache entity, and both directions of the mapper inMappers.swift. - Bump a document's
currentSchemaVersionwhen its on-disk shape changes incompatibly. - All writes go through
SyncEngine(save/delete) — never mutate the SwiftData cache directly, since it is rebuilt from files. - Mint IDs with
ULID.make(); they are stable across cache rebuilds.
Concurrency
- Code is Swift 6 strict-concurrency clean. UI and orchestration types are
@MainActor; file I/O lives in IndieSync'sDocumentFileStoreactor.
UI
- SwiftUI-first:
NavigationStack,@Query-backed lists, form-based add/edit, sheet presentations, and swipe actions. - Theming via
Color+Extensions.swift; date formatting viaDate+Extensions.swift(both inShared/Utils/).
Authoring the Changelog
CHANGELOG.md is bundled and shown in-app via IndieAbout — follow the app-changelog skill (single source of truth for format and workflow).