Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
945b5c0
ios-native parity item 1: repair SDK session and stream ownership
AnthonyRonning Oct 13, 2026
9fbe51b
ios-native parity item 2: align PCR current and rollback roots
AnthonyRonning Oct 13, 2026
557035d
ios-native parity item 4: classify structured chat rejection codes
AnthonyRonning Oct 13, 2026
28fbd28
ios-native parity items 3 and 5: decode billing and align access rules
AnthonyRonning Oct 13, 2026
5938341
ios-native parity items 4 6 7 9 11: report chat and dictation outcomes
AnthonyRonning Oct 13, 2026
45074c0
ios-native parity items 14g and 18: persist sidebar and default speec…
AnthonyRonning Oct 13, 2026
99da4af
ios-native parity items 16 and 17: align email signup and legal footer
AnthonyRonning Oct 13, 2026
225a0ac
ios-native parity items 4 7-9 11-13: align chat presentation and comp…
AnthonyRonning Oct 13, 2026
198791a
ios-native parity item 10: poll generated titles with lifecycle cance…
AnthonyRonning Oct 13, 2026
f159e07
ios-native parity item 14: align library rows refresh and bulk behavior
AnthonyRonning Oct 13, 2026
8dff137
ios-native parity items 13 15 17 18: align model details and account …
AnthonyRonning Oct 13, 2026
307754c
ios-native parity validation: reveal settings rows and load retained …
AnthonyRonning Oct 13, 2026
136bb9b
fix(swift-sdk): preserve legacy plan errors and simplify cleanup
AnthonyRonning Oct 13, 2026
ee8e0d2
fix(ios): align model and media access with Research
AnthonyRonning Oct 13, 2026
6425bbb
fix(ios): preserve stopped output and recover options navigation
AnthonyRonning Oct 13, 2026
b84ba2d
fix(ios): default missing web search preferences on
AnthonyRonning Oct 13, 2026
09fcb33
fix(ios): reconcile native navigation after chat presentations dismiss
AnthonyRonning Oct 13, 2026
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
9 changes: 5 additions & 4 deletions apps/maple-ios/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,11 @@ Billing status, team seats and the authenticated model catalog have independent
loading/failure states. A missing or failed plan is never Free. Only a known
Free guest is restricted as a Free guest; read-only plan, quota and seat
messages do not provide purchasing or management actions. Quick/Powerful
remain server-owned aliases. Disabled, deprecated and non-chat models cannot
be selected, and unknown future access tiers remain unavailable. Web search
retains its explicit preference while its effective value also requires a
known paid plan and compatible selected model.
remain server-owned aliases; catalog failures keep the saved model sendable.
Disabled, deprecated and non-chat models cannot
be selected; every non-Free catalog tier requires a paid plan for selection.
Web search starts enabled and follows its saved preference, and read-aloud
blocks only a known Free plan; the server authorizes every request.

