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 ios/RELEASE_METADATA.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,10 @@ Confirm the rating produced by App Store Connect's current questionnaire.
Setline does not generate or prescribe a training programme. The bundled sample demonstrates exact-order workout playback. All active workout actions work offline. No HealthKit or sensor data is requested.

Device-only mode is available without creating an account. Optional cloud sync
currently authenticates with Google. Do not submit this build for App Review
until Sign in with Apple is implemented and configured as an equivalent login
option, or the Google login is removed; neither exception in App Review
Guideline 4.8 applies to this primary account flow.
offers Sign in with Apple beside Google. Existing Google users explicitly add
Apple while authenticated; matching email text does not silently merge
identities. The Apple token is nonce-bound and validated by the service for
`com.significanthobbies.setline`.

## Screenshots and release

Expand Down
4 changes: 4 additions & 0 deletions ios/Setline.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
15268979096821BD9ABB22E0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
27A4007C0EBCEA2C015905F8 /* NativeAccountClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAccountClient.swift; sourceTree = "<group>"; };
3C2CB1821B5B5AE4C75873C6 /* Design.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Design.swift; sourceTree = "<group>"; };
40CADB4F69054A14B366A16D /* Setline.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Setline.entitlements; sourceTree = "<group>"; };
594DC48A6CD7B68259549A12 /* Progression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Progression.swift; sourceTree = "<group>"; };
608F81A574360691A58B8581 /* SetlineApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetlineApp.swift; sourceTree = "<group>"; };
65E8358BE39000A0F7E1BB90 /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -138,6 +139,7 @@
27A4007C0EBCEA2C015905F8 /* NativeAccountClient.swift */,
824EFFB1023C857CE4F1FE1C /* RootView.swift */,
DFAE6EB21BC26F97140F4FEE /* SecondaryViews.swift */,
40CADB4F69054A14B366A16D /* Setline.entitlements */,
608F81A574360691A58B8581 /* SetlineApp.swift */,
818A8EDA05B5D0B4E9DE47B3 /* WorkoutPlayerView.swift */,
1DB949AB54ED01CA9EBB90BB /* Resources */,
Expand Down Expand Up @@ -463,6 +465,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Sources/Setline/Setline.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
INFOPLIST_FILE = Sources/Setline/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
Expand Down Expand Up @@ -504,6 +507,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Sources/Setline/Setline.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
INFOPLIST_FILE = Sources/Setline/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
Expand Down
27 changes: 23 additions & 4 deletions ios/Sources/Setline/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ final class AppModel {
defer { isLoading = false }
do {
if ProcessInfo.processInfo.arguments.contains("--fresh-demo") {
document = .sample
var sample = SetlineDocument.sample
sample.programme = nil
document = sample
} else {
document = try await store.load()
}
Expand All @@ -62,11 +64,11 @@ final class AppModel {
isWorkoutPresented = true
}
if ProcessInfo.processInfo.arguments.contains("--account-demo") {
account = SetlineAccount(name: "Sarthak", email: "sarthak@example.com")
account = SetlineAccount(name: "Sarthak", email: "sarthak@example.com", providers: ["google"])
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")
account = SetlineAccount(name: "Sarthak", email: "sarthak@example.com", providers: ["google"])
document.syncState = .conflict
var accountDocument = document
if let template = accountDocument.templates.first {
Expand All @@ -84,7 +86,7 @@ final class AppModel {
document: SetlineCloudDocument(document: accountDocument),
revision: 3
)
} else {
} else if !ProcessInfo.processInfo.arguments.contains("--fresh-demo") {
await restoreAccount()
}
} catch {
Expand Down Expand Up @@ -232,6 +234,23 @@ final class AppModel {
}
}

func completeAppleSignIn(_ payload: AppleIdentityPayload) async {
isAccountBusy = true
accountMessage = nil
defer { isAccountBusy = false }
do {
if let account, !account.hasApple {
self.account = try await accountClient.linkApple(payload)
accountMessage = "Apple sign-in added to this Setline account."
} else {
account = try await accountClient.signInWithApple(payload)
}
try await reconcileAccountCopy()
} catch {
accountMessage = friendlyMessage(for: error)
}
}

func syncNow() async {
guard account != nil else { return }
if let deferredConflict {
Expand Down
81 changes: 80 additions & 1 deletion ios/Sources/Setline/NativeAccountClient.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
import AuthenticationServices
import CryptoKit
import Foundation
import Security
import SetlineCore
import UIKit

struct AppleIdentityPayload: Sendable {
let identityToken: String
let nonce: String
let email: String?
let firstName: String?
let lastName: String?
}

struct SetlineAccount: Equatable, Sendable {
let name: String
let email: String
let providers: Set<String>

var hasApple: Bool { providers.contains("apple") }
}

enum NativeAccountError: LocalizedError {
Expand Down Expand Up @@ -142,6 +154,20 @@ actor SetlineNativeAccountClient {
return try await account()
}

func signInWithApple(_ payload: AppleIdentityPayload) async throws -> SetlineAccount {
let response = try await appleRequest(path: "/api/auth/sign-in/social", payload: payload)
guard let token = response.response.value(forHTTPHeaderField: "set-auth-token") else {
throw NativeAccountError.missingSession
}
try await sessionStore.save(token)
return try await account()
}

func linkApple(_ payload: AppleIdentityPayload) async throws -> SetlineAccount {
_ = try await appleRequest(path: "/api/auth/link-social", payload: payload, authenticated: true)
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
Expand Down Expand Up @@ -170,10 +196,51 @@ actor SetlineNativeAccountClient {
try await sessionStore.delete()
}

private func appleRequest(
path: String,
payload: AppleIdentityPayload,
authenticated: Bool = false
) async throws -> NetworkResponse {
var idToken: [String: Any] = ["token": payload.identityToken, "nonce": payload.nonce]
if path.hasSuffix("sign-in/social") {
var user: [String: Any] = [:]
if let email = payload.email { user["email"] = email }
var name: [String: String] = [:]
if let firstName = payload.firstName { name["firstName"] = firstName }
if let lastName = payload.lastName { name["lastName"] = lastName }
if !name.isEmpty { user["name"] = name }
if !user.isEmpty { idToken["user"] = user }
}
return try await request(
path: path,
jsonBody: ["provider": "apple", "idToken": idToken],
authenticated: authenticated
)
}

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)
let accountsResponse = try await request(path: "/api/auth/list-accounts", method: "GET")
let accounts = try decoder.decode([ProviderAccount].self, from: accountsResponse.data)
return SetlineAccount(
name: session.user.name,
email: session.user.email,
providers: Set(accounts.map(\.providerId))
)
}

private func request(
path: String,
jsonBody: [String: Any],
authenticated: Bool
) async throws -> NetworkResponse {
try await request(
path: path,
method: "POST",
data: try JSONSerialization.data(withJSONObject: jsonBody),
authenticated: authenticated
)
}

private func request(
Expand Down Expand Up @@ -254,6 +321,17 @@ actor SetlineNativeAccountClient {
}
}

enum AppleNonce {
static func make() -> String {
let alphabet = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._")
return String((0..<32).map { _ in alphabet.randomElement()! })
}

static func digest(_ nonce: String) -> String {
SHA256.hash(data: Data(nonce.utf8)).map { String(format: "%02x", $0) }.joined()
}
}

@MainActor
final class SetlineWebAuthenticator: NSObject, ASWebAuthenticationPresentationContextProviding {
private var session: ASWebAuthenticationSession?
Expand Down Expand Up @@ -301,6 +379,7 @@ final class SetlineWebAuthenticator: NSObject, ASWebAuthenticationPresentationCo
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 ProviderAccount: Decodable { let providerId: String }
private struct ErrorResponse: Decodable { let message: String }
private struct StateResponse: Decodable { let state: SetlineCloudSnapshot? }
private struct StateWrite: Encodable {
Expand Down
44 changes: 44 additions & 0 deletions ios/Sources/Setline/SecondaryViews.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AuthenticationServices
import SetlineCore
import SwiftUI
import UniformTypeIdentifiers
Expand Down Expand Up @@ -363,9 +364,11 @@ private struct SessionDetailView: View {

struct SettingsView: View {
@Environment(AppModel.self) private var model
@Environment(\.colorScheme) private var colorScheme
@State private var isImporterPresented = false
@State private var showResetConfirmation = false
@State private var showDeleteAccountConfirmation = false
@State private var appleNonce = AppleNonce.make()

var body: some View {
@Bindable var model = model
Expand Down Expand Up @@ -403,6 +406,7 @@ struct SettingsView: View {
}
.buttonStyle(.borderedProminent)
.tint(SetlinePalette.ink)
appleAccountButton
} else {
if let lastSync = model.document.lastSyncedAt {
LabeledContent("Last synced") {
Expand All @@ -416,6 +420,12 @@ struct SettingsView: View {
}
.buttonStyle(.borderedProminent)
.tint(SetlinePalette.ink)
if model.account?.hasApple == false {
Text("Add Apple to this account so future Apple sign-ins open the same private workout copy.")
.font(.footnote)
.foregroundStyle(.secondary)
appleAccountButton
}
Button { Task { await model.signOut() } } label: {
Label("Sign out", systemImage: "rectangle.portrait.and.arrow.right")
.frame(maxWidth: .infinity, alignment: .leading)
Expand Down Expand Up @@ -535,6 +545,40 @@ struct SettingsView: View {
}
}

private var appleAccountButton: some View {
SignInWithAppleButton(.continue) { request in
appleNonce = AppleNonce.make()
request.requestedScopes = [.fullName, .email]
request.nonce = AppleNonce.digest(appleNonce)
} onCompletion: { result in
guard
case let .success(authorization) = result,
let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
let tokenData = credential.identityToken,
let token = String(data: tokenData, encoding: .utf8)
else {
if case let .failure(error) = result,
(error as? ASAuthorizationError)?.code != .canceled {
model.accountMessage = error.localizedDescription
}
return
}
let payload = AppleIdentityPayload(
identityToken: token,
nonce: appleNonce,
email: credential.email,
firstName: credential.fullName?.givenName,
lastName: credential.fullName?.familyName
)
Task { await model.completeAppleSignIn(payload) }
}
.signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black)
.frame(maxWidth: .infinity, minHeight: 48)
.clipShape(RoundedRectangle(cornerRadius: 10))
.accessibilityIdentifier("apple-account-button")
.disabled(model.isAccountBusy)
}

private func settingsSection<Content: View>(_ title: String, @ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 10) {
SectionLabel(text: title)
Expand Down
10 changes: 10 additions & 0 deletions ios/Sources/Setline/Setline.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!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>
</dict>
</plist>
10 changes: 10 additions & 0 deletions ios/Tests/SetlineUITests/SetlineUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,14 @@ final class SetlineUITests: XCTestCase {
XCTAssertTrue(app.textFields["Name"].exists)
XCTAssertTrue(app.buttons["Add exercise"].exists)
}

func testAccountScreenOffersAppleAlongsideGoogle() {
let app = XCUIApplication()
app.launchArguments = ["--fresh-demo"]
app.launch()

app.tabBars.buttons["You"].tap()
XCTAssertTrue(app.buttons["Connect Google account"].waitForExistence(timeout: 3))
XCTAssertTrue(app.buttons["apple-account-button"].exists)
}
}
1 change: 1 addition & 0 deletions ios/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ targets:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.significanthobbies.setline
PRODUCT_NAME: Setline
CODE_SIGN_ENTITLEMENTS: Sources/Setline/Setline.entitlements
TARGETED_DEVICE_FAMILY: "1"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
SUPPORTS_MACCATALYST: NO
Expand Down
4 changes: 4 additions & 0 deletions openspec/changes/add-native-ios-app/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ Domain types and operations live in `SetlineCore`; the app target owns SwiftUI c

Optional account connection uses `ASWebAuthenticationSession` and URLSession against existing Setline endpoints. Credentials are stored only in Keychain. The remote adapter exchanges versioned whole-state payloads and never performs per-set merging or silent template rewrites. No secrets or environment files are embedded.

### Apple identity is native and account linking is explicit

The iPhone app obtains an Apple identity token with AuthenticationServices and a SHA-256 nonce, then sends it directly to Better Auth for signature, issuer, audience, expiry, and nonce validation against `com.significanthobbies.setline`. A new Apple identity may create an account. An existing Google account can add Apple only while already authenticated through an explicit link action. Matching email addresses never silently merge accounts, and a hidden Apple relay address is accepted when the owner deliberately links it.

### Preserve-mode visual adaptation

The native app inherits `DESIGN.md`: chalk/paper daylight surfaces, ink structure, lime execution signals, tabular numerals, Scoreboard Split hierarchy, sparse rounding, and one dominant action. Native navigation, sheets, focus, Dynamic Type, VoiceOver, and Reduce Motion behavior take precedence where platform conventions improve operation.
Expand Down
3 changes: 2 additions & 1 deletion openspec/changes/add-native-ios-app/proposal.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Setline is designed around one-handed workout execution, but its only maintained
- Match the current Setline product surface: schedule, workout playback, substitutions, rest timing, records, templates, history, analytics, settings, data transfer, and account controls.
- Keep active-workout state available offline and recover interrupted sessions without reordering authored exercises.
- Reuse existing Setline API and synchronization contracts where available; retain a useful local-only path when signed out.
- Offer Sign in with Apple beside Google, with explicit linking for an already connected account and no email-based implicit account merge.
- Add native tests, privacy metadata, app metadata, icons, simulator verification, and a signed archive workflow that stops before upload.
- Keep the web application intact and do not add a unified Fleet hub.

Expand All @@ -23,4 +24,4 @@ None.

## Impact

Adds an `ios/` Swift/Xcode surface beside the existing web application. Existing web routes, API behavior, data formats, dependencies, and production deployment remain unchanged. The iOS app uses the personal Apple development team for local signing and produces no App Store Connect records or uploads.
Adds an `ios/` Swift/Xcode surface beside the existing web application and extends the existing auth service with native Apple identity-token validation. Existing workout routes and data formats remain unchanged. The iOS app uses the personal Apple development team for signing and is prepared for a separately authorized TestFlight upload.
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ The iOS client SHALL support versioned whole-state export, preview-before-replac
- **WHEN** the user selects a compatible Setline export
- **THEN** the client validates and summarizes the replacement before requiring explicit confirmation

### Requirement: Native account access includes Sign in with Apple
The iOS client SHALL offer Sign in with Apple beside Google account connection. Apple identity tokens SHALL be verified by the service for the native bundle identifier. The service SHALL disable implicit email-based linking; an existing signed-in account MAY add Apple only through an explicit authenticated linking action.

#### Scenario: Existing Google user adds Apple
- **WHEN** an authenticated Google user chooses the Apple control and completes Apple's authorization
- **THEN** Apple is linked to that same account without replacing local workout data or inferring identity from matching email text

### Requirement: The native experience is accessible and honest
The iOS client SHALL support Dynamic Type, VoiceOver labels and values, Reduce Motion, sufficient contrast, 44-point targets, and status cues that do not rely on color alone. Unsupported sensor or health values SHALL be omitted or labelled unavailable rather than estimated.

Expand Down
Loading
Loading