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
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 6 additions & 5 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions PROJECT_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
37 changes: 35 additions & 2 deletions ios/Kith.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,20 @@
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, ); }; };
3D2725D7CF8A07FC80223E4E /* KithCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FDEF1D744F055053CB588A9 /* KithCoreTests.swift */; };
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 */; };
Expand Down Expand Up @@ -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 = "<group>"; };
283EE3AFA2ADACAC47EDC05F /* PersonViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonViews.swift; sourceTree = "<group>"; };
2C466A859325D15A49C22248 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
30859487C6430E18E645A7E1 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
Expand All @@ -101,6 +105,7 @@
BC05AFA81D366A2F3F3657C9 /* LogViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViews.swift; sourceTree = "<group>"; };
DC4EA45455B6FF74E4BCFC21 /* Domain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Domain.swift; sourceTree = "<group>"; };
DE4DDB782CAF5D8FD571DC3B /* Design.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Design.swift; sourceTree = "<group>"; };
F2AF9D313021EF3F3623DE6D /* KithConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KithConnectionView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand All @@ -117,6 +122,7 @@
buildActionMask = 2147483647;
files = (
09E8E56D1B58E87EC470F8DE /* KithCore.framework in Frameworks */,
5AE7F8678D6CD5D3E962CB11 /* PersonalSyncKit in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -276,6 +284,7 @@
);
name = Kith;
packageProductDependencies = (
89477B67A2352008C58A6078 /* PersonalSyncKit */,
);
productName = Kith;
productReference = B5055DD36BBAD17F586CE183 /* Kith.app */;
Expand Down Expand Up @@ -336,6 +345,9 @@
);
mainGroup = F35B7C939F8B4E95D11CD4D9;
minimizedProjectReferenceProxies = 1;
packageReferences = (
59D3BF0695ED34B8D6D4043A /* XCRemoteSwiftPackageReference "personal-platform" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = B53CC26908B713C18F8BDABE /* Products */;
projectDirPath = "";
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 */;
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

100 changes: 96 additions & 4 deletions ios/Sources/Kith/AppModel.swift
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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") {
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -84,6 +95,7 @@ final class AppModel {
do {
try document.upsert(person)
persist()
enqueue(person)
selectedPersonID = person.id
isAddingPerson = false
} catch KithError.emptyName {
Expand All @@ -103,6 +115,7 @@ final class AppModel {
do {
try document.add(entry)
persist()
enqueue(entry)
} catch {
message = "Could not save that note."
}
Expand Down Expand Up @@ -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
)
}
}
4 changes: 4 additions & 0 deletions ios/Sources/Kith/Kith.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
<!--
Private CloudKit on Sarthak Agrawal's personal team (8F7LXHTJZR).
One person's people and notes, in that person's iCloud only.
Expand Down
5 changes: 4 additions & 1 deletion ios/Sources/Kith/KithApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ struct KithApp: App {
.task { await model.load() }
.onChange(of: scenePhase) { _, phase in
guard phase == .active else { return }
Task { await model.syncFromCloud() }
Task {
await model.syncFromCloud()
await model.syncFromPlatform()
}
}
}
}
Expand Down
Loading
Loading