diff --git a/CloudKit.md b/CloudKit.md new file mode 100644 index 0000000..d2f8a92 --- /dev/null +++ b/CloudKit.md @@ -0,0 +1,141 @@ +## CloudKit setup for FeatureVoterCloudKit + +This voter stores per-user votes in the Private database and aggregated vote counts in the Public database. + +### What it stores + +1. Private database + **Record type:** `RoadmapUserVote` + **Purpose:** Stores one vote marker per user per feature key to prevent double voting. + +2. Public database + **Record type:** `RoadmapFeatureCount` + **Purpose:** Stores a global counter per feature key to show total votes across users. + +--- + +## Requirements + +- The app target must have iCloud enabled with CloudKit. +- A CloudKit container must be selected in Xcode Signing & Capabilities. +- The CloudKit schema and security roles must be configured in CloudKit Console. + +--- + +## Step 1: Enable iCloud and CloudKit in Xcode + +1. Open your app target in Xcode. +2. Go to **Signing & Capabilities**. +3. Add the **iCloud** capability. +4. Enable: + - Key-value storage + - iCloud Documents + - CloudKit +5. Under **Containers**, select your CloudKit container. + +Important: The container identifier used by the app must match the container selected here. A mismatch results in a CKError “Bad Container”. + +--- + +## Step 2: Open CloudKit Console + +1. Open **CloudKit Console** from Xcode or the Apple Developer tools. +2. Select the correct CloudKit container. +3. Select the **Development** environment. + +--- + +## Step 3: Create the record types + +In CloudKit Console: + +1. Go to **CloudKit Database**. +2. Ensure the **Development** environment is selected. +3. Create the following record types under **Schema → Record Types**. + +### Public database record type + +- **Record type:** `RoadmapFeatureCount` +- **Fields:** + - `key` (String) + - `count` (Int64) + +Notes: +- `key` should be queryable and searchable. +- `count` stores the aggregated vote total. + +### Private database record type + +- **Record type:** `RoadmapUserVote` +- **Fields:** + - `featureKey` (String) + +--- + +## Step 4: Configure Public database permissions + +The aggregated vote counter is written to the Public database. To allow normal users to vote, the `_icloud` role must be allowed to write to `RoadmapFeatureCount`. + +1. Go to **Schema → Security Roles**. +2. Select the **_icloud** role. +3. Find the `RoadmapFeatureCount` record type. +4. Enable: + - Create + - Read + - Write + +Do not modify the `_world` role. + +You do not need to enable Public database write access for `RoadmapUserVote` if it is stored in the Private database. + +--- + +## Step 5: Save and deploy schema changes + +After changing record types or security roles: + +1. Click **Save**. +2. Click **Deploy Schema Changes**. +3. Deploy **Development** changes to the **Development** environment. + +Skipping this step can cause different behavior across devices due to CloudKit caching. + +--- + +## Step 6: Verify on devices + +If voting works on one device but fails on another: + +1. Delete the app from the failing device. +2. Reinstall the app from Xcode. +3. Ensure the device is signed into iCloud. +4. Test voting again. + +--- + +## Troubleshooting + +### CKError: “Bad Container” + +**Cause:** CloudKit container identifier mismatch. + +**Fix:** +- Verify the container selected in Xcode Signing & Capabilities. +- Ensure the app uses the same container identifier in code or relies on `.default()` with correct entitlements. + +--- + +### CKError: “Permission Failure” or “WRITE operation not permitted” + +**Cause:** Public database write access is not enabled for the `_icloud` role on `RoadmapFeatureCount`. + +**Fix:** +- Open **CloudKit Console → Security Roles → _icloud**. +- Enable **Create** and **Write** for `RoadmapFeatureCount`. +- Save and deploy schema changes. + +--- + +## Notes on the trust model + +This configuration allows authenticated iCloud users to write aggregated vote counts in the Public database. This enables global voting without running a server but is not tamper-proof. For roadmap voting and similar low-risk features, this trade-off is typically acceptable. \ No newline at end of file diff --git a/README.md b/README.md index a75c21c..b6edf34 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,50 @@ let configuration = RoadmapConfiguration( Here's a step by step guide to host your own server with Vapor (Swift): https://github.com/valentin-mille/RoadmapBackend +### Using CloudKit for Vote Persistence (No Backend) + +If you want to persist votes without running your own backend or using Sidetrack, you can use CloudKit as a backendless voting service. + +This approach stores: +- Per-user vote markers in the **Private CloudKit database** (prevents double voting) +- Aggregated vote counts in the **Public CloudKit database** (global totals) + +It requires CloudKit configuration but no server infrastructure. + +➡️ [CloudKit voting setup guide](/CloudKit.md) + +#### Example + +```swift +import CloudKit +import Roadmap +import SwiftUI + +struct ContentView: View { + + // If you rely on `.default()`, ensure the correct container is selected in + // Xcode Signing & Capabilities (iCloud + CloudKit). + private let voter = FeatureVoterCloudKit( + container: CKContainer(identifier: "iCloud.icloud.app.roadmap"), // replace with your container + recordNamePrefix: "roadmap" // change if you want to namespace record names + ) + + private var configuration: RoadmapConfiguration { + RoadmapConfiguration( + roadmapJSONURL: URL(string: "https://simplejsoncms.com/api/k2f11wikc6")!, + voter: voter, + namespace: "roadmap", + allowVotes: true, + allowSearching: true + ) + } + + var body: some View { + RoadmapView(configuration: configuration) + } +} +``` + ## FAQ ### Does Roadmap prevent users from voting multiple times? Yes, if a user has voted on a feature they won't be able to vote again from within your app. Users can intercept your network traffic and replay the api call if they're really desperate to manipulate your votes. diff --git a/Sources/Roadmap/DataProviders/FeatureVoterCloudKit.swift b/Sources/Roadmap/DataProviders/FeatureVoterCloudKit.swift new file mode 100644 index 0000000..308c2e9 --- /dev/null +++ b/Sources/Roadmap/DataProviders/FeatureVoterCloudKit.swift @@ -0,0 +1,289 @@ +// +// FeatureVoterCloudKit.swift +// Roadmap +// +// Created by Ezequiel dos Santos on 09/01/2026. +// + + +#if canImport(CloudKit) + +import CloudKit +import Foundation +import os + +public struct FeatureVoterCloudKit: FeatureVoter { + + public init( + container: CKContainer = .default(), + recordNamePrefix: String = "roadmap" + ) { + self.backend = Backend( + container: container, + recordNamePrefix: recordNamePrefix + ) + } + + /// Fetches the current count for the given feature. + /// - Returns: The current `count`, else `0` if unsuccessful. + public func fetch(for feature: RoadmapFeature) async -> Int { + guard feature.hasNotFinished else { return 0 } + return await backend.fetch(forFeatureID: feature.id) + } + + /// Votes for the given feature. + /// - Returns: The new `count` if successful. + public func vote(for feature: RoadmapFeature) async -> Int? { + guard feature.hasNotFinished else { return nil } + return await backend.vote(true, forFeatureID: feature.id) + } + + /// Unvotes for the given feature. + /// - Returns: The new `count` if successful. + public func unvote(for feature: RoadmapFeature) async -> Int? { + guard feature.hasNotFinished else { return nil } + return await backend.vote(false, forFeatureID: feature.id) + } + + // MARK: - Private + + private let backend: Backend +} + +// MARK: - Our "Backend" + +private struct Backend: @unchecked Sendable { + + // Schema names. Keep these identical across apps using same container. + private enum Schema { + static let countsRecordType = "RoadmapFeatureCount" + static let votesRecordType = "RoadmapUserVote" + + static let keyField = "key" + static let countField = "count" + static let voteKeyField = "featureKey" + } + + private let container: CKContainer + private let publicDB: CKDatabase + private let privateDB: CKDatabase + private let recordNamePrefix: String + private let log = Logger(subsystem: "com.roadmap", category: "FeatureVoterCloudKit") + + init(container: CKContainer, recordNamePrefix: String) { + self.container = container + self.publicDB = container.publicCloudDatabase + self.privateDB = container.privateCloudDatabase + self.recordNamePrefix = recordNamePrefix + } + + func fetch(forFeatureID featureID: String) async -> Int { + let key = normalize(featureID) + guard !key.isEmpty else { return 0 } + + do { + guard await cloudAvailable() else { return 0 } + let record = try await fetchOrCreateCountRecord(key: key) + return int(from: record[Schema.countField]) ?? 0 + } catch { + logError("fetch", key: key, error: error) + return 0 + } + } + + func vote(_ voted: Bool, forFeatureID featureID: String) async -> Int? { + let key = normalize(featureID) + guard !key.isEmpty else { return nil } + + do { + guard await cloudAvailable() else { return nil } + + let hasMarker = try await voteMarkerExists(key: key) + + if voted { + if hasMarker { return await fetch(forFeatureID: key) } + + try await createVoteMarker(key: key) + do { + return try await updateCount(key: key, delta: +1, floorAtZero: false) + } catch { + _ = try? await deleteVoteMarker(key: key) // best-effort rollback + throw error + } + } else { + if !hasMarker { return await fetch(forFeatureID: key) } + + try await deleteVoteMarker(key: key) + do { + return try await updateCount(key: key, delta: -1, floorAtZero: true) + } catch { + _ = try? await createVoteMarker(key: key) // best-effort rollback + throw error + } + } + } catch { + logError(voted ? "vote" : "unvote", key: key, error: error) + return nil + } + } + + // MARK: - Cloud availability + + private func cloudAvailable() async -> Bool { + do { + let status = try await container.accountStatus() + return status == .available + } catch { + logError("accountStatus", key: nil, error: error) + return false + } + } + + // MARK: - Records + + private func countRecordID(key: String) -> CKRecord.ID { + CKRecord.ID(recordName: "\(recordNamePrefix)_count_\(hash64Hex(key))") + } + + private func voteRecordID(key: String) -> CKRecord.ID { + CKRecord.ID(recordName: "\(recordNamePrefix)_vote_\(hash64Hex(key))") + } + + private func fetchOrCreateCountRecord(key: String) async throws -> CKRecord { + let id = countRecordID(key: key) + + do { + return try await publicDB.record(for: id) + } catch let ck as CKError where ck.code == .unknownItem { + let record = CKRecord(recordType: Schema.countsRecordType, recordID: id) + record[Schema.keyField] = key as NSString + record[Schema.countField] = NSNumber(value: Int64(0)) + + do { + return try await publicDB.save(record) + } catch let ck2 as CKError where ck2.code == .serverRecordChanged { + return try await publicDB.record(for: id) + } + } + } + + private func updateCount(key: String, delta: Int64, floorAtZero: Bool) async throws -> Int { + // optimistic concurrency loop + let maxAttempts = 6 + + for attempt in 1...maxAttempts { + let record = try await fetchOrCreateCountRecord(key: key) + + let current = int64(from: record[Schema.countField]) ?? 0 + var next = current &+ delta + if floorAtZero { next = max(0, next) } + + record[Schema.countField] = NSNumber(value: next) + + do { + let saved = try await publicDB.save(record) + return Int(int64(from: saved[Schema.countField]) ?? next) + } catch let ck as CKError where ck.code == .serverRecordChanged && attempt < maxAttempts { + try await backoff(attempt: attempt) + continue + } + } + + // fallback: return whatever is currently stored + return await fetch(forFeatureID: key) + } + + // MARK: - Vote marker (private DB) + + private func voteMarkerExists(key: String) async throws -> Bool { + let id = voteRecordID(key: key) + do { + _ = try await privateDB.record(for: id) + return true + } catch let ck as CKError where ck.code == .unknownItem { + return false + } + } + + private func createVoteMarker(key: String) async throws { + let id = voteRecordID(key: key) + let record = CKRecord(recordType: Schema.votesRecordType, recordID: id) + record[Schema.voteKeyField] = key as NSString + + do { + _ = try await privateDB.save(record) + } catch let ck as CKError where ck.code == .serverRecordChanged { + // race: already exists + return + } + } + + private func deleteVoteMarker(key: String) async throws { + let id = voteRecordID(key: key) + do { + _ = try await privateDB.deleteRecord(withID: id) + } catch let ck as CKError where ck.code == .unknownItem { + // already deleted + return + } + } + + // MARK: - Helpers + + private func normalize(_ s: String) -> String { + s.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + private func hash64Hex(_ s: String) -> String { + var hash: UInt64 = 14695981039346656037 + let prime: UInt64 = 1099511628211 + for b in s.utf8 { + hash ^= UInt64(b) + hash &*= prime + } + return String(format: "%016llx", hash) + } + + private func backoff(attempt: Int) async throws { + // 30ms, 60ms, 120ms, 240ms, ... capped at 500ms + let base: UInt64 = 30_000_000 + let shift = UInt64(max(0, attempt - 1)) + let delay = min(base << shift, 500_000_000) + try await Task.sleep(nanoseconds: delay) + } + + private func int(from any: Any?) -> Int? { + if let v = any as? Int { return v } + if let v = any as? Int64 { return Int(v) } + if let v = any as? NSNumber { return v.intValue } + return nil + } + + private func int64(from any: Any?) -> Int64? { + if let v = any as? Int64 { return v } + if let v = any as? Int { return Int64(v) } + if let v = any as? NSNumber { return v.int64Value } + return nil + } + + private func logError(_ operation: String, key: String?, error: Error) { + if let key { + log.error("[\(operation, privacy: .public)] key=\(key, privacy: .public) error=\(String(describing: error), privacy: .public)") + } else { + log.error("[\(operation, privacy: .public)] error=\(String(describing: error), privacy: .public)") + } + } +} + +#else + +import Foundation + +public struct FeatureVoterCloudKit: FeatureVoter { + public init() {} + public func fetch(for feature: RoadmapFeature) async -> Int { 0 } + public func vote(for feature: RoadmapFeature) async -> Int? { nil } + public func unvote(for feature: RoadmapFeature) async -> Int? { nil } +} + +#endif