Billing uses a captured account SDK API to mint a third-party token for the
configured billing origin. It caches that token only in memory and remints
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public struct AccountModelChoice: Equatable, Sendable, Identifiable {
public private(set) var team: AccountLoadState<TeamStatus> = .unknown
public private(set) var catalog: AccountLoadState<ModelCatalog> = .unknown
public private(set) var selectedModelID: String
/// A gated preference is retained; effectiveWebSearch is the dispatch value.
/// The explicit preference is the dispatch value while this session is active.
public var webSearchPreferred: Bool
private var active = true
private var billingRequest = UUID()
Expand Down Expand Up @@ -179,13 +179,7 @@ public struct AccountModelChoice: Equatable, Sendable, Identifiable {

public func modelAccess(_ model: AccountModelChoice) -> AccountAccess {
guard active else { return .retired }
guard billing.value != nil else { return .unavailable }
guard plan != .unknown else { return .unknownPlan }
switch model.accessTier {
case "free": return .allowed
case "starter", "pro": return plan.isPaid ? .allowed : .paidPlanRequired
default: return .modelUnavailable
}
return model.accessTier == "free" ? .allowed : (plan.isPaid ? .allowed : .paidPlanRequired)
}

@discardableResult public func selectModel(_ id: String) -> AccountAccess {
Expand All @@ -199,47 +193,30 @@ public struct AccountModelChoice: Equatable, Sendable, Identifiable {

private var accountChatAccess: AccountAccess {
guard active else { return .retired }
guard let status = billing.value else { return .unavailable }
guard status.plan != .unknown else { return .unknownPlan }
if isGuest, status.plan == .free { return .freeGuest }
if team.value?.seatMismatch != nil { return .seatMismatch }
guard status.canChat, status.chatsRemaining != 0 else { return .usageLimit }
if isGuest, billing.value?.plan == .free { return .freeGuest }
return .allowed
}

public var chatAccess: AccountAccess {
let account = accountChatAccess
guard account == .allowed else { return account }
guard let selectedModel else { return .modelUnavailable }
return modelAccess(selectedModel)
// Catalog availability controls the picker, not sending an existing
// selection. The server owns model, plan and usage authorization.
return LocalPreferences.isValidModelID(selectedModelID) ? .allowed : .modelUnavailable
}

public var paidFeatureAccess: AccountAccess {
let chat = accountChatAccess
guard chat == .allowed else { return chat }
guard billing.value != nil else { return .unavailable }
guard plan != .unknown else { return .unknownPlan }
return plan.isPaid ? .allowed : .paidPlanRequired
}

public var webSearchAccess: AccountAccess {
let paid = paidFeatureAccess
guard paid == .allowed else { return paid }
guard catalog.value != nil else { return .unavailable }
guard let selectedModel else { return .modelUnavailable }
let model = modelAccess(selectedModel)
guard model == .allowed else { return model }
return selectedModel.capabilities.toolUse ? .allowed : .modelIncompatible
}
public var webSearchAccess: AccountAccess { active ? .allowed : .retired }

public var effectiveWebSearch: Bool { webSearchPreferred && webSearchAccess == .allowed }
public var imageAccess: AccountAccess {
let paid = paidFeatureAccess
guard paid == .allowed else { return paid }
guard catalog.value != nil else { return .unavailable }
guard let selectedModel else { return .modelUnavailable }
let model = modelAccess(selectedModel)
guard model == .allowed else { return model }
return selectedModel.capabilities.vision ? .allowed : .modelIncompatible
}
public var imageAccess: AccountAccess { paidFeatureAccess }

public var transcriptionAccess: AccountAccess {
let paid = paidFeatureAccess
Expand All @@ -256,11 +233,6 @@ public struct AccountModelChoice: Equatable, Sendable, Identifiable {
}
public var speechAccess: AccountAccess {
guard active else { return .retired }
guard let status = billing.value, status.plan != .unknown else { return .unavailable }
guard status.plan.isPaid else { return .paidPlanRequired }
let paid = paidFeatureAccess
guard paid == .allowed else { return paid }
guard let speech = catalog.value?.audio?.speech else { return .unavailable }
return speech.available ? .allowed : .modelIncompatible
return billing.value?.plan == .free ? .paidPlanRequired : .allowed
}
}
20 changes: 20 additions & 0 deletions apps/maple-ios/Sources/MapleAppCore/Account/BillingContracts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,23 @@ public enum BillingPlan: Equatable, Sendable {
}
}

public struct BillingConflict: Decodable, Equatable, Sendable {
public let otherProvider: String?
/// Inert server data. It must never be interpreted as a route or action.
public let action: String?
}

public struct BillingPendingPlanChange: Decodable, Equatable, Sendable {
public let id: String?
public let type: String?
public let targetPlanName: String?
public let status: String?
public let expiresAt: String?
}

public struct BillingStatus: Decodable, Equatable, Sendable {
public let isSubscribed: Bool
public let stripeCustomerId: String?
public let productId: String
public let productName: String?
public let subscriptionStatus: String?
Expand All @@ -76,7 +91,12 @@ public struct BillingStatus: Decodable, Equatable, Sendable {
public let totalTokens: Int?
public let usedTokens: Int?
public let usageResetDate: String?
public let apiCreditBalance: Double?
public let iosIapEnabled: Bool?
public let iosUsExternalLinkEnabled: Bool?
public let subscriptions: [BillingSubscription]?
public let conflict: BillingConflict?
public let pendingPlanChange: BillingPendingPlanChange?

public var plan: BillingPlan {
guard let name = productName?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else {
Expand Down
10 changes: 6 additions & 4 deletions apps/maple-ios/Sources/MapleAppCore/Auth/AuthCeremonies.swift
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ public enum AuthCeremonyIssue: Error, Sendable, Equatable {

public func confirmPasswordReset(code: String, newPassword: String, confirmation: String) async {
guard case .resetCode(let email) = state, let challenge, !retired else { return }
guard validCode(code), validPassword(newPassword), newPassword == confirmation else {
guard validCode(code), Self.validPassword(newPassword), newPassword == confirmation else {
issue = .invalidInput; return
}
await perform(recovering: .resetCode(email: email)) { [self] in
Expand Down Expand Up @@ -231,7 +231,7 @@ public enum AuthCeremonyIssue: Error, Sendable, Equatable {

public func changePassword(currentPassword: String, newPassword: String, confirmation: String) async {
guard mayStart, scope != nil else { return }
guard !currentPassword.isEmpty, validPassword(newPassword), newPassword == confirmation else {
guard !currentPassword.isEmpty, Self.validPassword(newPassword), newPassword == confirmation else {
issue = .invalidInput; return
}
await perform(recovering: .idle) { [self] in
Expand Down Expand Up @@ -479,7 +479,9 @@ public enum AuthCeremonyIssue: Error, Sendable, Equatable {
private func validCode(_ value: String) -> Bool {
value.utf8.count == 8 && value.utf8.allSatisfy { (48...57).contains($0) || (65...90).contains($0) }
}
private func validPassword(_ value: String) -> Bool { value.utf16.count >= 8 && value.utf8.count <= 16 * 1024 }
public nonisolated static func validPassword(_ value: String) -> Bool {
value.utf16.count >= 8 && value.utf8.count <= 16 * 1024
}

private var supportsBrowserCallback: Bool {
configuration.callbackScheme == "cloud.opensecret.maple"
Expand Down Expand Up @@ -650,7 +652,7 @@ public actor SDKAuthCeremonyService: AuthCeremonyServing {
public func cancelBrowser(id: UUID) async {
guard activeBrowserID == id else { return }
activeBrowserID = nil; prepared = nil; confirmation = nil
try? await client.transport.cancelNativeOAuthHandoff()
await client.transport.cancelNativeOAuthHandoff()
}

private func accountAPI(_ expected: CredentialSnapshot) async throws -> ResearchAPI {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public struct SDKSessionService: SessionServing {
_ = try await client.signInGuest(id: id, password: password)
}
public func signUp(email: String, password: String, name: String?) async throws {
_ = try await client.signUp(email: email, password: password, name: name)
_ = try await client.signUp(email: email, password: password, inviteCode: "", name: name)
}
public func signOut() async throws { try await client.signOut() }
public func signOut(ifOwnedBy identity: SessionIdentity?) async throws {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,11 @@ public enum SessionIssue: Sendable, Equatable {
await authenticate { try await self.service.signInGuest(id: id, password: password) }
}

public func signUp(email: String, password: String, name: String?) async {
await authenticate { try await self.service.signUp(email: email, password: password, name: name) }
public func signUp(email: String, password: String) async {
await authenticate {
guard AuthCeremonies.validPassword(password) else { throw ResearchAPIError.invalidArgument }
try await self.service.signUp(email: email, password: password, name: "ANON")
}
}

public func beginExternalAuthentication() -> UUID? {
Expand Down
4 changes: 3 additions & 1 deletion apps/maple-ios/Sources/MapleAppCore/Chat/ChatContracts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,9 @@ public struct ChatSendAcceptance: Sendable, Equatable {
public enum ChatIssue: String, Error, Sendable {
case offline, busy, emptyDraft, invalidImages, draftTooLarge, storageUnavailable
case conversationUnavailable, uncertainResponse, responseUnavailable, cancellationUnavailable
case historyUnavailable, requestRejected, contextLimit, usageLimit, modelUnavailable, signedOut
case historyUnavailable, requestRejected, contextLimit, freeTierTokenLimit, usageLimit, modelUnavailable, signedOut
case unsupportedValue, imageDescriptionUnavailable, inferenceCapacityUnavailable, generationFailed
case stoppedBeforeSending, stoppingWhenResponseReady, responseFinishedBeforeStop, recoveredCompletion, recoveredStop
case queueLimit, unfinishedQueueEdit, draftInUse
}

Expand Down
Loading