diff --git a/.fleet/design-review-ios.json b/.fleet/design-review-ios.json index 4b51e13..edf5f8a 100644 --- a/.fleet/design-review-ios.json +++ b/.fleet/design-review-ios.json @@ -23,6 +23,8 @@ {"width": 390, "path": "ios/artifacts/simulator/rest-timer.png"}, {"width": 390, "path": "ios/artifacts/simulator/plan.png"}, {"width": 390, "path": "ios/artifacts/simulator/history.png"}, + {"width": 390, "path": "ios/artifacts/simulator/account-sync.png"}, + {"width": 390, "path": "ios/artifacts/simulator/sync-conflict.png"}, {"width": 390, "path": "ios/artifacts/simulator/accessibility-xl.png"} ], "projectCheck": {"command": "cd ios && ./scripts/check.sh", "status": "pass"}, diff --git a/ios/Setline.xcodeproj/project.pbxproj b/ios/Setline.xcodeproj/project.pbxproj index d385e98..663a2e9 100644 --- a/ios/Setline.xcodeproj/project.pbxproj +++ b/ios/Setline.xcodeproj/project.pbxproj @@ -20,9 +20,11 @@ BE5FDC83C2B64F526F64979C /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 824EFFB1023C857CE4F1FE1C /* RootView.swift */; }; C66AD84C2D8FAD24149B2445 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = F52F5D64B932696BAEDEE0AC /* PrivacyInfo.xcprivacy */; }; C7E1EDD15D038B815CB78D27 /* SetlineApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 608F81A574360691A58B8581 /* SetlineApp.swift */; }; + C953D5949A40703738078819 /* CloudSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = F51113C8A77901F17242774D /* CloudSync.swift */; }; D9647D921553DE22AB36226A /* SetlineCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0DE14EAFD3C581FCA8D09CA7 /* SetlineCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; E85DFE444A157EC32C671B9B /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B839442BFCCB000A563CD704 /* AppModel.swift */; }; EC589E605EEE16DA5E0F613E /* SetlineCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0DE14EAFD3C581FCA8D09CA7 /* SetlineCore.framework */; }; + EF0C5CDD67CA95C3309607B0 /* NativeAccountClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A4007C0EBCEA2C015905F8 /* NativeAccountClient.swift */; }; F5D0CC71C826F541575E7D99 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8358BE39000A0F7E1BB90 /* Persistence.swift */; }; /* End PBXBuildFile section */ @@ -78,6 +80,8 @@ /* Begin PBXFileReference section */ 0DE14EAFD3C581FCA8D09CA7 /* SetlineCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SetlineCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 1211B40E359F4BBD82A557A9 /* SetlineCoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetlineCoreTests.swift; sourceTree = ""; }; + 15268979096821BD9ABB22E0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 27A4007C0EBCEA2C015905F8 /* NativeAccountClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAccountClient.swift; sourceTree = ""; }; 3C2CB1821B5B5AE4C75873C6 /* Design.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Design.swift; sourceTree = ""; }; 594DC48A6CD7B68259549A12 /* Progression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Progression.swift; sourceTree = ""; }; 608F81A574360691A58B8581 /* SetlineApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetlineApp.swift; sourceTree = ""; }; @@ -91,6 +95,7 @@ B839442BFCCB000A563CD704 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; BA87F29CCAE78D4F24E75B1F /* Setline.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Setline.app; sourceTree = BUILT_PRODUCTS_DIR; }; DFAE6EB21BC26F97140F4FEE /* SecondaryViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryViews.swift; sourceTree = ""; }; + F51113C8A77901F17242774D /* CloudSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSync.swift; sourceTree = ""; }; F52F5D64B932696BAEDEE0AC /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; FEE9CEC1CE889DCD183F4CF7 /* SetlineUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetlineUITests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -129,6 +134,8 @@ children = ( B839442BFCCB000A563CD704 /* AppModel.swift */, 3C2CB1821B5B5AE4C75873C6 /* Design.swift */, + 15268979096821BD9ABB22E0 /* Info.plist */, + 27A4007C0EBCEA2C015905F8 /* NativeAccountClient.swift */, 824EFFB1023C857CE4F1FE1C /* RootView.swift */, DFAE6EB21BC26F97140F4FEE /* SecondaryViews.swift */, 608F81A574360691A58B8581 /* SetlineApp.swift */, @@ -153,6 +160,7 @@ 5745423AFF1E15935A399E22 /* SetlineCore */ = { isa = PBXGroup; children = ( + F51113C8A77901F17242774D /* CloudSync.swift */, B0859DE334CC4D97FFE0DFDA /* Domain.swift */, 65E8358BE39000A0F7E1BB90 /* Persistence.swift */, 594DC48A6CD7B68259549A12 /* Progression.swift */, @@ -346,6 +354,7 @@ files = ( E85DFE444A157EC32C671B9B /* AppModel.swift in Sources */, 3CE2DEDE101DA826A45AC5F8 /* Design.swift in Sources */, + EF0C5CDD67CA95C3309607B0 /* NativeAccountClient.swift in Sources */, BE5FDC83C2B64F526F64979C /* RootView.swift in Sources */, A949A76428AB4118FB259A70 /* SecondaryViews.swift in Sources */, C7E1EDD15D038B815CB78D27 /* SetlineApp.swift in Sources */, @@ -365,6 +374,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C953D5949A40703738078819 /* CloudSync.swift in Sources */, 7DFE7762F43344AFC746CDC6 /* Domain.swift in Sources */, F5D0CC71C826F541575E7D99 /* Persistence.swift in Sources */, 309CBBBA2928642CBD999F67 /* Progression.swift in Sources */, @@ -454,14 +464,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = Setline; - INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness"; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent; - INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_FILE = Sources/Setline/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -470,7 +473,7 @@ PRODUCT_NAME = Setline; SDKROOT = iphoneos; SUPPORTS_MACCATALYST = NO; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; @@ -502,14 +505,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = Setline; - INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness"; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent; - INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_FILE = Sources/Setline/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -518,7 +514,7 @@ PRODUCT_NAME = Setline; SDKROOT = iphoneos; SUPPORTS_MACCATALYST = NO; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; diff --git a/ios/Sources/Setline/AppModel.swift b/ios/Sources/Setline/AppModel.swift index cf0fb19..03415cd 100644 --- a/ios/Sources/Setline/AppModel.swift +++ b/ios/Sources/Setline/AppModel.swift @@ -1,3 +1,4 @@ +import AuthenticationServices import Foundation import Observation import SetlineCore @@ -12,13 +13,33 @@ final class AppModel { var message: String? var importPreview: SetlineDocument? var isImportConfirmationPresented = false + var account: SetlineAccount? + var isAccountBusy = false + var cloudConflict: SetlineCloudSnapshot? + var accountMessage: String? private let store: SetlineStore + private let accountClient: SetlineNativeAccountClient + private let webAuthenticator: SetlineWebAuthenticator + private var remoteRevision: Int? + private var syncRequested = false + private var isSyncing = false + private var deferredConflict: SetlineCloudSnapshot? - init(store: SetlineStore = SetlineStore()) { + init( + store: SetlineStore = SetlineStore(), + accountClient: SetlineNativeAccountClient = SetlineNativeAccountClient(), + webAuthenticator: SetlineWebAuthenticator = SetlineWebAuthenticator() + ) { self.store = store + self.accountClient = accountClient + self.webAuthenticator = webAuthenticator if ProcessInfo.processInfo.arguments.contains("--plan-demo") { selectedTab = 1 } if ProcessInfo.processInfo.arguments.contains("--history-demo") { selectedTab = 2 } + if ProcessInfo.processInfo.arguments.contains("--account-demo") || + ProcessInfo.processInfo.arguments.contains("--account-conflict-demo") { + selectedTab = 3 + } } func load() async { @@ -40,6 +61,32 @@ final class AppModel { try document.completeCurrent(with: [SetSegment(weight: 40, repetitions: 8)]) isWorkoutPresented = true } + if ProcessInfo.processInfo.arguments.contains("--account-demo") { + account = SetlineAccount(name: "Sarthak", email: "sarthak@example.com") + document.syncState = .synced + document.lastSyncedAt = Date().addingTimeInterval(-240) + } else if ProcessInfo.processInfo.arguments.contains("--account-conflict-demo") { + account = SetlineAccount(name: "Sarthak", email: "sarthak@example.com") + document.syncState = .conflict + var accountDocument = document + if let template = accountDocument.templates.first { + accountDocument.history = [ + WorkoutSession( + templateID: template.id, + templateName: template.name, + startedAt: Date().addingTimeInterval(-3_600), + completedAt: Date().addingTimeInterval(-2_700), + steps: [] + ), + ] + } + cloudConflict = SetlineCloudSnapshot( + document: SetlineCloudDocument(document: accountDocument), + revision: 3 + ) + } else { + await restoreAccount() + } } catch { document = .sample message = error.localizedDescription @@ -143,6 +190,11 @@ final class AppModel { self.importPreview = nil isImportConfirmationPresented = false message = "Setline data replaced." + if account != nil, deferredConflict == nil, cloudConflict == nil { + document.syncState = .pending + try await store.save(document) + Task { await self.queueSync() } + } } catch { message = error.localizedDescription } @@ -153,17 +205,188 @@ final class AppModel { try await store.reset() document = .sample message = "Local data reset." + if account != nil, deferredConflict == nil, cloudConflict == nil { + document.syncState = .pending + try await store.save(document) + Task { await self.queueSync() } + } } catch { message = error.localizedDescription } } + func connectAccount() async { + isAccountBusy = true + accountMessage = nil + defer { isAccountBusy = false } + do { + let url = await accountClient.googleStartURL + let code = try await webAuthenticator.authenticate(at: url) + account = try await accountClient.exchangeHandoff(code) + try await reconcileAccountCopy() + } catch let error as NSError + where error.domain == ASWebAuthenticationSessionErrorDomain && error.code == 1 { + accountMessage = nil + } catch { + accountMessage = friendlyMessage(for: error) + } + } + + func syncNow() async { + guard account != nil else { return } + if let deferredConflict { + self.deferredConflict = nil + cloudConflict = deferredConflict + return + } + await queueSync() + } + + func keepDeviceCopy() async { + guard let conflict = cloudConflict else { return } + cloudConflict = nil + deferredConflict = nil + remoteRevision = conflict.revision + await queueSync() + } + + func useAccountCopy() async { + guard let conflict = cloudConflict else { return } + do { + let restored = conflict.document.localDocument() + try await store.replace(with: restored) + document = restored + remoteRevision = conflict.revision + cloudConflict = nil + deferredConflict = nil + accountMessage = "Account copy restored on this iPhone." + } catch { + accountMessage = friendlyMessage(for: error) + } + } + + func decideConflictLater() { + deferredConflict = cloudConflict + cloudConflict = nil + document.syncState = .conflict + Task { try? await store.save(document) } + } + + func signOut() async { + await accountClient.signOut() + account = nil + remoteRevision = nil + cloudConflict = nil + deferredConflict = nil + document.syncState = .deviceOnly + try? await store.save(document) + accountMessage = "Signed out. Your workouts remain on this iPhone." + } + + func deleteAccount() async { + isAccountBusy = true + defer { isAccountBusy = false } + do { + try await accountClient.deleteAccount() + account = nil + remoteRevision = nil + cloudConflict = nil + deferredConflict = nil + document.syncState = .deviceOnly + try await store.save(document) + accountMessage = "Setline account and its private cloud copy were deleted." + } catch { + accountMessage = friendlyMessage(for: error) + } + } + + private func restoreAccount() async { + do { + account = try await accountClient.restoreAccount() + if account != nil { try await reconcileAccountCopy() } + } catch { + account = nil + document.syncState = .deviceOnly + accountMessage = friendlyMessage(for: error) + } + } + + private func reconcileAccountCopy() async throws { + let remote = try await accountClient.fetchState() + guard let remote else { + let saved = try await accountClient.pushState( + SetlineCloudDocument(document: document), + baseRevision: nil + ) + remoteRevision = saved.revision + await markSynced() + return + } + remoteRevision = remote.revision + if remote.document == SetlineCloudDocument(document: document) { + await markSynced() + } else { + document.syncState = .conflict + try await store.save(document) + cloudConflict = remote + } + } + + private func queueSync() async { + syncRequested = true + guard !isSyncing else { return } + isSyncing = true + defer { isSyncing = false } + while syncRequested { + syncRequested = false + document.syncState = .pending + try? await store.save(document) + do { + let saved = try await accountClient.pushState( + SetlineCloudDocument(document: document), + baseRevision: remoteRevision + ) + remoteRevision = saved.revision + await markSynced() + } catch let NativeAccountError.conflict(conflict) { + document.syncState = .conflict + try? await store.save(document) + cloudConflict = conflict + return + } catch { + document.syncState = .failed + try? await store.save(document) + accountMessage = friendlyMessage(for: error) + return + } + } + } + + private func markSynced() async { + document.syncState = .synced + document.lastSyncedAt = .now + try? await store.save(document) + accountMessage = "Private account copy is up to date." + } + + private func friendlyMessage(for error: Error) -> String { + if let native = error as? NativeAccountError { + return native.errorDescription ?? "Setline account service is unavailable." + } + return "Setline could not complete that account action. Try again." + } + private func mutate(_ operation: (inout SetlineDocument) throws -> Void) async { do { var next = document try operation(&next) try await store.save(next) document = next + if account != nil, deferredConflict == nil, cloudConflict == nil { + document.syncState = .pending + try await store.save(document) + Task { await self.queueSync() } + } } catch { message = error.localizedDescription } diff --git a/ios/Sources/Setline/Info.plist b/ios/Sources/Setline/Info.plist new file mode 100644 index 0000000..50b4522 --- /dev/null +++ b/ios/Sources/Setline/Info.plist @@ -0,0 +1,54 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Setline + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + com.significanthobbies.setline.auth + CFBundleURLSchemes + + setline + + + + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + LSApplicationCategoryType + public.app-category.healthcare-fitness + UIApplicationSupportsIndirectInputEvents + + UILaunchScreen + + UIStatusBarStyle + UIStatusBarStyleDarkContent + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/Sources/Setline/NativeAccountClient.swift b/ios/Sources/Setline/NativeAccountClient.swift new file mode 100644 index 0000000..cda8c57 --- /dev/null +++ b/ios/Sources/Setline/NativeAccountClient.swift @@ -0,0 +1,322 @@ +import AuthenticationServices +import Foundation +import Security +import SetlineCore +import UIKit + +struct SetlineAccount: Equatable, Sendable { + let name: String + let email: String +} + +enum NativeAccountError: LocalizedError { + case invalidCallback + case missingSession + case conflict(SetlineCloudSnapshot) + case server(String) + case http(Int, String) + + var errorDescription: String? { + switch self { + case .invalidCallback: + "Setline could not verify the sign-in handoff." + case .missingSession: + "Your Setline session expired. Sign in again." + case .conflict: + "A newer account copy needs your decision." + case let .server(message), let .http(_, message): + message + } + } +} + +actor SetlineKeychainSessionStore { + private let service: String + private let account: String + + init( + service: String = "com.significanthobbies.setline.session", + account: String = "better-auth-bearer" + ) { + self.service = service + self.account = account + } + + func load() throws -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { + throw NativeAccountError.server("Setline could not read the secure session.") + } + return String(data: data, encoding: .utf8) + } + + func save(_ token: String) throws { + let data = Data(token.utf8) + let identity: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + let updateStatus = SecItemUpdate(identity as CFDictionary, attributes as CFDictionary) + if updateStatus == errSecItemNotFound { + var insertion = identity + insertion.merge(attributes) { _, replacement in replacement } + guard SecItemAdd(insertion as CFDictionary, nil) == errSecSuccess else { + throw NativeAccountError.server("Setline could not store the secure session.") + } + } else if updateStatus != errSecSuccess { + throw NativeAccountError.server("Setline could not update the secure session.") + } + } + + func delete() throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw NativeAccountError.server("Setline could not remove the secure session.") + } + } +} + +actor SetlineNativeAccountClient { + static let productionBaseURL = URL(string: "https://setline.significanthobbies.com")! + + private let baseURL: URL + private let urlSession: URLSession + private let sessionStore: SetlineKeychainSessionStore + + init( + baseURL: URL = productionBaseURL, + urlSession: URLSession = .shared, + sessionStore: SetlineKeychainSessionStore = SetlineKeychainSessionStore() + ) { + self.baseURL = baseURL + self.urlSession = urlSession + self.sessionStore = sessionStore + } + + var googleStartURL: URL { + var components = URLComponents( + url: endpoint("/api/native/auth/google/start"), + resolvingAgainstBaseURL: false + )! + components.queryItems = [URLQueryItem(name: "callback", value: "setline://auth")] + return components.url! + } + + func restoreAccount() async throws -> SetlineAccount? { + guard try await sessionStore.load() != nil else { return nil } + do { + return try await account() + } catch { + try? await sessionStore.delete() + throw error + } + } + + func exchangeHandoff(_ code: String) async throws -> SetlineAccount { + let response = try await request( + path: "/api/native/auth/exchange", + body: ["code": code], + authenticated: false + ) + let payload = try JSONDecoder().decode(TokenResponse.self, from: response.data) + try await sessionStore.save(payload.token) + return try await account() + } + + func fetchState() async throws -> SetlineCloudSnapshot? { + let response = try await request(path: "/api/native/state", method: "GET") + return try decoder.decode(StateResponse.self, from: response.data).state + } + + func pushState( + _ document: SetlineCloudDocument, + baseRevision: Int? + ) async throws -> SetlineCloudSnapshot { + let body = StateWrite(document: document, baseRevision: baseRevision) + let response = try await request(path: "/api/native/state", method: "PUT", encodableBody: body) + let payload = try decoder.decode(StateResponse.self, from: response.data) + guard let state = payload.state else { + throw NativeAccountError.server("Setline did not return the saved account copy.") + } + return state + } + + func signOut() async { + _ = try? await request(path: "/api/auth/sign-out", body: [String: String]()) + try? await sessionStore.delete() + } + + func deleteAccount() async throws { + _ = try await request(path: "/api/auth/delete-user", body: [String: String]()) + try await sessionStore.delete() + } + + private func account() async throws -> SetlineAccount { + let response = try await request(path: "/api/auth/get-session", method: "GET") + let session = try decoder.decode(SessionResponse.self, from: response.data) + return SetlineAccount(name: session.user.name, email: session.user.email) + } + + private func request( + path: String, + method: String = "POST", + body: [String: String], + authenticated: Bool = true + ) async throws -> NetworkResponse { + try await request( + path: path, + method: method, + data: try JSONEncoder().encode(body), + authenticated: authenticated + ) + } + + private func request( + path: String, + method: String, + encodableBody: T + ) async throws -> NetworkResponse { + try await request( + path: path, + method: method, + data: try encoder.encode(encodableBody), + authenticated: true + ) + } + + private func request( + path: String, + method: String, + data: Data? = nil, + authenticated: Bool = true + ) async throws -> NetworkResponse { + var request = URLRequest(url: endpoint(path)) + request.httpMethod = method + request.httpBody = data + request.setValue("application/json", forHTTPHeaderField: "Accept") + if data != nil { request.setValue("application/json", forHTTPHeaderField: "Content-Type") } + if authenticated { + guard let token = try await sessionStore.load() else { + throw NativeAccountError.missingSession + } + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + let (responseData, rawResponse) = try await urlSession.data(for: request) + guard let response = rawResponse as? HTTPURLResponse else { + throw NativeAccountError.server("Setline received an invalid server response.") + } + if response.statusCode == 409, + let conflict = try? decoder.decode(StateResponse.self, from: responseData).state { + throw NativeAccountError.conflict(conflict) + } + guard (200..<300).contains(response.statusCode) else { + let message = (try? decoder.decode(ErrorResponse.self, from: responseData).message) + ?? "Setline account service is unavailable." + if response.statusCode == 401 { throw NativeAccountError.missingSession } + throw NativeAccountError.http(response.statusCode, message) + } + return NetworkResponse(data: responseData, response: response) + } + + private func endpoint(_ path: String) -> URL { + baseURL.appending(path: path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))) + } + + private var encoder: JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + } + + private var decoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} + +@MainActor +final class SetlineWebAuthenticator: NSObject, ASWebAuthenticationPresentationContextProviding { + private var session: ASWebAuthenticationSession? + + func authenticate(at url: URL) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + let session = ASWebAuthenticationSession( + url: url, + callbackURLScheme: "setline" + ) { callbackURL, error in + if let error { + continuation.resume(throwing: error) + return + } + guard + let callbackURL, + callbackURL.scheme == "setline", + callbackURL.host == "auth", + let code = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "code" })?.value + else { + continuation.resume(throwing: NativeAccountError.invalidCallback) + return + } + continuation.resume(returning: code) + } + session.presentationContextProvider = self + session.prefersEphemeralWebBrowserSession = false + self.session = session + guard session.start() else { + continuation.resume(throwing: NativeAccountError.server("Setline could not open sign in.")) + return + } + } + } + + func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first(where: \.isKeyWindow) ?? ASPresentationAnchor() + } +} + +private struct TokenResponse: Decodable { let token: String } +private struct SessionResponse: Decodable { let user: SessionUser } +private struct SessionUser: Decodable { let name: String; let email: String } +private struct ErrorResponse: Decodable { let message: String } +private struct StateResponse: Decodable { let state: SetlineCloudSnapshot? } +private struct StateWrite: Encodable { + let document: SetlineCloudDocument + let baseRevision: Int? + + private enum CodingKeys: String, CodingKey { case document, baseRevision } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(document, forKey: .document) + if let baseRevision { + try container.encode(baseRevision, forKey: .baseRevision) + } else { + try container.encodeNil(forKey: .baseRevision) + } + } +} +private struct NetworkResponse { let data: Data; let response: HTTPURLResponse } diff --git a/ios/Sources/Setline/SecondaryViews.swift b/ios/Sources/Setline/SecondaryViews.swift index 2e6a872..ccaa271 100644 --- a/ios/Sources/Setline/SecondaryViews.swift +++ b/ios/Sources/Setline/SecondaryViews.swift @@ -365,6 +365,7 @@ struct SettingsView: View { @Environment(AppModel.self) private var model @State private var isImporterPresented = false @State private var showResetConfirmation = false + @State private var showDeleteAccountConfirmation = false var body: some View { @Bindable var model = model @@ -379,18 +380,59 @@ struct SettingsView: View { .background(SetlinePalette.blue) .clipShape(RoundedRectangle(cornerRadius: 9)) VStack(alignment: .leading, spacing: 3) { - Text("Device-only mode").font(.headline) - Text("Workout actions work offline.").font(.subheadline).foregroundStyle(.secondary) + Text(model.account?.name ?? "Device-only mode").font(.headline) + Text(model.account?.email ?? "Workout actions work offline.") + .font(.subheadline) + .foregroundStyle(.secondary) } Spacer() - Text(model.document.syncState.rawValue.uppercased()) + Text(syncLabel(model.document.syncState).uppercased()) .font(.caption2.weight(.black)) } - Link(destination: URL(string: "https://setline.significanthobbies.com")!) { - Label("Open Setline account", systemImage: "safari") - .frame(maxWidth: .infinity, minHeight: 48) + if model.isAccountBusy { + HStack(spacing: 10) { + ProgressView() + Text("Contacting Setline…") + } + .frame(maxWidth: .infinity, minHeight: 48) + } else if model.account == nil { + Button { Task { await model.connectAccount() } } label: { + Label("Connect Google account", systemImage: "person.crop.circle.badge.plus") + .frame(maxWidth: .infinity, minHeight: 48) + .foregroundStyle(.white) + } + .buttonStyle(.borderedProminent) + .tint(SetlinePalette.ink) + } else { + if let lastSync = model.document.lastSyncedAt { + LabeledContent("Last synced") { + Text(lastSync, style: .relative).foregroundStyle(.secondary) + } + } + Button { Task { await model.syncNow() } } label: { + Label("Sync now", systemImage: "arrow.triangle.2.circlepath") + .frame(maxWidth: .infinity, minHeight: 48) + .foregroundStyle(.white) + } + .buttonStyle(.borderedProminent) + .tint(SetlinePalette.ink) + Button { Task { await model.signOut() } } label: { + Label("Sign out", systemImage: "rectangle.portrait.and.arrow.right") + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(minHeight: 48) + Button(role: .destructive) { showDeleteAccountConfirmation = true } label: { + Label("Delete Setline account", systemImage: "person.crop.circle.badge.minus") + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(minHeight: 48) + } + if let accountMessage = model.accountMessage { + Text(accountMessage) + .font(.footnote) + .foregroundStyle(.secondary) + .accessibilityLabel("Account status: \(accountMessage)") } - .buttonStyle(.bordered) } settingsSection("Your data") { ShareLink( @@ -444,6 +486,53 @@ struct SettingsView: View { Button("Reset local data", role: .destructive) { Task { await model.resetLocalData() } } Button("Cancel", role: .cancel) {} } + .confirmationDialog( + "Delete your Setline account and private cloud copy?", + isPresented: $showDeleteAccountConfirmation + ) { + Button("Delete account", role: .destructive) { Task { await model.deleteAccount() } } + Button("Cancel", role: .cancel) {} + } message: { + Text("Workouts already saved on this iPhone remain local. This account action cannot be undone.") + } + .sheet(item: $model.cloudConflict) { conflict in + NavigationStack { + VStack(alignment: .leading, spacing: 22) { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 34, weight: .bold)) + .foregroundStyle(SetlinePalette.blue) + Text("Choose the workout copy to keep") + .font(.title2.bold()) + Text("This iPhone and your private account changed separately. Review the totals, then choose. Nothing is replaced until you decide.") + .foregroundStyle(.secondary) + LabeledContent("This iPhone") { + Text(workoutCount(model.document.history.count)) + } + LabeledContent("Account copy") { + Text(workoutCount(conflict.document.history.count)) + } + Button { Task { await model.keepDeviceCopy() } } label: { + Text("Keep this iPhone’s copy").foregroundStyle(.white) + } + .buttonStyle(.borderedProminent) + .tint(SetlinePalette.ink) + .controlSize(.large) + .frame(maxWidth: .infinity) + Button("Use the account copy") { Task { await model.useAccountCopy() } } + .buttonStyle(.bordered) + .controlSize(.large) + .frame(maxWidth: .infinity) + Button("Decide later") { model.decideConflictLater() } + .frame(maxWidth: .infinity, minHeight: 44) + Spacer() + } + .padding(24) + .navigationTitle("Sync conflict") + .navigationBarTitleDisplayMode(.inline) + } + .presentationDetents([.large]) + .interactiveDismissDisabled() + } } private func settingsSection(_ title: String, @ViewBuilder content: () -> Content) -> some View { @@ -455,6 +544,20 @@ struct SettingsView: View { .clipShape(RoundedRectangle(cornerRadius: 14)) } } + + private func syncLabel(_ state: SyncState) -> String { + switch state { + case .deviceOnly: "On device" + case .pending: "Syncing" + case .synced: "Synced" + case .conflict: "Decision needed" + case .failed: "Retry needed" + } + } + + private func workoutCount(_ count: Int) -> String { + "\(count) workout\(count == 1 ? "" : "s")" + } } private func pageHeader(_ title: String, subtitle: String) -> some View { diff --git a/ios/Sources/SetlineCore/CloudSync.swift b/ios/Sources/SetlineCore/CloudSync.swift new file mode 100644 index 0000000..0bdcea5 --- /dev/null +++ b/ios/Sources/SetlineCore/CloudSync.swift @@ -0,0 +1,47 @@ +import Foundation + +/// The versioned document exchanged by native Setline clients. +/// Device-only synchronization metadata is deliberately excluded so a successful +/// sync never creates another document change by itself. +public struct SetlineCloudDocument: Codable, Equatable, Sendable { + public var schemaVersion: Int + public var templates: [WorkoutTemplate] + public var programme: CustomProgramme? + public var activeSession: WorkoutSession? + public var history: [WorkoutSession] + + public init(document: SetlineDocument) { + schemaVersion = document.schemaVersion + templates = document.templates + programme = document.programme + activeSession = document.activeSession + history = document.history + } + + public func localDocument( + syncState: SyncState = .synced, + lastSyncedAt: Date = .now + ) -> SetlineDocument { + SetlineDocument( + schemaVersion: schemaVersion, + templates: templates, + programme: programme, + activeSession: activeSession, + history: history, + syncState: syncState, + lastSyncedAt: lastSyncedAt + ) + } +} + +public struct SetlineCloudSnapshot: Codable, Equatable, Identifiable, Sendable { + public var document: SetlineCloudDocument + public var revision: Int + + public init(document: SetlineCloudDocument, revision: Int) { + self.document = document + self.revision = revision + } + + public var id: Int { revision } +} diff --git a/ios/Tests/SetlineCoreTests/SetlineCoreTests.swift b/ios/Tests/SetlineCoreTests/SetlineCoreTests.swift index 19dbad7..4acebe5 100644 --- a/ios/Tests/SetlineCoreTests/SetlineCoreTests.swift +++ b/ios/Tests/SetlineCoreTests/SetlineCoreTests.swift @@ -2,6 +2,26 @@ import XCTest @testable import SetlineCore final class SetlineCoreTests: XCTestCase { + func testCloudDocumentExcludesDeviceSyncMetadata() { + var first = SetlineDocument.sample + first.syncState = .pending + first.lastSyncedAt = Date(timeIntervalSince1970: 100) + var second = first + second.syncState = .failed + second.lastSyncedAt = Date(timeIntervalSince1970: 200) + + XCTAssertEqual(SetlineCloudDocument(document: first), SetlineCloudDocument(document: second)) + } + + func testCloudDocumentRestoresAsSyncedLocalDocument() { + let cloud = SetlineCloudDocument(document: .sample) + let restored = cloud.localDocument(lastSyncedAt: Date(timeIntervalSince1970: 300)) + + XCTAssertEqual(restored.syncState, .synced) + XCTAssertEqual(restored.lastSyncedAt, Date(timeIntervalSince1970: 300)) + XCTAssertEqual(SetlineCloudDocument(document: restored), cloud) + } + func testSessionSnapshotKeepsAuthoredOrderWhenTemplateChanges() throws { var document = SetlineDocument.sample let templateID = try XCTUnwrap(document.templates.first?.id) diff --git a/ios/artifacts/simulator/account-sync.png b/ios/artifacts/simulator/account-sync.png new file mode 100644 index 0000000..fe9aad9 Binary files /dev/null and b/ios/artifacts/simulator/account-sync.png differ diff --git a/ios/artifacts/simulator/sync-conflict.png b/ios/artifacts/simulator/sync-conflict.png new file mode 100644 index 0000000..1b9114a Binary files /dev/null and b/ios/artifacts/simulator/sync-conflict.png differ diff --git a/ios/project.yml b/ios/project.yml index 9912916..ef372a1 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -36,19 +36,33 @@ targets: buildPhase: resources dependencies: - target: SetlineCore + info: + path: Sources/Setline/Info.plist + properties: + CFBundleDisplayName: Setline + CFBundleShortVersionString: "$(MARKETING_VERSION)" + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" + CFBundleURLTypes: + - CFBundleTypeRole: Editor + CFBundleURLName: com.significanthobbies.setline.auth + CFBundleURLSchemes: + - setline + ITSAppUsesNonExemptEncryption: false + LSApplicationCategoryType: public.app-category.healthcare-fitness + UIApplicationSupportsIndirectInputEvents: true + UILaunchScreen: {} + UIStatusBarStyle: UIStatusBarStyleDarkContent + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.significanthobbies.setline PRODUCT_NAME: Setline + TARGETED_DEVICE_FAMILY: "1" ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - GENERATE_INFOPLIST_FILE: YES - INFOPLIST_KEY_CFBundleDisplayName: Setline - INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO - INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.healthcare-fitness - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES - INFOPLIST_KEY_UILaunchScreen_Generation: YES - INFOPLIST_KEY_UIStatusBarStyle: UIStatusBarStyleDarkContent - INFOPLIST_KEY_UISupportedInterfaceOrientations: "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" SUPPORTS_MACCATALYST: NO SetlineCoreTests: type: bundle.unit-test diff --git a/migrations/0003_native_account_sync.sql b/migrations/0003_native_account_sync.sql new file mode 100644 index 0000000..bbcaba2 --- /dev/null +++ b/migrations/0003_native_account_sync.sql @@ -0,0 +1,17 @@ +CREATE TABLE native_auth_handoffs ( + code_hash TEXT PRIMARY KEY NOT NULL, + session_token TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE INDEX native_auth_handoffs_expiry_idx + ON native_auth_handoffs(expires_at); + +CREATE TABLE native_workout_state ( + user_id TEXT PRIMARY KEY NOT NULL REFERENCES user(id) ON DELETE CASCADE, + payload TEXT NOT NULL, + revision INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); diff --git a/openspec/changes/add-native-ios-app/tasks.md b/openspec/changes/add-native-ios-app/tasks.md index 2a14aa0..6e87332 100644 --- a/openspec/changes/add-native-ios-app/tasks.md +++ b/openspec/changes/add-native-ios-app/tasks.md @@ -20,6 +20,8 @@ ## 4. Accounts And Quality - [ ] 4.1 Add isolated native authentication, Keychain session storage, whole-state sync status, conflicts, sign out, and deletion controls + - [x] Implement and test the personal-account OAuth handoff, bearer session, versioned native state adapter, revision conflicts, and native controls + - [ ] Apply the reviewed D1 migration and deploy the callback/API contract before enabling live account connection - [x] 4.2 Complete Dynamic Type, VoiceOver, Reduce Motion, contrast, empty/error/loading states, and native polish review - [x] 4.3 Add release metadata, privacy/support copy, simulator screenshots, and documented device-only checks - [x] 4.4 Run strict OpenSpec validation, tests, Release simulator build, personal-team archive, and signature verification without upload diff --git a/tests/native-account.test.mjs b/tests/native-account.test.mjs new file mode 100644 index 0000000..8af5bd8 --- /dev/null +++ b/tests/native-account.test.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createServer } from "vite"; + +let handoff; +let nativeState; +let vite; + +test.before(async () => { + vite = await createServer({ + appType: "custom", + configFile: false, + server: { middlewareMode: true }, + }); + handoff = await vite.ssrLoadModule("/worker/native-handoff.ts"); + nativeState = await vite.ssrLoadModule("/worker/native-state.ts"); +}); + +test.after(async () => { + await vite.close(); +}); + +test("native authentication accepts only Setline's exact callback", () => { + assert.equal(handoff.isAllowedNativeCallback("setline://auth"), true); + assert.equal(handoff.isAllowedNativeCallback("setline://auth.evil.example"), false); + assert.equal(handoff.isAllowedNativeCallback("https://setline.example/auth"), false); +}); + +test("native handoff codes are opaque and hash deterministically", async () => { + const first = handoff.createNativeHandoffCode(); + const second = handoff.createNativeHandoffCode(); + assert.equal(first.length, 43); + assert.notEqual(first, second); + assert.equal(await handoff.hashNativeHandoffCode(first), await handoff.hashNativeHandoffCode(first)); + assert.notEqual(await handoff.hashNativeHandoffCode(first), await handoff.hashNativeHandoffCode(second)); +}); + +test("native state requires schema one and an explicit base revision", () => { + assert.deepEqual( + nativeState.parseNativeStateEnvelope({ document: { schemaVersion: 1 }, baseRevision: null }), + { document: { schemaVersion: 1 }, baseRevision: null }, + ); + assert.equal( + nativeState.parseNativeStateEnvelope({ document: { schemaVersion: 2 }, baseRevision: null }), + null, + ); + assert.equal( + nativeState.parseNativeStateEnvelope({ document: { schemaVersion: 1 } }), + null, + ); + assert.equal( + nativeState.parseNativeStateEnvelope({ document: { schemaVersion: 1 }, baseRevision: -1 }), + null, + ); +}); diff --git a/worker/auth.ts b/worker/auth.ts index fb9a43f..0d12806 100644 --- a/worker/auth.ts +++ b/worker/auth.ts @@ -1,5 +1,6 @@ import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { bearer } from "better-auth/plugins"; import { drizzle } from "drizzle-orm/d1"; import { account, session, user, verification } from "./schema"; @@ -59,7 +60,8 @@ export function createAuth(env: SetlineBindings, requestUrl: string) { enabled: true, }, }, - trustedOrigins: [...new Set([baseURL, ...LOCAL_ORIGINS])], + plugins: [bearer()], + trustedOrigins: [...new Set([baseURL, ...LOCAL_ORIGINS, "setline://auth"])], rateLimit: { enabled: false, }, diff --git a/worker/index.ts b/worker/index.ts index d5187d0..1bc6e66 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -3,6 +3,14 @@ import handler from "vinext/server/app-router-entry"; import { handleAgentEdge } from "./agent-edge.mjs"; import { createAuth, isGoogleConfigured, type SetlineBindings } from "./auth"; import { handleMcpRead, handleMcpTokenManagement } from "./mcp"; +import { + consumeNativeHandoff, + createNativeHandoffCode, + isAllowedNativeCallback, + NATIVE_AUTH_CALLBACK, + saveNativeHandoff, +} from "./native-handoff"; +import { handleNativeState } from "./native-state"; import { handlePrivateState } from "./state"; const SECURITY_HEADERS = { @@ -51,6 +59,60 @@ const worker = { return json({ googleConfigured: isGoogleConfigured(env) }); } + if (url.pathname === "/api/native/auth/google/start" && request.method === "GET") { + if (!isGoogleConfigured(env)) { + return json({ code: "OAUTH_NOT_CONFIGURED", message: "Google sign-in is unavailable." }, 503); + } + const callback = url.searchParams.get("callback") ?? NATIVE_AUTH_CALLBACK; + if (!isAllowedNativeCallback(callback)) { + return json({ code: "INVALID_CALLBACK", message: "The native callback is not allowed." }, 400); + } + const completeURL = new URL("/api/native/auth/google/complete", request.url); + completeURL.searchParams.set("callback", callback); + const result = await createAuth(env, request.url).api.signInSocial({ + body: { + provider: "google", + callbackURL: completeURL.toString(), + errorCallbackURL: completeURL.toString(), + }, + headers: request.headers, + }); + if (!result.url) { + return json({ code: "OAUTH_START_FAILED", message: "Google sign-in could not start." }, 502); + } + return Response.redirect(result.url); + } + + if (url.pathname === "/api/native/auth/google/complete" && request.method === "GET") { + const callback = url.searchParams.get("callback") ?? NATIVE_AUTH_CALLBACK; + if (!isAllowedNativeCallback(callback)) { + return json({ code: "INVALID_CALLBACK", message: "The native callback is not allowed." }, 400); + } + const session = await createAuth(env, request.url).api.getSession({ headers: request.headers }); + const redirect = new URL(callback); + if (!session?.session.token) { + redirect.searchParams.set("error", "google_auth_failed"); + return Response.redirect(redirect.toString()); + } + const code = createNativeHandoffCode(); + await saveNativeHandoff(env.DB, code, session.session.token); + redirect.searchParams.set("code", code); + return Response.redirect(redirect.toString()); + } + + if (url.pathname === "/api/native/auth/exchange" && request.method === "POST") { + const body = (await request.json().catch(() => null)) as { code?: unknown } | null; + const code = typeof body?.code === "string" ? body.code.trim() : ""; + if (code.length < 32 || code.length > 128) { + return json({ code: "INVALID_HANDOFF", message: "The sign-in handoff is invalid." }, 400); + } + const token = await consumeNativeHandoff(env.DB, code); + if (!token) { + return json({ code: "EXPIRED_HANDOFF", message: "The sign-in handoff expired or was already used." }, 401); + } + return json({ token }); + } + if (url.pathname.startsWith("/api/auth/")) { if ( url.pathname.endsWith("/sign-in/social") && @@ -123,6 +185,19 @@ const worker = { } } + if (url.pathname === "/api/native/state") { + try { + return withApiHeaders(await handleNativeState(request, env)); + } catch (error) { + console.error(JSON.stringify({ + event: "setline_native_state_error", + method: request.method, + message: error instanceof Error ? error.message : "Unknown error", + })); + return json({ code: "STATE_UNAVAILABLE", message: "Native sync is temporarily unavailable." }, 503); + } + } + if (url.pathname.startsWith("/api/")) { return json({ code: "NOT_FOUND", message: "API route not found." }, 404); } diff --git a/worker/native-handoff.ts b/worker/native-handoff.ts new file mode 100644 index 0000000..b971abb --- /dev/null +++ b/worker/native-handoff.ts @@ -0,0 +1,61 @@ +export const NATIVE_AUTH_CALLBACK = "setline://auth"; +export const NATIVE_HANDOFF_TTL_MS = 5 * 60 * 1000; + +export function isAllowedNativeCallback(value: string) { + return value === NATIVE_AUTH_CALLBACK; +} + +export function createNativeHandoffCode() { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + return btoa(String.fromCharCode(...bytes)) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replaceAll("=", ""); +} + +export async function hashNativeHandoffCode(code: string) { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(code), + ); + return [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +export async function saveNativeHandoff( + db: D1Database, + code: string, + sessionToken: string, + now = Date.now(), +) { + const codeHash = await hashNativeHandoffCode(code); + await db + .prepare( + `INSERT INTO native_auth_handoffs (code_hash, session_token, expires_at, created_at) + VALUES (?, ?, ?, ?)`, + ) + .bind(codeHash, sessionToken, now + NATIVE_HANDOFF_TTL_MS, now) + .run(); +} + +export async function consumeNativeHandoff( + db: D1Database, + code: string, + now = Date.now(), +) { + const codeHash = await hashNativeHandoffCode(code); + const row = await db + .prepare( + `DELETE FROM native_auth_handoffs + WHERE code_hash = ? AND expires_at > ? + RETURNING session_token`, + ) + .bind(codeHash, now) + .first<{ session_token: string }>(); + await db + .prepare("DELETE FROM native_auth_handoffs WHERE expires_at <= ?") + .bind(now) + .run(); + return row?.session_token ?? null; +} diff --git a/worker/native-state.ts b/worker/native-state.ts new file mode 100644 index 0000000..8ec6bd2 --- /dev/null +++ b/worker/native-state.ts @@ -0,0 +1,135 @@ +import { createAuth, type SetlineBindings } from "./auth"; + +const MAX_STATE_BYTES = 512 * 1024; + +type NativeStateRow = { + payload: string; + revision: number; +}; + +type NativeStateEnvelope = { + document: Record; + baseRevision: number | null; +}; + +function json(payload: unknown, status = 200) { + return Response.json(payload, { status }); +} + +export function parseNativeStateEnvelope(value: unknown): NativeStateEnvelope | null { + if (!value || typeof value !== "object") return null; + const candidate = value as Record; + if (!candidate.document || typeof candidate.document !== "object") return null; + const document = candidate.document as Record; + if (document.schemaVersion !== 1) return null; + const baseRevision = candidate.baseRevision; + if ( + baseRevision !== null && + (!Number.isSafeInteger(baseRevision) || Number(baseRevision) < 0) + ) { + return null; + } + return { document, baseRevision: baseRevision as number | null }; +} + +async function resolveUserId(request: Request, env: SetlineBindings) { + const session = await createAuth(env, request.url).api.getSession({ + headers: request.headers, + }); + return session?.user?.id ?? null; +} + +function parseRow(row: NativeStateRow | null) { + if (!row) return null; + try { + const document = JSON.parse(row.payload) as unknown; + if (!document || typeof document !== "object") return null; + return { document, revision: row.revision }; + } catch { + return null; + } +} + +export async function handleNativeState(request: Request, env: SetlineBindings) { + const userId = await resolveUserId(request, env); + if (!userId) { + return json({ code: "UNAUTHORIZED", message: "Sign in to continue." }, 401); + } + + if (request.method === "GET") { + const row = await env.DB.prepare( + "SELECT payload, revision FROM native_workout_state WHERE user_id = ?", + ) + .bind(userId) + .first(); + return json({ state: parseRow(row) }); + } + + if (request.method !== "PUT") { + return new Response("Method Not Allowed", { + status: 405, + headers: { Allow: "GET, PUT" }, + }); + } + + const declaredLength = Number(request.headers.get("content-length") ?? "0"); + if (Number.isFinite(declaredLength) && declaredLength > MAX_STATE_BYTES) { + return json({ code: "STATE_TOO_LARGE", message: "Workout state is too large." }, 413); + } + const text = await request.text(); + if (new TextEncoder().encode(text).byteLength > MAX_STATE_BYTES) { + return json({ code: "STATE_TOO_LARGE", message: "Workout state is too large." }, 413); + } + const envelope = parseNativeStateEnvelope( + (() => { + try { + return JSON.parse(text) as unknown; + } catch { + return null; + } + })(), + ); + if (!envelope) { + return json({ code: "INVALID_STATE", message: "Native workout state is invalid." }, 400); + } + + const payload = JSON.stringify(envelope.document); + const now = Date.now(); + if (envelope.baseRevision === null) { + const result = await env.DB.prepare( + `INSERT OR IGNORE INTO native_workout_state + (user_id, payload, revision, created_at, updated_at) + VALUES (?, ?, 1, ?, ?)`, + ) + .bind(userId, payload, now, now) + .run(); + if (result.meta.changes > 0) { + return json({ state: { document: envelope.document, revision: 1 } }); + } + } else { + const nextRevision = envelope.baseRevision + 1; + const row = await env.DB.prepare( + `UPDATE native_workout_state + SET payload = ?, revision = ?, updated_at = ? + WHERE user_id = ? AND revision = ? + RETURNING payload, revision`, + ) + .bind(payload, nextRevision, now, userId, envelope.baseRevision) + .first(); + if (row) return json({ state: parseRow(row) }); + } + + const current = await env.DB.prepare( + "SELECT payload, revision FROM native_workout_state WHERE user_id = ?", + ) + .bind(userId) + .first(); + return json( + { + code: "STALE_STATE", + message: "A newer native workout state is already stored.", + state: parseRow(current), + }, + 409, + ); +}