diff --git a/AGENTS.md b/AGENTS.md index e3afe62..e7807a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,10 +4,10 @@ - Keep Kith's source, product planning, tests, and release configuration in this repository. Fleet Workspace may catalog Kith but does not own its product source. -- Kith is device-first. People and logs live in one local JSON document, and - optionally mirror to the owner's private iCloud database on the personal - Apple team `8F7LXHTJZR` (`iCloud.com.significanthobbies.kith`). Do not add - a Kith account, a Kith server, or a third-party analytics SDK. +- Kith is device-first. People and logs live in one local JSON document. The + private CloudKit mirror remains during transition; signed-in synchronization + uses PersonalSyncKit and Personal Platform. Do not make network access block + local use or add a third-party analytics SDK. - Speak about people, closeness, and notes — never contacts, CRM, pipelines, or leads. - Closeness is an explicit 1–5 value the person sets. Do not infer it from diff --git a/PRODUCT.md b/PRODUCT.md index 295aa55..5e965eb 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -29,8 +29,9 @@ scores. - Used on an iPhone, often in a quiet moment after a call, dinner, or birthday, one-handed, with little patience for forms. - The repeated action is "open someone, write a short note, leave." -- Data is intimate. It stays on the owner's devices and, when they are - signed into iCloud, in their private CloudKit database. +- Data is intimate. The local document remains immediately usable offline. + When explicitly signed in, structured people and notes also synchronize + through the private Personal Platform account. ## Capabilities and Constraints @@ -42,9 +43,9 @@ scores. short body. - The home surface is a floating constellation. A list with search exists for finding someone quickly and for VoiceOver. -- Everything is stored in one local JSON document. There is no Kith - account. When the owner is signed into iCloud, that document is mirrored - to the personal-team container `iCloud.com.significanthobbies.kith`. +- Everything is stored first in one local JSON document. Personal Platform + synchronization is optional and uses Sign in with Apple; the existing + personal-team CloudKit mirror remains available during transition. - Contact import, notifications, photos, and a public site remain deferred. ## Brand Commitments diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index 13a0282..4f47d1d 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -23,6 +23,8 @@ reminders as a notification product, messaging, and a web client. - SwiftUI on iOS 17+ - Personal Apple Developer team `8F7LXHTJZR` (Sarthak Agrawal) and CloudKit container `iCloud.com.significanthobbies.kith` +- PersonalSyncKit from `Significant-Hobbies/personal-platform` and the private + Cloudflare Personal Platform Worker - XcodeGen to generate the Xcode project - XCTest for domain, persistence, and interface coverage @@ -32,6 +34,9 @@ reminders as a notification product, messaging, and a web client. ## Timeline +- 2026-08-21 — added optional Sign in with Apple synchronization for people + and interactions through Personal Platform. Local JSON remains immediate and + offline-capable; the CloudKit mirror stays enabled as migration rollback. - 2026-08-21 — created the personal-team App Store Connect record as `Kith by Significant Hobbies` (ID `6803666674`) and uploaded iPhone build `1.0.0 (1)` for internal-only TestFlight; Apple accepted the package for @@ -63,6 +68,8 @@ reminders as a notification product, messaging, and a web client. - Searchable list fallback and reduced-motion static layout - Empty state and a `--ui-demo` fixture for tests and screenshots - Private CloudKit mirror on the personal team when iCloud is signed in +- Optional signed-in Cloudflare synchronization for people and interactions, + with a durable local outbox, foreground/manual sync, and no local-data import - Public factory landing at kith.significanthobbies.com ## Work queue diff --git a/ios/Kith.xcodeproj/project.pbxproj b/ios/Kith.xcodeproj/project.pbxproj index a075d5f..493424d 100644 --- a/ios/Kith.xcodeproj/project.pbxproj +++ b/ios/Kith.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 09E8E56D1B58E87EC470F8DE /* KithCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 706248B78A28D30986A34B42 /* KithCore.framework */; }; 15835961CE7BABD27EA30295 /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8E3415A5E88E90361208B65E /* CloudKit.framework */; }; 1D8DB7DB42B1154E40F56ED6 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A662AC2BCD668AE688D25BB5 /* Persistence.swift */; }; + 1D9712691351162D15E59B82 /* KithPlatformRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09DA028D29EAA834DF9E94A7 /* KithPlatformRecord.swift */; }; 26F587DD0A2D6F634E9068BE /* KithCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 706248B78A28D30986A34B42 /* KithCore.framework */; }; 28DC706C84B926D801903C80 /* KithCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 706248B78A28D30986A34B42 /* KithCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3D16CCA4E369961726F5EC1F /* KithCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 706248B78A28D30986A34B42 /* KithCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -17,10 +18,12 @@ 43362B2B8A40AEC6FFBF4E34 /* LogViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC05AFA81D366A2F3F3657C9 /* LogViews.swift */; }; 4DAC407477F1CDC34376B408 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 30859487C6430E18E645A7E1 /* Assets.xcassets */; }; 50198B83B77FCE3BB74868B9 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6982529B80DE3228F8C45F4 /* RootView.swift */; }; + 5AE7F8678D6CD5D3E962CB11 /* PersonalSyncKit in Frameworks */ = {isa = PBXBuildFile; productRef = 89477B67A2352008C58A6078 /* PersonalSyncKit */; }; 5D36133D386B4091A7DB8845 /* BubbleField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA76B6FCE2A1BD2FDDE7D45 /* BubbleField.swift */; }; 615BC818E410D41C47C41708 /* Domain.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4EA45455B6FF74E4BCFC21 /* Domain.swift */; }; 65C76174953419A9F87B0092 /* DemoData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79D378032A9722C9555ED8AE /* DemoData.swift */; }; 6A749513F46572C1A112A145 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10F1C159C3DA6714FD52E7A /* AppModel.swift */; }; + 83B5015F25ACE804801B8CD0 /* KithConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2AF9D313021EF3F3623DE6D /* KithConnectionView.swift */; }; 9AE67972B45E3203D786E267 /* KithApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBCFD88B8B1F5D94999E4F0C /* KithApp.swift */; }; 9CD8FB3118B722E7027AF697 /* CloudStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8B5ADE80477C15D5324428 /* CloudStore.swift */; }; A076BAE8EDA2DF34808A76E6 /* PersonViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 283EE3AFA2ADACAC47EDC05F /* PersonViews.swift */; }; @@ -81,6 +84,7 @@ /* Begin PBXFileReference section */ 093BCDF0FE2F82258D1A28E0 /* KithCoreTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = KithCoreTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 09C2DC255CA3B902918900FB /* KithUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = KithUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 09DA028D29EAA834DF9E94A7 /* KithPlatformRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KithPlatformRecord.swift; sourceTree = ""; }; 283EE3AFA2ADACAC47EDC05F /* PersonViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonViews.swift; sourceTree = ""; }; 2C466A859325D15A49C22248 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 30859487C6430E18E645A7E1 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -101,6 +105,7 @@ BC05AFA81D366A2F3F3657C9 /* LogViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViews.swift; sourceTree = ""; }; DC4EA45455B6FF74E4BCFC21 /* Domain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Domain.swift; sourceTree = ""; }; DE4DDB782CAF5D8FD571DC3B /* Design.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Design.swift; sourceTree = ""; }; + F2AF9D313021EF3F3623DE6D /* KithConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KithConnectionView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -117,6 +122,7 @@ buildActionMask = 2147483647; files = ( 09E8E56D1B58E87EC470F8DE /* KithCore.framework in Frameworks */, + 5AE7F8678D6CD5D3E962CB11 /* PersonalSyncKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -198,6 +204,8 @@ 5EBF5537116C0DE3DA6726C5 /* Info.plist */, 6B63B598C54DF2994E3A9AF0 /* Kith.entitlements */, BBCFD88B8B1F5D94999E4F0C /* KithApp.swift */, + F2AF9D313021EF3F3623DE6D /* KithConnectionView.swift */, + 09DA028D29EAA834DF9E94A7 /* KithPlatformRecord.swift */, BC05AFA81D366A2F3F3657C9 /* LogViews.swift */, 283EE3AFA2ADACAC47EDC05F /* PersonViews.swift */, A6982529B80DE3228F8C45F4 /* RootView.swift */, @@ -276,6 +284,7 @@ ); name = Kith; packageProductDependencies = ( + 89477B67A2352008C58A6078 /* PersonalSyncKit */, ); productName = Kith; productReference = B5055DD36BBAD17F586CE183 /* Kith.app */; @@ -336,6 +345,9 @@ ); mainGroup = F35B7C939F8B4E95D11CD4D9; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 59D3BF0695ED34B8D6D4043A /* XCRemoteSwiftPackageReference "personal-platform" */, + ); preferredProjectObjectVersion = 77; productRefGroup = B53CC26908B713C18F8BDABE /* Products */; projectDirPath = ""; @@ -381,6 +393,8 @@ 5D36133D386B4091A7DB8845 /* BubbleField.swift in Sources */, F3C48A6CA9EC720182B123BC /* Design.swift in Sources */, 9AE67972B45E3203D786E267 /* KithApp.swift in Sources */, + 83B5015F25ACE804801B8CD0 /* KithConnectionView.swift in Sources */, + 1D9712691351162D15E59B82 /* KithPlatformRecord.swift in Sources */, 43362B2B8A40AEC6FFBF4E34 /* LogViews.swift in Sources */, A076BAE8EDA2DF34808A76E6 /* PersonViews.swift in Sources */, 50198B83B77FCE3BB74868B9 /* RootView.swift in Sources */, @@ -475,7 +489,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 8F7LXHTJZR; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -655,7 +669,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 8F7LXHTJZR; ENABLE_NS_ASSERTIONS = NO; @@ -754,6 +768,25 @@ defaultConfigurationName = Debug; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 59D3BF0695ED34B8D6D4043A /* XCRemoteSwiftPackageReference "personal-platform" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Significant-Hobbies/personal-platform.git"; + requirement = { + branch = main; + kind = branch; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 89477B67A2352008C58A6078 /* PersonalSyncKit */ = { + isa = XCSwiftPackageProductDependency; + package = 59D3BF0695ED34B8D6D4043A /* XCRemoteSwiftPackageReference "personal-platform" */; + productName = PersonalSyncKit; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 9551192361D22AAFCE735BA5 /* Project object */; } diff --git a/ios/Kith.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Kith.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..4582299 --- /dev/null +++ b/ios/Kith.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "8bb672a61b91690298a8cde3b06b0445f363fb19078a800e40d0a67c3077a89c", + "pins" : [ + { + "identity" : "personal-platform", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Significant-Hobbies/personal-platform.git", + "state" : { + "branch" : "main", + "revision" : "53a591bd19270b503177f4674f3e9d82d7255cb4" + } + } + ], + "version" : 3 +} diff --git a/ios/Sources/Kith/AppModel.swift b/ios/Sources/Kith/AppModel.swift index 9dcf121..7839887 100644 --- a/ios/Sources/Kith/AppModel.swift +++ b/ios/Sources/Kith/AppModel.swift @@ -1,12 +1,12 @@ import Foundation import KithCore import Observation +import PersonalSyncKit /// Owns the one Kith document and every action that changes it. /// -/// The phone is the working copy. Personal iCloud (private CloudKit on the -/// Sarthak Agrawal team) mirrors it when the owner is signed in. There is no -/// Kith account and no Kith server. +/// The phone is the working copy. CloudKit remains a transition mirror, while +/// an explicit Personal Platform sign-in synchronizes typed people and notes. @MainActor @Observable final class AppModel { @@ -15,14 +15,23 @@ final class AppModel { var selectedPersonID: UUID? var isAddingPerson = false var isShowingList = false + var isShowingConnection = false var searchText = "" var message: String? private let store: KithStore private let cloud: KithCloudStore? + private let platform: PersonalPlatformConnection? + let account: PersonalAppleSignInModel? - init(store: KithStore = KithStore(), cloud: KithCloudStore? = KithCloudStore()) { + init( + store: KithStore = KithStore(), + cloud: KithCloudStore? = KithCloudStore(), + platform: PersonalPlatformConnection? = AppModel.makePlatformConnection() + ) { self.store = store + self.platform = platform + account = platform.map { PersonalAppleSignInModel(identity: $0.identity) } let arguments = ProcessInfo.processInfo.arguments self.cloud = Self.isDemoLaunch(arguments) ? nil : cloud if arguments.contains("--person-demo") { @@ -55,6 +64,8 @@ final class AppModel { } else { document = try await store.load() await syncFromCloud() + await account?.restore() + await syncFromPlatform() } } catch { message = "Could not open your people." @@ -84,6 +95,7 @@ final class AppModel { do { try document.upsert(person) persist() + enqueue(person) selectedPersonID = person.id isAddingPerson = false } catch KithError.emptyName { @@ -103,6 +115,7 @@ final class AppModel { do { try document.add(entry) persist() + enqueue(entry) } catch { message = "Could not save that note." } @@ -130,4 +143,83 @@ final class AppModel { } } } + + func syncFromPlatform() async { + guard let platform else { return } + do { + let changes = try await platform.sync.synchronize() + guard !changes.isEmpty else { return } + for change in changes { apply(change) } + try await store.save(document) + } catch { + // The local document remains fully usable while offline. + } + } + + private func enqueue(_ person: Person) { + guard let platform else { return } + Task { + do { + try await platform.sync.enqueue( + recordId: person.id.uuidString.lowercased(), + occurredAt: KithPlatformRecord.iso(person.updatedAt), + record: KithPlatformRecord.person(person) + ) + _ = try? await platform.sync.synchronize() + } catch {} + } + } + + private func enqueue(_ entry: Entry) { + guard let platform, let person = document.person(id: entry.personID) else { return } + Task { + do { + try await platform.sync.enqueue( + recordId: entry.id.uuidString.lowercased(), + occurredAt: KithPlatformRecord.iso(entry.happenedOn), + record: KithPlatformRecord.interaction(entry, person: person) + ) + _ = try? await platform.sync.synchronize() + } catch {} + } + } + + private func apply(_ change: SyncChange) { + switch change.operation { + case .delete: + guard let id = UUID(uuidString: change.id) else { return } + if document.people.contains(where: { $0.id == id }) { document.removePerson(id: id) } + else { document.removeEntry(id: id) } + case .upsert: + guard let object = change.record.objectValue, + let recordType = object["recordType"]?.stringValue else { return } + if recordType == "person", let person = KithPlatformRecord.person(from: object) { + if let index = document.people.firstIndex(where: { $0.id == person.id }) { + document.people[index] = person + document.markSaved() + } else { + try? document.upsert(person) + } + } else if recordType == "interaction", + let pair = KithPlatformRecord.interaction(from: object, recordId: change.id) { + if document.person(id: pair.person.id) == nil { try? document.upsert(pair.person) } + if !document.entries.contains(where: { $0.id == pair.entry.id }) { + try? document.add(pair.entry) + } + } + } + } + + private static func makePlatformConnection() -> PersonalPlatformConnection? { + let defaults = UserDefaults.standard + let key = "personal-platform-device-id" + let deviceId = defaults.string(forKey: key) ?? UUID().uuidString.lowercased() + defaults.set(deviceId, forKey: key) + return try? PersonalPlatformConnection( + domain: .kith, + keychainService: "com.significanthobbies.kith", + supportDirectory: KithFiles.supportDirectory, + deviceId: deviceId + ) + } } diff --git a/ios/Sources/Kith/Kith.entitlements b/ios/Sources/Kith/Kith.entitlements index ffad188..8cc2f99 100644 --- a/ios/Sources/Kith/Kith.entitlements +++ b/ios/Sources/Kith/Kith.entitlements @@ -2,6 +2,10 @@ + com.apple.developer.applesignin + + Default +