From 945b5c04049babcc333fabcc21a3ae270ffe54d3 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:46:10 +0000 Subject: [PATCH 01/17] ios-native parity item 1: repair SDK session and stream ownership --- .../Sources/MapleSDK/API/ResponsesAPI.swift | 15 +- .../Credentials/CredentialAuthority.swift | 50 +++++-- .../Transport/EncryptedTransport.swift | 25 ++-- .../MapleSDKTests/API/ResponsesAPITests.swift | 70 +++++++++ .../Transport/CredentialAuthorityTests.swift | 133 ++++++++++++++++++ .../Transport/EncryptedTransportTests.swift | 50 ++++++- .../Transport/TestSecureStore.swift | 9 +- 7 files changed, 315 insertions(+), 37 deletions(-) diff --git a/sdk/swift/Sources/MapleSDK/API/ResponsesAPI.swift b/sdk/swift/Sources/MapleSDK/API/ResponsesAPI.swift index 05f8a86fe..2327ed205 100644 --- a/sdk/swift/Sources/MapleSDK/API/ResponsesAPI.swift +++ b/sdk/swift/Sources/MapleSDK/API/ResponsesAPI.swift @@ -152,7 +152,8 @@ public enum ResponseStreamEvent: Sendable, Equatable { } extension ResearchAPI { - /// The producer is bounded, cancellation cancels the network consumer, and a + /// Event buffering preserves every event across consumer stalls; SSE event + /// sizes remain bounded and cancellation cancels the network consumer. A /// terminal event is withheld until authenticated transport EOF is verified. public func createResponse( _ request: ResponsesCreateRequest, @@ -174,22 +175,16 @@ extension ResearchAPI { else { throw ResearchAPIError.invalidResponse } - return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(256)) { continuation in + return AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in let task = Task { do { var decoder = ResponsesSSEDecoder() for try await chunk in response.body { try Task.checkCancellation() - for event in try decoder.append(chunk) { - if case .dropped = continuation.yield(event) { - throw ResearchAPIError.streamConsumerTooSlow - } - } + for event in try decoder.append(chunk) { continuation.yield(event) } } try Task.checkCancellation() - for event in try decoder.finish() { - if case .dropped = continuation.yield(event) { throw ResearchAPIError.streamConsumerTooSlow } - } + for event in try decoder.finish() { continuation.yield(event) } continuation.finish() } catch { continuation.finish(throwing: error) } } diff --git a/sdk/swift/Sources/MapleSDK/Credentials/CredentialAuthority.swift b/sdk/swift/Sources/MapleSDK/Credentials/CredentialAuthority.swift index 7cf64175d..9a3c1219e 100644 --- a/sdk/swift/Sources/MapleSDK/Credentials/CredentialAuthority.swift +++ b/sdk/swift/Sources/MapleSDK/Credentials/CredentialAuthority.swift @@ -128,7 +128,7 @@ public actor CredentialAuthority { private var state: State private var cleanupFailed = false private var observers: [UUID: AsyncStream.Continuation] = [:] - private var refreshing: (snapshot: CredentialSnapshot, task: Task)? + private var refreshing: (id: UUID, snapshot: CredentialSnapshot, task: Task)? private struct State: Codable { var version = 1 @@ -145,17 +145,25 @@ public actor CredentialAuthority { self.store = store storageKey = "authority:" + apiOrigin if let data = try store.read(key: storageKey) { - guard data.count <= 96 * 1024, let saved = try? JSONDecoder().decode(State.self, from: data), - saved.version == 1, saved.origin == apiOrigin, - saved.cacheRoot == nil || saved.cacheRoot?.count == 32, - (saved.access == nil) == (saved.refresh == nil) - else { throw TransportError.invalidCredentials } - if let access = saved.access, let refresh = saved.refresh { - _ = try CredentialPair(access: access, refresh: refresh) + do { + guard data.count <= 96 * 1024, let saved = try? JSONDecoder().decode(State.self, from: data), + saved.version == 1, saved.origin == apiOrigin, + saved.cacheRoot == nil || saved.cacheRoot?.count == 32, + (saved.access == nil) == (saved.refresh == nil) + else { throw TransportError.invalidCredentials } + if let access = saved.access, let refresh = saved.refresh { + _ = try CredentialPair(access: access, refresh: refresh) + } + state = saved + // A new process never adopts another process's in-flight ownership leases. + state.generation = UUID() + } catch { + // A malformed origin has no usable authority. Subscribers receive + // this new signed-out generation, or a cleanup requirement if the + // corrupt durable state could not be removed. + state = State(origin: apiOrigin) + do { try store.remove(key: storageKey) } catch { cleanupFailed = true } } - state = saved - // A new process never adopts another process's in-flight ownership leases. - state.generation = UUID() } else { state = State(origin: apiOrigin) } @@ -289,12 +297,21 @@ public actor CredentialAuthority { if state.revision != expected.revision { return } if let pending = refreshing, pending.snapshot == expected { try await pending.task.value + try Task.checkCancellation() return } - let task = Task { try await operation() } - refreshing = (expected, task) - defer { if refreshing?.snapshot == expected { refreshing = nil } } + let id = UUID() + let task = Task { + defer { finishRefresh(id: id) } + try await operation() + } + refreshing = (id, expected, task) try await task.value + try Task.checkCancellation() + } + + private func finishRefresh(id: UUID) { + if refreshing?.id == id { refreshing = nil } } func cacheRoot() throws -> Data { @@ -303,7 +320,10 @@ public actor CredentialAuthority { var updated = state let root = try TransportCrypto.random(count: 32) updated.cacheRoot = root - try commit(updated) + // The namespace can remain process-local when its cache cannot be saved. + // Credential replacement and revocation still require durable commits. + state = updated + try? commit(updated) return root } } diff --git a/sdk/swift/Sources/MapleSDK/Transport/EncryptedTransport.swift b/sdk/swift/Sources/MapleSDK/Transport/EncryptedTransport.swift index 6a2fa0a16..5e138ae6c 100644 --- a/sdk/swift/Sources/MapleSDK/Transport/EncryptedTransport.swift +++ b/sdk/swift/Sources/MapleSDK/Transport/EncryptedTransport.swift @@ -28,6 +28,7 @@ public actor EncryptedTransport: OAuthSessionTransport { private let store: any SecureStore private let now: @Sendable () -> Date private var session: AttestedSession? + private var restoreSessionCache = true private var establishing: (id: UUID, task: Task)? private var oauthBindings: [UUID: (AttestedSession, CredentialSnapshot, String)] = [:] private var nativeBindings: [UUID: (AttestedSession, CredentialSnapshot, Data, Date)] = [:] @@ -69,13 +70,15 @@ public actor EncryptedTransport: OAuthSessionTransport { try Task.checkCancellation() return value } - if let data = try store.read(key: sessionStorageKey) { + let cached = restoreSessionCache ? try? store.read(key: sessionStorageKey) : nil + restoreSessionCache = false + if let data = cached { if data.count <= 16 * 1024, let restored = try? JSONDecoder().decode(AttestedSession.self, from: data), (try? restored.validate(endpoint: endpoint, policy: policy, now: now())) != nil { session = restored; return restored } - try store.remove(key: sessionStorageKey) + try? store.remove(key: sessionStorageKey) } let id = UUID() let task = Task { [endpoint, policy, network, now] in @@ -95,7 +98,9 @@ public actor EncryptedTransport: OAuthSessionTransport { do { let established = try await task.value guard establishing?.id == id else { throw TransportError.expiredSession } - try store.write(JSONEncoder().encode(established), key: sessionStorageKey) + // Attestation remains authoritative; a cache failure cannot discard + // a verified session that is usable in this process. + try? store.write(JSONEncoder().encode(established), key: sessionStorageKey) session = established; establishing = nil try Task.checkCancellation() return established @@ -169,8 +174,11 @@ public actor EncryptedTransport: OAuthSessionTransport { public func invalidateSession() throws { establishing?.task.cancel(); establishing = nil; session = nil + // Never restore a retired session in this process, even if deleting its + // best-effort durable cache fails. + restoreSessionCache = false oauthBindings.removeAll(); nativeBindings.removeAll() - try store.remove(key: sessionStorageKey) + try? store.remove(key: sessionStorageKey) } private func retire(_ old: AttestedSession) throws { @@ -246,10 +254,9 @@ public actor EncryptedTransport: OAuthSessionTransport { else { throw TransportError.invalidEnvelope } let snapshot = try await credentials.snapshot() guard snapshot.principalId == nil else { throw TransportError.authorityChanged } - let root = try await credentials.cacheRoot() let current = try await ensureSession() let response = try await dispatch( - request, credential: nil, cacheRoot: root, snapshot: snapshot, + request, credential: nil, cacheRoot: nil, snapshot: snapshot, session: current, requestID: TransportCrypto.random(count: 16), inferenceSendCount: 0) try await credentials.assertCurrent(snapshot) guard session?.identity == current.identity else { throw TransportError.expiredSession } @@ -271,10 +278,9 @@ public actor EncryptedTransport: OAuthSessionTransport { else { throw TransportError.invalidSession } - let root = try await credentials.cacheRoot() do { let response = try await dispatch( - request, credential: nil, cacheRoot: root, snapshot: snapshot, + request, credential: nil, cacheRoot: nil, snapshot: snapshot, session: retained, requestID: TransportCrypto.random(count: 16), inferenceSendCount: 0) var data = Data() for try await chunk in response.body { @@ -297,7 +303,6 @@ public actor EncryptedTransport: OAuthSessionTransport { public func prepareNativeOAuthHandoff() async throws -> PreparedNativeOAuthHandoff { let snapshot = try await credentials.snapshot() guard snapshot.principalId == nil else { throw TransportError.authorityChanged } - _ = try await credentials.cacheRoot() let current = try await ensureSession() try await credentials.assertCurrent(snapshot) guard session?.identity == current.identity, current.expiresAt > now() else { @@ -421,7 +426,7 @@ public actor EncryptedTransport: OAuthSessionTransport { limit = min(limit, headerLimit) } guard !inference || (1...2).contains(limit) else { throw TransportError.invalidEnvelope } - let root = try await credentials.cacheRoot() + let root = credential == nil ? nil : try await credentials.cacheRoot() var current = try await ensureSession() for attempt in 1...2 { do { diff --git a/sdk/swift/Tests/MapleSDKTests/API/ResponsesAPITests.swift b/sdk/swift/Tests/MapleSDKTests/API/ResponsesAPITests.swift index e9937f9ab..55926f082 100644 --- a/sdk/swift/Tests/MapleSDKTests/API/ResponsesAPITests.swift +++ b/sdk/swift/Tests/MapleSDKTests/API/ResponsesAPITests.swift @@ -164,6 +164,43 @@ private struct FixtureStreamTransport: MapleTransport { func send(_ request: MapleRequest) async throws -> MapleResponse { response } } +private actor StalledResponseFixture { + private let chunks: [Data] + private var index = 0 + private(set) var reads = 0 + init(_ chunks: [Data]) { self.chunks = chunks } + func next() -> Data? { + reads += 1 + guard index < chunks.count else { return nil } + defer { index += 1 } + return chunks[index] + } +} + +@Test func stalledResponseConsumerPreservesEveryEventAndTerminal() async throws { + let chunks = + Array(repeating: Data(sse("response.output_text.delta", delta).utf8), count: 600) + + [Data(sse("response.completed", lifecycle("response.completed", status: "completed")).utf8)] + let source = StalledResponseFixture(chunks) + let api = ResearchAPI( + transport: FixtureStreamTransport( + response: MapleResponse( + status: 200, headers: ["content-type": "text/event-stream"], + body: AsyncThrowingStream(unfolding: { await source.next() })))) + let events = try await api.createResponse(.init(input: .text("Synthetic"), conversation: "c")) + // Allow more than the old 256-event capacity to accumulate before reading. + try await Task.sleep(for: .milliseconds(30)) + var iterator = events.makeAsyncIterator() + #expect(try await iterator.next()?.isTerminal == false) + try await Task.sleep(for: .milliseconds(30)) + var deltas = 1, terminal = 0 + while let event = try await iterator.next() { + if event.isTerminal { terminal += 1 } else { deltas += 1 } + } + #expect(deltas == 600 && terminal == 1) + #expect(await source.reads == chunks.count + 1) +} + @Test func authenticatedTunnelFailureAfterTerminalIsNotSuccess() async throws { let fixture = sse("response.completed", lifecycle("response.completed", status: "completed")) let body = AsyncThrowingStream { continuation in @@ -187,6 +224,8 @@ private struct FixtureStreamTransport: MapleTransport { private actor StreamCancellationProbe { private var wasCancelled = false private var waiter: CheckedContinuation? + private var consumed = false + private var consumedWaiter: CheckedContinuation? func cancel() { wasCancelled = true waiter?.resume() @@ -196,6 +235,14 @@ private actor StreamCancellationProbe { if wasCancelled { return } await withCheckedContinuation { waiter = $0 } } + func didConsume() { + consumed = true + consumedWaiter?.resume(); consumedWaiter = nil + } + func waitUntilConsumed() async { + if consumed { return } + await withCheckedContinuation { consumedWaiter = $0 } + } } @Test func responseConsumerCancellationCancelsUpstream() async throws { @@ -217,3 +264,26 @@ private actor StreamCancellationProbe { _ = await consumer.result await probe.wait() } + +@Test(arguments: [1, 600]) +func responseCancellationAfterFirstChunkCancelsBufferedUpstream(eventCount: Int) async throws { + let probe = StreamCancellationProbe() + let body = AsyncThrowingStream { continuation in + continuation.onTermination = { @Sendable _ in Task { await probe.cancel() } } + continuation.yield(Data(String(repeating: sse("response.output_text.delta", delta), count: eventCount).utf8)) + } + let api = ResearchAPI( + transport: FixtureStreamTransport( + response: MapleResponse(status: 200, headers: ["content-type": "text/event-stream"], body: body))) + let consumer = Task { + let events = try await api.createResponse(.init(input: .text("Synthetic"), conversation: "c")) + for try await _ in events { + await probe.didConsume() + try await Task.sleep(for: .seconds(60)) + } + } + await probe.waitUntilConsumed() + consumer.cancel() + _ = await consumer.result + await probe.wait() +} diff --git a/sdk/swift/Tests/MapleSDKTests/Transport/CredentialAuthorityTests.swift b/sdk/swift/Tests/MapleSDKTests/Transport/CredentialAuthorityTests.swift index bf7e6fcc4..b29bda7ad 100644 --- a/sdk/swift/Tests/MapleSDKTests/Transport/CredentialAuthorityTests.swift +++ b/sdk/swift/Tests/MapleSDKTests/Transport/CredentialAuthorityTests.swift @@ -81,6 +81,119 @@ import Testing #expect(try await other.cacheRoot() != root) } + @Test func corruptPersistedAuthorityResetsOnlyItsOriginAndPublishesSignedOutState() async throws { + let origin = "https://example.test", key = "authority:" + origin + let access = try fixtureJWT(principal: "a", kind: "access") + let refresh = try fixtureJWT(principal: "a", kind: "refresh") + let valid: [String: Any] = [ + "version": 1, "origin": origin, "revision": 3, "generation": UUID().uuidString, + "access": access, "refresh": refresh, + ] + let invalid: [[String: Any]] = [ + valid.merging(["version": 2]) { _, new in new }, + valid.merging(["origin": "https://other.test"]) { _, new in new }, + valid.merging(["refresh": NSNull()]) { _, new in new }, + valid.merging(["cacheRoot": Data(repeating: 1, count: 31).base64EncodedString()]) { _, new in new }, + valid.merging(["access": "malformed-jwt"]) { _, new in new }, + valid.merging(["refresh": try fixtureJWT(principal: "b", kind: "refresh")]) { _, new in new }, + ] + let blobs = + [Data("malformed JSON".utf8), Data(repeating: 32, count: 96 * 1_024 + 1)] + + (try invalid.map { try JSONSerialization.data(withJSONObject: $0) }) + for blob in blobs { + let store = TestSecureStore() + try store.write(blob, key: key) + try store.write(Data("untouched".utf8), key: "authority:https://other.test") + let client = try MapleClient( + apiURL: origin, clientID: "00000000-0000-0000-0000-000000000001", + policy: PCRTrustPolicy(environment: .production), store: store) + let snapshot = try await client.credentials.snapshot() + #expect(snapshot.principalId == nil && snapshot.revision == 0) + var changes = await client.credentials.changes().makeAsyncIterator() + let change = await changes.next() + #expect(change?.generation == snapshot.generation && change?.requiresCleanup == false) + #expect(try store.read(key: key) == nil) + #expect(try store.read(key: "authority:https://other.test") == Data("untouched".utf8)) + _ = try await client.credentials.install(accessToken: access, refreshToken: refresh, expected: snapshot) + #expect(try await client.credentials.authenticatedUserId() == "a") + } + } + + @Test func corruptAuthorityCleanupFailureConstructsButBlocksUseUntilDurableCleanup() async throws { + let store = TestSecureStore() + try store.write(Data("malformed JSON".utf8), key: "authority:https://example.test") + store.setWriteFailure(true) + let authority = try CredentialAuthority(apiURL: "https://example.test", store: store) + var changes = await authority.changes().makeAsyncIterator() + let initial = await changes.next() + #expect(initial?.requiresCleanup == true) + await #expect(throws: TransportError.persistence) { try await authority.snapshot() } + await #expect(throws: TransportError.persistence) { try await authority.completePendingCleanup() } + store.setWriteFailure(false) + try await authority.completePendingCleanup() + let repaired = await changes.next() + #expect(repaired?.generation == initial?.generation && repaired?.requiresCleanup == false) + #expect(try await authority.authenticatedUserId() == nil) + let restored = try CredentialAuthority(apiURL: "https://example.test", store: store) + #expect(try await restored.authenticatedUserId() == nil) + } + + @Test func unreadableCredentialAuthorityStillFailsClosed() throws { + let store = TestSecureStore() + store.setReadFailure(true) + #expect(throws: TransportError.persistence) { + try CredentialAuthority(apiURL: "https://example.test", store: store) + } + } + + @Test func cancelledRefreshCallerKeepsSharedRotationAliveForOtherCallers() async throws { + let authority = try CredentialAuthority(apiURL: "https://example.test", store: TestSecureStore()) + let access = try fixtureJWT(principal: "a", kind: "access") + let refresh = try fixtureJWT(principal: "a", kind: "refresh") + let expected = try await authority.install( + accessToken: access, refreshToken: refresh, expected: authority.snapshot()) + let gate = RefreshSuspension() + let operation: @Sendable () async throws -> Void = { + await gate.run() + _ = try await authority.install( + accessToken: access, refreshToken: refresh, expected: expected, refreshing: true) + } + let caller = Task { try await authority.coalescedRefresh(expected: expected, operation: operation) } + await gate.waitUntilStarted() + caller.cancel() + let follower = Task { try await authority.coalescedRefresh(expected: expected, operation: operation) } + // The shared operation remains suspended while the other waiter joins. + try await Task.sleep(for: .milliseconds(30)) + #expect(await gate.runs == 1) + await gate.release() + await #expect(throws: CancellationError.self) { try await caller.value } + try await follower.value + let renewed = try await authority.snapshot() + #expect(renewed.generation == expected.generation && renewed.revision == expected.revision + 1) + #expect(await gate.runs == 1) + } + + @Test func sharedRefreshCannotInstallAfterSameAccountReplacement() async throws { + let authority = try CredentialAuthority(apiURL: "https://example.test", store: TestSecureStore()) + let access = try fixtureJWT(principal: "a", kind: "access") + let refresh = try fixtureJWT(principal: "a", kind: "refresh") + let expected = try await authority.install( + accessToken: access, refreshToken: refresh, expected: authority.snapshot()) + let gate = RefreshSuspension() + let pending = Task { + try await authority.coalescedRefresh(expected: expected) { + await gate.run() + _ = try await authority.install( + accessToken: access, refreshToken: refresh, expected: expected, refreshing: true) + } + } + await gate.waitUntilStarted() + let replacement = try await authority.install(accessToken: access, refreshToken: refresh, expected: expected) + await gate.release() + await #expect(throws: TransportError.authorityChanged) { try await pending.value } + #expect(try await authority.snapshot() == replacement) + } + @Test func endpointRejectsUnsafeOrigins() throws { for value in [ "http://example.test", "https://user@example.test", "https://example.test?token=x", @@ -92,3 +205,23 @@ import Testing #expect(try APIEndpoint("http://[::1]:3000").isLoopback) } } + +private actor RefreshSuspension { + private(set) var runs = 0 + private var started: CheckedContinuation? + private var waiting: [CheckedContinuation] = [] + + func run() async { + runs += 1 + started?.resume(); started = nil + await withCheckedContinuation { waiting.append($0) } + } + func waitUntilStarted() async { + if runs > 0 { return } + await withCheckedContinuation { started = $0 } + } + func release() { + for continuation in waiting { continuation.resume() } + waiting.removeAll() + } +} diff --git a/sdk/swift/Tests/MapleSDKTests/Transport/EncryptedTransportTests.swift b/sdk/swift/Tests/MapleSDKTests/Transport/EncryptedTransportTests.swift index 4b6df77dd..81efac862 100644 --- a/sdk/swift/Tests/MapleSDKTests/Transport/EncryptedTransportTests.swift +++ b/sdk/swift/Tests/MapleSDKTests/Transport/EncryptedTransportTests.swift @@ -48,10 +48,58 @@ import Testing #expect(await network.handshakes == 1) let seen = await network.observations #expect(seen.count == 2 && seen[0].requestID != seen[1].requestID) - #expect(seen[0].cacheRoot == seen[1].cacheRoot && seen[0].cacheRoot != nil) + #expect(seen.allSatisfy { $0.cacheRoot == nil && $0.credentialKind == nil }) #expect(!seen[0].bodyPresent) } + @Test func anonymousOAuthRequestsNeverCreateOrAttachCacheRoot() async throws { + let network = FixtureNetwork(), store = TestSecureStore() + let transport = try await make(network, store: store) + let (response, binding) = try await transport.beginOAuth( + MapleRequest(method: .post, target: "/auth/google", auth: .none)) + _ = try await collect(response) + _ = try await collect( + transport.completeOAuth( + MapleRequest(method: .post, target: "/auth/google/callback", auth: .none), binding: binding)) + #expect(await network.observations.map(\.target) == ["/auth/google", "/auth/google/callback"]) + #expect(await network.observations.allSatisfy { $0.cacheRoot == nil && $0.credentialKind == nil }) + #expect(try store.read(key: "authority:http://127.0.0.1:3000") == nil) + } + + @Test func cacheWriteFailuresKeepVerifiedSessionAndAuthenticatedNamespaceInMemory() async throws { + let network = FixtureNetwork(), store = TestSecureStore() + let transport = try await make(network, store: store, signedIn: true) + let original = try await transport.credentials.snapshot() + store.setWriteFailure(true) + let authenticated = MapleRequest(method: .get, target: "/protected/user", expectedPrincipalId: "a") + _ = try await collect(transport.send(authenticated)) + _ = try await collect(transport.send(authenticated)) + #expect(await network.handshakes == 1) + let seen = await network.observations + #expect(seen.count == 2 && seen.allSatisfy { $0.credentialKind == "bearer" }) + #expect(seen[0].cacheRoot != nil && seen[0].cacheRoot == seen[1].cacheRoot) + #expect(try await transport.credentials.snapshot() == original) + await #expect(throws: TransportError.persistence) { + try await transport.credentials.install( + accessToken: fixtureJWT(principal: "b", kind: "access"), + refreshToken: fixtureJWT(principal: "b", kind: "refresh"), expected: original) + } + #expect(try await transport.credentials.snapshot() == original) + } + + @Test func sessionCacheReadAndRemovalFailuresDoNotBlockOrRestoreRetiredSession() async throws { + let network = FixtureNetwork(), store = TestSecureStore() + let transport = try await make(network, store: store) + store.setReadFailure(true) + _ = try await collect(transport.send(request)) + store.setReadFailure(false) + store.setWriteFailure(true) + try await transport.invalidateSession() + _ = try await collect(transport.send(request)) + #expect(await network.handshakes == 2) + #expect(await network.observations.count == 2) + } + @Test func onlyExactRecoveryHintAllowsOneResend() async throws { let network = FixtureNetwork([.hint]), transport = try await make(network) _ = try await collect(transport.send(request)) diff --git a/sdk/swift/Tests/MapleSDKTests/Transport/TestSecureStore.swift b/sdk/swift/Tests/MapleSDKTests/Transport/TestSecureStore.swift index c811a099a..6db15c86e 100644 --- a/sdk/swift/Tests/MapleSDKTests/Transport/TestSecureStore.swift +++ b/sdk/swift/Tests/MapleSDKTests/Transport/TestSecureStore.swift @@ -5,8 +5,15 @@ final class TestSecureStore: SecureStore, @unchecked Sendable { private let lock = NSLock() private var values: [String: Data] = [:] private var failWrites = false + private var failReads = false func setWriteFailure(_ fail: Bool) { lock.withLock { failWrites = fail } } - func read(key: String) throws -> Data? { lock.withLock { values[key] } } + func setReadFailure(_ fail: Bool) { lock.withLock { failReads = fail } } + func read(key: String) throws -> Data? { + try lock.withLock { + if failReads { throw TransportError.persistence } + return values[key] + } + } func write(_ data: Data, key: String) throws { try lock.withLock { if failWrites { throw TransportError.persistence } From 9fbe51b6be450189ae8487f64e0f8e560e91f12c Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:46:10 +0000 Subject: [PATCH 02/17] ios-native parity item 2: align PCR current and rollback roots --- .../MapleSDK/Trust/PCRTrustRoots.swift | 28 ++++++------------- .../MapleSDKTests/Trust/PCRTrustTests.swift | 13 +++++++++ 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/sdk/swift/Sources/MapleSDK/Trust/PCRTrustRoots.swift b/sdk/swift/Sources/MapleSDK/Trust/PCRTrustRoots.swift index 69a2c0c05..1a46130f1 100644 --- a/sdk/swift/Sources/MapleSDK/Trust/PCRTrustRoots.swift +++ b/sdk/swift/Sources/MapleSDK/Trust/PCRTrustRoots.swift @@ -2,28 +2,16 @@ // Changes to approvals belong to the existing signed-PCR review process. extension PCRTrustPolicy { static let productionRoots: [String] = [ - "a0ef363874f70bd82af98d8e628bf7f128dfca1f9918e83af494cd17f5d4ff1a0c1b94b235941a6d20138d1152cbe5aa", - "3d2f90b4dac7316e5485e03f4cdf3f2f8ae79fde77442546475eac6c3037916ac54b8bdb26c85a73e9b778a9a5077638", - "a1c09f74133cbcc0e5034b9a8a5ed1a5a567d4922140d69534827b4473c7f385b989ce594e0b11f6b8ec971decb95608", - "eeddbb58f57c38894d6d5af5e575fbe791c5bf3bbcfb5df8da8cfcf0c2e1da1913108e6a762112444740b88c163d7f4b", - "74ed417f88cb0ca76c4a3d10f278bd010f1d3f95eafb254d4732511bb50e404507a4049b779c5230137e4091a5582271", - "9043fcab93b972d3c14ad2dc8fa78ca7ad374fc937c02435681772a003f7a72876bc4d578089b5c4cf3fe9b480f1aabb", - "52c3595b151d93d8b159c257301bfd5aa6f49210de0c55a6cd6df5ebeee44e4206cab950500f5d188f7fa14e6d900b75", - "91cb67311e910cce68cd5b7d0de77aa40610d87c6681439b44c46c3ff786ae643956ab2c812478a1da8745b259f07a45", - "859065ac81b81d3735130ba08b8af72a7256b603fefb74faabae25ed28cca6edcaa7c10ea32b5948d675c18a9b0f2b1d", - "acd82a7d3943e23e95a9dc3ce0b0107ea358d6287f9e3afa245622f7c7e3e0a66142a928b6efcc02f594a95366d3a99d", + // Approved 2026-10-11 (services/opensecret signed history) + "21d27bd19cdd02632485a6e8874753e42cf7f098f1335bb62a9c23e7f64da9100c02dba582da17ac68052fa6be16b7a9", + // Approved 2026-10-09 (services/opensecret signed history; rollback) + "643b3c293a4eac11ec14ab405d2405e46cb714263a12da6c62f485a0fa395cb3c3d5ef07b18d39a1f9715f25e7943b66", ] static let developmentRoots: [String] = [ - "61d92c66684de563fbc4ce3ece9680966672a065a65dedfd1dcdab149b2d3d3d725ab853dae6dc251dbb3326ff7d1214", - "c5470ea9d2d8499a65fbb9bc936bd7a62a5ef90b54da1504535cb4384f303fb673369d64f1a43f17c8ed8d2a89f09949", - "831eb975dad0b3f3741612acc92f020c0467bd3ac967ddf925fa60eff9e02cf9239ec50c699715fbf9a35e3448e2a117", - "62c0407056217a4c10764ed9045694c29fa93255d3cc04c2f989cdd9a1f8050c8b169714c71f1118ebce2fcc9951d1a9", - "cb95519905443f9f66f05f63c548b61ad1561a27fd5717b69285861aaea3c3063fe12a2571773b67fea3c6c11b4d8ec6", - "deb5895831b5e4286f5a2dcf5e9c27383821446f8df2b465f141d10743599be20ba3bb381ce063bf7139cc89f7f61d4c", - "70ba26c6af1ec3b57ce80e1adcc0ee96d70224d4c7a078f427895cdf68e1c30f09b5ac4c456588d872f3f21ff77c036b", - "669404ea71435b8f498b48db7816a5c2ab1d258b1a77685b11d84d15a73189504d79c4dee13a658de9f4a0cbfc39cfe8", - "a791bf92c25ffdfd372660e460a0e238c6778c090672df6509ae4bc065cf8668b6baac6b6a11d554af53ee0ff0172ad5", - "c4285443b87b9b12a6cea3bef1064ec060f652b235a297095975af8f134e5ed65f92d70d4616fdec80af9dff48bb9f35", + // Approved 2026-10-11 (services/opensecret signed history) + "a95956c3fb9457e509be6d614ffefcddf98fe594f77bb6a56891fc1f5bdec41da2add13db22e0908363c8d0929c04def", + // Approved 2026-10-09 (services/opensecret signed history; rollback) + "e573a3ddc8a27f387e24cfb02a8fa594fe782674de6ee1fa9385126b9f8ec38f4a236e91c371bdd2b9444623c1c1cc34", ] } diff --git a/sdk/swift/Tests/MapleSDKTests/Trust/PCRTrustTests.swift b/sdk/swift/Tests/MapleSDKTests/Trust/PCRTrustTests.swift index 1007d5370..a460bc9e7 100644 --- a/sdk/swift/Tests/MapleSDKTests/Trust/PCRTrustTests.swift +++ b/sdk/swift/Tests/MapleSDKTests/Trust/PCRTrustTests.swift @@ -3,6 +3,19 @@ import Testing @testable import MapleSDK struct PCRTrustTests { + @Test func embeddedRootsMatchCurrentAndRollbackApprovals() { + #expect( + PCRTrustPolicy.productionRoots == [ + "21d27bd19cdd02632485a6e8874753e42cf7f098f1335bb62a9c23e7f64da9100c02dba582da17ac68052fa6be16b7a9", + "643b3c293a4eac11ec14ab405d2405e46cb714263a12da6c62f485a0fa395cb3c3d5ef07b18d39a1f9715f25e7943b66", + ]) + #expect( + PCRTrustPolicy.developmentRoots == [ + "a95956c3fb9457e509be6d614ffefcddf98fe594f77bb6a56891fc1f5bdec41da2add13db22e0908363c8d0929c04def", + "e573a3ddc8a27f387e24cfb02a8fa594fe782674de6ee1fa9385126b9f8ec38f4a236e91c371bdd2b9444623c1c1cc34", + ]) + } + static let signedPCR0 = "3637534c33a8bafc5034d5763e441a481f161bbbe888e375ce14b016c7497dc4e550afe866bd8e65969b409d54766481" static let signature = From 557035dc934328d202ff8fc42b4a0a73a143c0d3 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:46:10 +0000 Subject: [PATCH 03/17] ios-native parity item 4: classify structured chat rejection codes --- .../Sources/MapleSDK/API/MapleTransport.swift | 2 +- .../Sources/MapleSDK/API/ResearchAPI.swift | 77 +++++++++- .../MapleSDKTests/API/ResearchAPITests.swift | 145 +++++++++++++++++- 3 files changed, 213 insertions(+), 11 deletions(-) diff --git a/sdk/swift/Sources/MapleSDK/API/MapleTransport.swift b/sdk/swift/Sources/MapleSDK/API/MapleTransport.swift index 7b69f93f8..a908ea8c9 100644 --- a/sdk/swift/Sources/MapleSDK/API/MapleTransport.swift +++ b/sdk/swift/Sources/MapleSDK/API/MapleTransport.swift @@ -83,7 +83,7 @@ public enum ResearchAPIError: Error, Sendable, Equatable, LocalizedError { case imageDescriptionUnavailable, authenticationRequired, forbidden, notFound case invalidLogin, emailAlreadyRegistered, oauthNotLinked, invalidInviteCode case userNotFound, oauthEmailUnavailable, alreadyVerified, modelUnavailable - case billingCleanupUnavailable, payloadTooLarge, rateLimited + case billingCleanupUnavailable, payloadTooLarge, rateLimited, unsupportedValue } public var errorDescription: String? { diff --git a/sdk/swift/Sources/MapleSDK/API/ResearchAPI.swift b/sdk/swift/Sources/MapleSDK/API/ResearchAPI.swift index 4a01f1c40..259a2a5c5 100644 --- a/sdk/swift/Sources/MapleSDK/API/ResearchAPI.swift +++ b/sdk/swift/Sources/MapleSDK/API/ResearchAPI.swift @@ -364,12 +364,8 @@ public struct ResearchAPI: Sendable { var reason: ResearchAPIError.RejectionReason = .unspecified // Recognize only stable, bounded public contracts. Never expose the body. let text = String(data: body, encoding: .utf8) ?? "" - if response.status == 413 && text.contains("Message exceeds context limit") { - reason = .contextLimit - } else if response.status == 403 && text.contains("Free tier token limit exceeded") { - reason = .freeTierTokenLimit - } else if response.status == 403 && text.contains("Usage limit reached") { - reason = .usageLimit + if let chatReason = Self.chatRejection(response, body: body) { + reason = chatReason } else if response.status == 503 && response.header("x-opensecret-error-contract") == "1" && response.header("x-opensecret-error-code") == "image_description_unavailable" { @@ -392,8 +388,6 @@ public struct ResearchAPI: Sendable { reason = .oauthEmailUnavailable } else if response.status == 400 && text.contains("User is already verified") { reason = .alreadyVerified - } else if response.status == 403 && text.contains("Model not available on current plan") { - reason = .modelUnavailable } else if response.status == 413 { reason = .payloadTooLarge } else if response.status == 429 { @@ -408,6 +402,73 @@ public struct ResearchAPI: Sendable { throw ResearchAPIError.http(status: response.status, reason: reason) } + private static func chatRejection(_ response: MapleResponse, body: Data) -> ResearchAPIError.RejectionReason? { + let contract = response.header("x-opensecret-error-contract") + guard contract == nil || contract == "1" else { return nil } + let headerCode = response.header("x-opensecret-error-code") + guard headerCode == nil || contract == "1" else { return nil } + // Unrelated payload fields must not hide a supplied conflicting code. + let fields = try? JSONDecoder().decode(ChatErrorBody.self, from: body) + let codes = [headerCode.map(ChatErrorBody.Code.value), fields?.code, fields?.nestedCode] + .compactMap { $0 } + .filter { $0 != .absent } + let code: String + if let first = codes.first { + // Every supplied source must agree. Unknown or conflicting codes + // must never fall back to a human-readable limit message. + guard case let .value(value) = first, codes.allSatisfy({ $0 == first }) else { return nil } + code = value + } else { + // Older servers publish only the flat status and exact message. + // Bound that compatibility path and reject arbitrary substrings. + guard body.count <= 4_096, fields?.status == Int64(response.status), let message = fields?.message + else { return nil } + switch message { + case "Usage limit reached": code = "usage_limit_reached" + case "Free tier token limit exceeded": code = "free_tier_token_limit_exceeded" + case "Message exceeds context limit": code = "message_exceeds_context_limit" + default: return nil + } + } + switch (response.status, code) { + case (403, "usage_limit_reached"): return .usageLimit + case (403, "free_tier_token_limit_exceeded"): return .freeTierTokenLimit + case (413, "message_exceeds_context_limit"): return .contextLimit + case (403, "model_not_available_on_plan"): return .modelUnavailable + case (400, "unsupported_value"): return .unsupportedValue + default: return nil + } + } + + private struct ChatErrorBody: Decodable { + enum Code: Equatable { + case absent, value(String), invalid + } + let code: Code + let nestedCode: Code + let status: Int64? + let message: String? + enum CodingKeys: String, CodingKey { case code, error, status, message } + + init(from decoder: any Decoder) throws { + let fields = try decoder.container(keyedBy: CodingKeys.self) + code = Self.readCode(fields) + if let error = try? fields.nestedContainer(keyedBy: CodingKeys.self, forKey: .error) { + nestedCode = Self.readCode(error) + } else { + nestedCode = .absent + } + status = try? fields.decode(Int64.self, forKey: .status) + message = try? fields.decode(String.self, forKey: .message) + } + + private static func readCode(_ fields: KeyedDecodingContainer) -> Code { + guard fields.contains(.code), (try? fields.decodeNil(forKey: .code)) != true else { return .absent } + guard let value = try? fields.decode(String.self, forKey: .code) else { return .invalid } + return .value(value) + } + } + internal func collect(_ stream: AsyncThrowingStream, limit: Int? = nil) async throws -> Data { var data = Data() let limit = limit ?? maximumResponseBytes diff --git a/sdk/swift/Tests/MapleSDKTests/API/ResearchAPITests.swift b/sdk/swift/Tests/MapleSDKTests/API/ResearchAPITests.swift index 54d4f54b8..9874195fe 100644 --- a/sdk/swift/Tests/MapleSDKTests/API/ResearchAPITests.swift +++ b/sdk/swift/Tests/MapleSDKTests/API/ResearchAPITests.swift @@ -212,7 +212,11 @@ private func object(_ request: MapleRequest) throws -> [String: JSONValue] { } @Test func errorsAreSanitizedBoundedAndNeverRetried() async throws { - let transport = APIRecorder([.init(status: 403, body: #"{"error":"Usage limit reached; private-canary"}"#)]) + let transport = APIRecorder([ + .init( + status: 403, body: #"{"error":{"code":"usage_limit_reached","message":"private-canary"}}"#, + headers: ["x-opensecret-error-contract": "1", "x-opensecret-error-code": "usage_limit_reached"]) + ]) let api = ResearchAPI(transport: transport) do { _ = try await api.getConversation(id: "c"); Issue.record("Expected error") } catch { #expect(error as? ResearchAPIError == .http(status: 403, reason: .usageLimit)) @@ -223,6 +227,143 @@ private func object(_ request: MapleRequest) throws -> [String: JSONValue] { await #expect(throws: ResearchAPIError.responseTooLarge) { try await oversized.getConversation(id: "c") } } +@Test func chatErrorCodesUseEverySuppliedSourceAndTheHTTPStatus() async throws { + let cases: [(Int, String, ResearchAPIError.RejectionReason, ResearchAPIError.RejectionReason)] = [ + (403, "usage_limit_reached", .usageLimit, .forbidden), + (403, "free_tier_token_limit_exceeded", .freeTierTokenLimit, .forbidden), + (413, "message_exceeds_context_limit", .contextLimit, .payloadTooLarge), + (403, "model_not_available_on_plan", .modelUnavailable, .forbidden), + (400, "unsupported_value", .unsupportedValue, .unspecified), + ] + for (status, code, reason, fallback) in cases { + let headers = ["x-opensecret-error-contract": "1", "x-opensecret-error-code": code] + let body = String( + data: try JSONEncoder().encode([ + "code": JSONValue.string(code), + "error": .object(["code": .string(code), "message": .string("Changed public wording")]), + ]), encoding: .utf8)! + for reply in [ + APIRecorder.Reply(status: status, body: body, headers: headers), + APIRecorder.Reply(status: status, body: "", headers: headers), + APIRecorder.Reply(status: status, body: body), + ] { + let api = ResearchAPI(transport: APIRecorder([reply])) + await #expect(throws: ResearchAPIError.http(status: status, reason: reason)) { + try await api.getConversation(id: "c") + } + } + // The same code under a different HTTP status cannot establish the issue. + let wrongStatus = status == 403 ? 413 : 403 + let wrongReason: ResearchAPIError.RejectionReason = wrongStatus == 413 ? .payloadTooLarge : .forbidden + let wrongAPI = ResearchAPI( + transport: APIRecorder([.init(status: wrongStatus, body: body, headers: headers)])) + await #expect(throws: ResearchAPIError.http(status: wrongStatus, reason: wrongReason)) { + try await wrongAPI.getConversation(id: "c") + } + let unversionedAPI = ResearchAPI( + transport: APIRecorder([ + .init(status: status, body: body, headers: ["x-opensecret-error-code": code]) + ])) + await #expect(throws: ResearchAPIError.http(status: status, reason: fallback)) { + try await unversionedAPI.getConversation(id: "c") + } + // A nested body code alone is sufficient, with or without contract 1. + let nestedBody = String( + data: try JSONEncoder().encode(["error": ["code": code]]), encoding: .utf8)! + for nestedHeaders in [[:], ["x-opensecret-error-contract": "1"]] { + let nestedAPI = ResearchAPI( + transport: APIRecorder([.init(status: status, body: nestedBody, headers: nestedHeaders)])) + await #expect(throws: ResearchAPIError.http(status: status, reason: reason)) { + try await nestedAPI.getConversation(id: "c") + } + } + } +} + +@Test func conflictingUnknownAndMalformedChatCodesNeverUseLegacyMessages() async throws { + let legacy = #"{"status":403,"message":"Usage limit reached"}"# + let cases: [APIRecorder.Reply] = [ + .init( + status: 403, body: legacy, + headers: ["x-opensecret-error-contract": "2", "x-opensecret-error-code": "usage_limit_reached"]), + .init(status: 403, body: legacy, headers: ["x-opensecret-error-contract": "2"]), + .init(status: 403, body: legacy, headers: ["x-opensecret-error-code": "usage_limit_reached"]), + .init( + status: 403, body: legacy, + headers: ["x-opensecret-error-contract": "1", "x-opensecret-error-code": "unknown"]), + .init(status: 403, body: #"{"status":403,"message":"Usage limit reached","code":"unknown"}"#), + .init(status: 403, body: #"{"status":403,"message":"Usage limit reached","code":403}"#), + .init(status: 403, body: #"{"status":403,"message":"Usage limit reached","error":{"code":false}}"#), + .init( + status: 403, + body: + #"{"status":403,"message":"Usage limit reached","code":"usage_limit_reached","error":{"code":"free_tier_token_limit_exceeded"}}"# + ), + .init( + status: 403, + body: #"{"status":403,"message":"Usage limit reached","error":{"code":"model_not_available_on_plan"}}"#, + headers: ["x-opensecret-error-contract": "1", "x-opensecret-error-code": "usage_limit_reached"]), + .init( + status: 403, body: #"{"error":{"code":"model_not_available_on_plan"},"extra":1e400}"#, + headers: ["x-opensecret-error-contract": "1", "x-opensecret-error-code": "usage_limit_reached"]), + .init( + status: 403, body: #"{"error":{"code":1e400}}"#, + headers: ["x-opensecret-error-contract": "1", "x-opensecret-error-code": "usage_limit_reached"]), + ] + for reply in cases { + let api = ResearchAPI(transport: APIRecorder([reply])) + await #expect(throws: ResearchAPIError.http(status: 403, reason: .forbidden)) { + try await api.getConversation(id: "c") + } + } +} + +@Test func legacyChatLimitsRequireExactBoundedMessagesAndMatchingStatuses() async throws { + let cases: [(Int, String, ResearchAPIError.RejectionReason)] = [ + (403, "Usage limit reached", .usageLimit), + (403, "Free tier token limit exceeded", .freeTierTokenLimit), + (413, "Message exceeds context limit", .contextLimit), + ] + for (status, message, reason) in cases { + let body = String( + data: try JSONEncoder().encode(["status": JSONValue.integer(Int64(status)), "message": .string(message)]), + encoding: .utf8)! + let api = ResearchAPI(transport: APIRecorder([.init(status: status, body: body)])) + await #expect(throws: ResearchAPIError.http(status: status, reason: reason)) { + try await api.getConversation(id: "c") + } + } + for body in [ + "Usage limit reached", + #"{"error":"Usage limit reached"}"#, + #"{"message":"Usage limit reached"}"#, + #"{"status":"403","message":"Usage limit reached"}"#, + #"{"status":413,"message":"Usage limit reached"}"#, + #"{"status":403,"message":"Usage limit reached soon"}"#, + #"{"status":403,"message":"Model not available on current plan"}"#, + #"{"status":403,"message":"Usage limit reached""#, + #"{"status":403,"message":"Usage limit reached"} trailing"#, + #"{"status":403,"message":"Usage limit reached","padding":""# + String(repeating: "x", count: 4_096) + #""}"#, + ] { + let api = ResearchAPI(transport: APIRecorder([.init(status: 403, body: body)])) + await #expect(throws: ResearchAPIError.http(status: 403, reason: .forbidden)) { + try await api.getConversation(id: "c") + } + } +} + +@Test func chatErrorHeadersRemainUntrustedUntilTheBodyFinishes() async throws { + let api = ResearchAPI(transport: APIRecorder([String]())) + let response = MapleResponse( + status: 403, + headers: ["x-opensecret-error-contract": "1", "x-opensecret-error-code": "usage_limit_reached"], + body: AsyncThrowingStream { + $0.yield(Data(#"{"error":{"code":"usage_limit_reached"}}"#.utf8)) + $0.finish(throwing: ResearchAPIError.invalidStream) + }) + await #expect(throws: ResearchAPIError.invalidStream) { try await api.checked(response) } +} + @Test(arguments: ["0", "60", "61", "999999999999999999999", "01", "not-a-delay"]) func capacityContractAndBoundedRetryAfter(_ delay: String) throws { let response = MapleResponse( @@ -265,7 +406,7 @@ func capacityContractAndBoundedRetryAfter(_ delay: String) throws { (404, "User not found", .userNotFound), (400, "No valid email found for the Oauth account", .oauthEmailUnavailable), (400, "User is already verified", .alreadyVerified), - (403, "Model not available on current plan", .modelUnavailable), + (403, "Model not available on current plan", .forbidden), (413, "Payload too large", .payloadTooLarge), (429, "Too many requests", .rateLimited), ] From 28fbd28b732f1e5f7ab2e86530e12699f147c09c Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:46:37 +0000 Subject: [PATCH 04/17] ios-native parity items 3 and 5: decode billing and align access rules --- .../Account/AccountEntitlements.swift | 39 +++----- .../Account/BillingContracts.swift | 20 ++++ .../Account/AccountEntitlementsTests.swift | 92 +++++++++++++++---- .../Account/BillingServiceTests.swift | 65 +++++++++++++ .../Account/MediaAccessTests.swift | 30 +++--- 5 files changed, 187 insertions(+), 59 deletions(-) diff --git a/apps/maple-ios/Sources/MapleAppCore/Account/AccountEntitlements.swift b/apps/maple-ios/Sources/MapleAppCore/Account/AccountEntitlements.swift index b3a993858..fd10a80f2 100644 --- a/apps/maple-ios/Sources/MapleAppCore/Account/AccountEntitlements.swift +++ b/apps/maple-ios/Sources/MapleAppCore/Account/AccountEntitlements.swift @@ -179,11 +179,9 @@ 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 + case "pro": return plan.isPaid ? .allowed : .paidPlanRequired default: return .modelUnavailable } } @@ -199,11 +197,7 @@ 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 } @@ -211,35 +205,26 @@ public struct AccountModelChoice: Equatable, Sendable, Identifiable { let account = accountChatAccess guard account == .allowed else { return account } guard let selectedModel else { return .modelUnavailable } - return modelAccess(selectedModel) + // The picker gates new paid selections. Existing selections reach the + // server, which owns plan and usage authorization for each request. + return switch modelAccess(selectedModel) { + case .allowed, .paidPlanRequired: .allowed + case let access: access + } } 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 { paidFeatureAccess } 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 diff --git a/apps/maple-ios/Sources/MapleAppCore/Account/BillingContracts.swift b/apps/maple-ios/Sources/MapleAppCore/Account/BillingContracts.swift index 0fd7c0f8e..8ff1ba0ff 100644 --- a/apps/maple-ios/Sources/MapleAppCore/Account/BillingContracts.swift +++ b/apps/maple-ios/Sources/MapleAppCore/Account/BillingContracts.swift @@ -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? @@ -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 { diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Account/AccountEntitlementsTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Account/AccountEntitlementsTests.swift index 2c680b08a..e88d0ff67 100644 --- a/apps/maple-ios/Tests/MapleAppCoreTests/Account/AccountEntitlementsTests.swift +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Account/AccountEntitlementsTests.swift @@ -19,20 +19,25 @@ import Testing let gate = AccountGate() let service = AccountServingStub( statuses: [.failure(.unavailable)], teams: [.success(try basicTeam())], billingGates: [1: gate]) - let account = AccountEntitlements(scope: accountScope(), isGuest: true, billing: service, catalog: service) + let account = AccountEntitlements( + scope: accountScope(), isGuest: true, billing: service, catalog: service, + selectedModelID: "auto:powerful") #expect(account.billing == .unknown) #expect(account.plan == .unknown) - #expect(account.chatAccess == .unavailable) + #expect(account.chatAccess == .modelUnavailable) + await account.refreshCatalog() + #expect(account.chatAccess == .allowed) let load = Task { await account.refresh() } await gate.waitUntilEntered() #expect(account.billing == .loading) #expect(account.plan == .unknown) #expect(account.webSearchPreferred) #expect(!account.effectiveWebSearch) + #expect(account.chatAccess == .allowed) await gate.release(); await load.value #expect(account.billing == .failed(.unavailable)) #expect(account.plan == .unknown) - #expect(account.chatAccess != .freeGuest) + #expect(account.chatAccess == .allowed) } @Test func explicitFreeEmailAndGuestDifferWithoutPurchaseActions() async throws { @@ -61,19 +66,70 @@ import Testing #expect(!account.effectiveWebSearch) } - @Test func unknownPlanAndServerQuotaRemainConstrained() async throws { - for account in [try fixture(plan: nil), try fixture(plan: "Future Premium")] { + @Test func unknownPlanAndServerQuotaDoNotBlockFreeModelChat() async throws { + for account in [try fixture(plan: nil), try fixture(plan: "Future Premium", guest: true)] { await account.refresh() - #expect(account.chatAccess == .unknownPlan) + #expect(account.plan == .unknown) + #expect(account.chatAccess == .allowed) #expect(!account.effectiveWebSearch) } for account in [try fixture(canChat: false), try fixture(remaining: 0)] { await account.refresh() - #expect(account.chatAccess == .usageLimit) + #expect(account.chatAccess == .allowed) + #expect(account.effectiveWebSearch) + } + } + + @Test func onlyLoadedKnownFreeGuestIsBlocked() async throws { + let service = AccountServingStub( + statuses: [.success(try billingStatus(plan: " FREE\n")), .failure(.unavailable)], + teams: [.success(try basicTeam())]) + let guest = AccountEntitlements(scope: accountScope(), isGuest: true, billing: service, catalog: service) + await guest.refreshCatalog() + #expect(guest.chatAccess == .allowed) + await guest.refreshBilling() + #expect(guest.chatAccess == .freeGuest) + await guest.refreshBilling() + #expect(guest.billing == .failed(.unavailable)) + #expect(guest.chatAccess == .allowed) + } + + @Test func selectedPaidModelMayChatWithUnknownPlanWhilePickerRemainsPaidGated() async throws { + for plan: String? in [nil, "", "Future Premium"] { + let account = try fixture(plan: plan, selected: "auto:powerful") + await account.refresh() + #expect(account.plan == .unknown) + #expect(account.chatAccess == .allowed) + #expect(account.selectModel("auto:powerful") == .paidPlanRequired) + #expect(account.selectedModelID == "auto:powerful") #expect(!account.effectiveWebSearch) } } + @Test func selectedPaidModelReachesServerAfterDowngradeAndBillingFailure() async throws { + for guest in [false, true] { + let service = AccountServingStub( + statuses: [ + .success(try billingStatus()), .success(try billingStatus(plan: "Free")), + .failure(.unavailable), + ], teams: [.success(try basicTeam())]) + let account = AccountEntitlements( + scope: accountScope(), isGuest: guest, billing: service, catalog: service, + selectedModelID: "auto:powerful") + await account.refresh() + #expect(account.chatAccess == .allowed) + await account.refreshBilling() + #expect(account.plan == .free) + #expect(account.chatAccess == (guest ? .freeGuest : .allowed)) + #expect(account.selectModel("auto:powerful") == .paidPlanRequired) + #expect(account.webSearchAccess == (guest ? .freeGuest : .paidPlanRequired)) + await account.refreshBilling() + #expect(account.billing == .failed(.unavailable)) + #expect(account.chatAccess == .allowed) + #expect(account.selectedModelID == "auto:powerful") + } + } + @Test func teamCountsUseFallbacksAndMissingCountsStayUnknown() async throws { let fallback: TeamStatus = try accountDecode([ "has_team_subscription": true, "team_created": true, "members_count": 5, @@ -97,7 +153,7 @@ import Testing statuses: [.success(try billingStatus(plan: "Team"))], teams: [.success(missing), .failure(.unavailable)]) let account = AccountEntitlements(scope: accountScope(), isGuest: false, billing: service, catalog: service) await account.refresh() - #expect(account.chatAccess == .seatMismatch) + #expect(account.chatAccess == .allowed) await account.refreshTeam() #expect(account.team == .failed(.unavailable)) #expect(account.team.value?.seatMismatch == nil) @@ -108,16 +164,15 @@ import Testing let catalog = accountCatalog(items: [ catalogItem(), catalogItem("power-target", access: "pro"), catalogItem("off", enabled: false), catalogItem("old", deprecated: true), catalogItem("audio", chat: false), - catalogItem("future", access: "future"), catalogItem("starter", access: "starter"), + catalogItem("future", access: "future"), catalogItem(), // duplicate server entry cannot duplicate menus ]) let account = try fixture(catalog: catalog) await account.refresh() - #expect(Set(account.advancedModels.map(\.id)) == ["quick-target", "power-target", "future", "starter"]) - #expect(account.advancedModels.count == 4) + #expect(Set(account.advancedModels.map(\.id)) == ["quick-target", "power-target", "future"]) + #expect(account.advancedModels.count == 3) #expect(account.primaryModels.map(\.id) == ["auto:quick", "auto:powerful"]) #expect(account.selectModel("future") == .modelUnavailable) - #expect(account.selectModel("starter") == .allowed) } @Test func catalogFailurePreservesSelectionAndDoesNotAdoptFallback() async throws { @@ -154,17 +209,18 @@ import Testing #expect(account.selectedModelID == "auto:quick") } - @Test func actualAliasTargetOverridesStaleAliasAccessAndToolMetadata() async throws { + @Test func actualAliasTargetOverridesAccessWithoutGatingWebSearchOnTools() async throws { let catalog = accountCatalog(items: [ catalogItem(access: "pro", tools: false), catalogItem("power-target", access: "pro"), ]) let free = try fixture(plan: "Free", catalog: catalog) let paid = try fixture(catalog: catalog) await free.refresh(); await paid.refresh() - #expect(free.chatAccess == .paidPlanRequired) + #expect(free.chatAccess == .allowed) + #expect(free.selectModel("auto:quick") == .paidPlanRequired) #expect(paid.chatAccess == .allowed) - #expect(paid.webSearchAccess == .modelIncompatible) - #expect(paid.webSearchPreferred && !paid.effectiveWebSearch) + #expect(paid.webSearchAccess == .allowed) + #expect(paid.webSearchPreferred && paid.effectiveWebSearch) } @Test func newerRefreshWinsOverLateBillingAndCatalogResults() async throws { @@ -256,7 +312,7 @@ import Testing #expect(account.speechAccess == .modelIncompatible && !account.speechAvailable) } - @Test func speechAdmissionDistinguishesUnknownFreeQuotaAndMissingCapability() async throws { + @Test func speechAdmissionDistinguishesUnknownFreeAndMissingCapabilityWithoutQuotaGates() async throws { let unknown = try fixture(plan: "Future Premium"), free = try fixture(plan: "Free", guest: true) let exhausted = try fixture(remaining: 0) var missing = accountCatalog(); missing.audio = nil @@ -265,7 +321,7 @@ import Testing await unknown.refresh(); await free.refresh(); await exhausted.refresh(); await absent.refresh() #expect(unknown.speechAccess == .unavailable) #expect(free.speechAccess == .paidPlanRequired) - #expect(exhausted.speechAccess == .usageLimit) + #expect(exhausted.speechAccess == .allowed) #expect(absent.speechAccess == .unavailable) } } diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Account/BillingServiceTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Account/BillingServiceTests.swift index 177a8e641..cd3dbc3d7 100644 --- a/apps/maple-ios/Tests/MapleAppCoreTests/Account/BillingServiceTests.swift +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Account/BillingServiceTests.swift @@ -115,7 +115,12 @@ struct BillingServiceTests { @Test func malformedCountsAndUnknownPlanAreNotFree() throws { #expect(try billingStatus(plan: nil).plan == .unknown) #expect(try billingStatus(plan: "Future Premium").plan == .unknown) + #expect(try billingStatus(plan: "Free plan").plan == .unknown) + #expect(try billingStatus(plan: "").plan == .unknown) #expect(try billingStatus(plan: " FREE\n").plan == .free) + #expect(try billingStatus(plan: " Pro annual\n").plan == .paid(.pro)) + #expect(try billingStatus(plan: "MAX monthly").plan == .paid(.max)) + #expect(try billingStatus(plan: "Team Annual").plan == .paid(.team)) #expect(throws: AccountServiceError.invalidResponse) { try billingStatus(remaining: -1).validate() } #expect(throws: (any Error).self) { let _: TeamStatus = try accountDecode([ @@ -124,6 +129,66 @@ struct BillingServiceTests { } } + @Test func fullBillingStatusFixtureDecodesOptionalContractFields() throws { + let status: BillingStatus = try accountDecode([ + "is_subscribed": true, "stripe_customer_id": "fixture-customer", "product_id": "fixture-product", + "product_name": "Max", "subscription_status": "active", "current_period_end": 1_735_689_600, + "can_chat": true, "chats_remaining": 42, "payment_provider": "stripe", + "total_tokens": 1_000_000, "used_tokens": 123_456, "usage_reset_date": "2025-01-01", + "api_credit_balance": 12_345.5, "ios_iap_enabled": true, "ios_us_external_link_enabled": false, + "subscriptions": [ + [ + "provider": "stripe", "plan": "Max", "product_id": "fixture-product", "state": "active", + "renews_at": 1_735_689_600, "manage": "portal", "selected": true, + "environment": "fixture", "auto_renew_enabled": true, + ] + ], + "conflict": ["other_provider": "apple", "action": "future_action"], + "pending_plan_change": [ + "id": "fixture-change", "type": "downgrade", "target_plan_name": "Pro", "status": "pending", + "expires_at": "2025-01-01T00:00:00Z", + ], + ]) + try status.validate() + #expect(status.isSubscribed && status.stripeCustomerId == "fixture-customer") + #expect(status.productId == "fixture-product" && status.productName == "Max") + #expect(status.subscriptionStatus == "active" && status.currentPeriodEnd == .seconds(1_735_689_600)) + #expect(status.canChat && status.chatsRemaining == 42 && status.paymentProvider == "stripe") + #expect(status.totalTokens == 1_000_000 && status.usedTokens == 123_456) + #expect(status.usageResetDate == "2025-01-01") + #expect(status.apiCreditBalance == 12_345.5) + #expect(status.iosIapEnabled == true && status.iosUsExternalLinkEnabled == false) + let subscription = try #require(status.subscriptions?.first) + #expect(subscription.provider == "stripe" && subscription.plan == "Max") + #expect(subscription.productId == "fixture-product" && subscription.state == "active") + #expect(subscription.renewsAt == 1_735_689_600 && subscription.manage == "portal") + #expect(subscription.selected == true && subscription.environment == "fixture") + #expect(subscription.autoRenewEnabled == true) + let conflict = try #require(status.conflict) + #expect(conflict.otherProvider == "apple" && conflict.action == "future_action") + let pending = try #require(status.pendingPlanChange) + #expect(pending.id == "fixture-change" && pending.type == "downgrade") + #expect(pending.targetPlanName == "Pro" && pending.status == "pending") + #expect(pending.expiresAt == "2025-01-01T00:00:00Z") + } + + @Test func optionalBillingFieldsMayBeAbsentOrNull() throws { + var payload = try #require(JSONSerialization.jsonObject(with: billingBody()) as? [String: Any]) + let optional = [ + "stripe_customer_id", "api_credit_balance", "ios_iap_enabled", "ios_us_external_link_enabled", + "conflict", "pending_plan_change", + ] + for nulls in [false, true] { + for key in optional { + if nulls { payload[key] = NSNull() } else { payload.removeValue(forKey: key) } + } + let status: BillingStatus = try accountDecode(payload) + #expect(status.stripeCustomerId == nil && status.apiCreditBalance == nil) + #expect(status.iosIapEnabled == nil && status.iosUsExternalLinkEnabled == nil) + #expect(status.conflict == nil && status.pendingPlanChange == nil) + } + } + @Test func mixedDatesAndFutureManagementValuesStayInert() throws { let seconds: BillingDate = try JSONDecoder().decode(BillingDate.self, from: Data("1735689600".utf8)) let iso: BillingDate = try JSONDecoder().decode(BillingDate.self, from: Data(#""2025-01-01T00:00:00Z""#.utf8)) diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Account/MediaAccessTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Account/MediaAccessTests.swift index 71b07fddf..b78441119 100644 --- a/apps/maple-ios/Tests/MapleAppCoreTests/Account/MediaAccessTests.swift +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Account/MediaAccessTests.swift @@ -37,45 +37,47 @@ import Testing #expect(!value.transcriptionAvailable) } - @Test func confirmedPlanQuotaAndSeatReasonsPropagateBeforeCapabilities() async throws { + @Test func confirmedPlanGatesMediaWhileQuotaAndSeatsRemainServerOwned() async throws { let cases: [(AccountEntitlements, AccountAccess)] = [ (try account(plan: "Free"), .paidPlanRequired), (try account(plan: "Free", guest: true), .freeGuest), (try account(plan: "Future Premium"), .unknownPlan), - (try account(canChat: false), .usageLimit), - (try account(remaining: 0), .usageLimit), + (try account(canChat: false), .allowed), + (try account(remaining: 0), .allowed), ( try account( team: accountDecode([ "has_team_subscription": true, "team_created": true, "seat_limit_exceeded": true, - ])), .seatMismatch + ])), .allowed ), ] for (value, reason) in cases { await value.refresh() #expect(value.imageAccess == reason) #expect(value.transcriptionAccess == reason) - #expect(!value.transcriptionAvailable) + #expect(value.transcriptionAvailable == (reason == .allowed)) } } - @Test func imageUsesActualTargetVisionAndModelAdmission() async throws { + @Test func imagesAndWebSearchDoNotDependOnSelectedModelCapabilitiesOrCatalog() async throws { let value = try account(); await value.refresh() #expect(value.imageAccess == .allowed) #expect(value.selectedModel?.capabilities.vision == true) #expect(value.selectModel("auto:powerful") == .allowed) - #expect(value.imageAccess == .modelIncompatible) + #expect(value.imageAccess == .allowed) // Audio does not depend on the text model's vision capability. #expect(value.transcriptionAccess == .allowed && value.transcriptionAvailable) var unknownTier = catalogItem(access: "future"); unknownTier.capabilities.vision = true let unsupported = try account(catalog: accountCatalog(items: [unknownTier])) await unsupported.refresh() - #expect(unsupported.imageAccess == .modelUnavailable) + #expect(unsupported.chatAccess == .modelUnavailable) + #expect(unsupported.imageAccess == .allowed && unsupported.webSearchAccess == .allowed) #expect(unsupported.transcriptionAccess == .allowed) let missing = try account(catalog: accountCatalog(items: [])); await missing.refresh() - #expect(missing.imageAccess == .modelUnavailable) + #expect(missing.chatAccess == .modelUnavailable) + #expect(missing.imageAccess == .allowed && missing.webSearchAccess == .allowed) } @Test func absentAndConfirmedUnavailableTranscriptionHaveDifferentReasons() async throws { @@ -92,7 +94,7 @@ import Testing #expect(value.transcriptionAccess == .modelIncompatible && !value.transcriptionAvailable) } - @Test func catalogRefreshRetainsVerifiedAccessThenFailureRevokesIt() async throws { + @Test func catalogFailureOnlyRevokesCatalogOwnedAudioAvailability() async throws { let failureGate = AccountGate(), retryGate = AccountGate() let service = AccountServingStub( statuses: [.success(try billingStatus())], teams: [.success(try basicTeam())], @@ -104,13 +106,13 @@ import Testing await failureGate.waitUntilEntered() #expect(value.imageAccess == .allowed && value.transcriptionAccess == .allowed) await failureGate.release(); await failure.value - #expect(value.webSearchAccess == .unavailable) - #expect(value.imageAccess == .unavailable && value.transcriptionAccess == .unavailable) + #expect(value.webSearchAccess == .allowed) + #expect(value.imageAccess == .allowed && value.transcriptionAccess == .unavailable) let retry = Task { await value.refreshCatalog() } await retryGate.waitUntilEntered() #expect(value.catalog == .loading) - #expect(value.webSearchAccess == .unavailable) - #expect(value.imageAccess == .unavailable && value.transcriptionAccess == .unavailable) + #expect(value.webSearchAccess == .allowed) + #expect(value.imageAccess == .allowed && value.transcriptionAccess == .unavailable) await retryGate.release(); await retry.value #expect(value.imageAccess == .allowed && value.transcriptionAccess == .allowed) } From 593834146f06341a2526e3e4a846e6d6bdb7a8c6 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:46:37 +0000 Subject: [PATCH 05/17] ios-native parity items 4 6 7 9 11: report chat and dictation outcomes --- .../MapleAppCore/Chat/ChatContracts.swift | 4 +- .../MapleAppCore/Chat/ChatRuntime.swift | 42 +++++- .../Dictation/DictationContracts.swift | 2 +- .../Dictation/DictationCoordinator.swift | 3 + .../Chat/ChatQueueTests.swift | 2 +- .../Chat/ChatRuntimeTests.swift | 121 +++++++++++++++++- .../Dictation/DictationWorkflowTests.swift | 12 ++ 7 files changed, 174 insertions(+), 12 deletions(-) diff --git a/apps/maple-ios/Sources/MapleAppCore/Chat/ChatContracts.swift b/apps/maple-ios/Sources/MapleAppCore/Chat/ChatContracts.swift index 3e32105ba..d71864859 100644 --- a/apps/maple-ios/Sources/MapleAppCore/Chat/ChatContracts.swift +++ b/apps/maple-ios/Sources/MapleAppCore/Chat/ChatContracts.swift @@ -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 } diff --git a/apps/maple-ios/Sources/MapleAppCore/Chat/ChatRuntime.swift b/apps/maple-ios/Sources/MapleAppCore/Chat/ChatRuntime.swift index 0ea857247..556620d6d 100644 --- a/apps/maple-ios/Sources/MapleAppCore/Chat/ChatRuntime.swift +++ b/apps/maple-ios/Sources/MapleAppCore/Chat/ChatRuntime.swift @@ -466,6 +466,7 @@ public final class ChatRuntime { run.stopRequested = true; run.phase = .stopping; snapshot.pendingRun = run; changed() scheduleAutosave() if waitingForCapacity { runTask?.cancel(); return } + if run.dispatched, run.responseID == nil { lastIssue = .stoppingWhenResponseReady } if run.dispatched, run.responseID != nil { startCancellation(runID: run.id, operation: operationID) } @@ -726,7 +727,14 @@ public final class ChatRuntime { runOutputIDs[runID, default: []].insert(value.item.id) case let .contentPartAdded(value), let .contentPartDone(value): runOutputIDs[runID, default: []].insert(value.itemId) - case let .outputTextDelta(value), let .outputTextDone(value): + case let .outputTextDelta(value), let .outputTextDone(value), let .reasoningTextDelta(value), + let .reasoningTextDone(value): + runOutputIDs[runID, default: []].insert(value.itemId) + case let .toolCallCreated(value): + runOutputIDs[runID, default: []].insert(value.toolCallId) + case let .toolOutputCreated(value): + runOutputIDs[runID, default: []].insert(value.toolOutputId) + case let .webSearchInProgress(value), let .webSearchSearching(value), let .webSearchCompleted(value): runOutputIDs[runID, default: []].insert(value.itemId) default: break } @@ -867,13 +875,15 @@ public final class ChatRuntime { guard owns(runID, operation), let finishedRun = snapshot.pendingRun else { return } let chatID = conversationID let wasStopped = finishedRun.stopRequested - let outputIDs = (runOutputIDs.removeValue(forKey: runID) ?? []).union(response?.output?.map(\.id) ?? []) // Sparse terminal responses can omit output already received as text deltas. // Associate only this run's output; older history and explicit links retain their owners. let responseID = response?.id ?? finishedRun.responseID - for index in messages.indices where outputIDs.contains(messages[index].id) && messages[index].responseId == nil - { - messages[index].responseId = responseID + let outputIDs = (runOutputIDs.removeValue(forKey: runID) ?? []) + .union(response?.output?.map(\.id) ?? []) + .union(messages.filter { responseID != nil && $0.responseId == responseID && $0.role != "user" }.map(\.id)) + for index in messages.indices where outputIDs.contains(messages[index].id) { + if messages[index].responseId == nil { messages[index].responseId = responseID } + if outcome == .cancelled { messages[index].status = "incomplete" } } let completion = chatID.map { id in ChatCompletion( @@ -883,6 +893,17 @@ public final class ChatRuntime { recovered: recovered, wasStopped: wasStopped, wasVisible: isVisible, wasForeground: isForeground) } snapshot.pendingRun = nil; changed(); lastOutcome = outcome + if outcome == .failed, !wasStopped { + lastIssue = .generationFailed + } else if outcome == .completed, wasStopped { + lastIssue = .responseFinishedBeforeStop + } else if recovered, outcome == .completed { + lastIssue = .recoveredCompletion + } else if recovered, outcome == .cancelled || outcome == .failed { + lastIssue = .recoveredStop + } else { + lastIssue = nil + } runTask?.cancel(); cancelTask?.cancel() runTask = nil; cancelTask = nil if outcome != .completed || recovered || wasStopped { snapshot.queue?.paused = true } @@ -925,7 +946,7 @@ public final class ChatRuntime { messages.removeAll { $0.id == run.messageID } snapshot.pendingRun = nil; changed(); lastOutcome = run.stopRequested ? .cancelled : .rejected; snapshot.queue?.paused = true - lastIssue = issue + lastIssue = run.stopRequested && (issue == nil || issue == .requestRejected) ? .stoppedBeforeSending : issue do { try await persistCurrent() } catch { if owns(operation) { lastIssue = .storageUnavailable } } } @@ -1128,13 +1149,17 @@ public final class ChatRuntime { } private static func issue(_ error: any Error) -> ChatIssue { + if findOpenSecretInferenceCapacityError(error) != nil { return .inferenceCapacityUnavailable } guard case let ResearchAPIError.http(_, reason) = error else { return error is ChatIssue ? (error as? ChatIssue ?? .requestRejected) : .requestRejected } return switch reason { - case .contextLimit, .freeTierTokenLimit: .contextLimit + case .contextLimit: .contextLimit + case .freeTierTokenLimit: .freeTierTokenLimit case .usageLimit: .usageLimit case .modelUnavailable: .modelUnavailable + case .unsupportedValue: .unsupportedValue + case .imageDescriptionUnavailable: .imageDescriptionUnavailable default: .requestRejected } } @@ -1269,6 +1294,9 @@ public final class ChatRuntime { parts[value.contentIndex].text = (parts[value.contentIndex].text ?? "") + (value.delta ?? "") } items[index].content = parts + if reasoning, items[index].status != "incomplete" { + items[index].status = final ? "completed" : "in_progress" + } } } diff --git a/apps/maple-ios/Sources/MapleAppCore/Dictation/DictationContracts.swift b/apps/maple-ios/Sources/MapleAppCore/Dictation/DictationContracts.swift index 417470263..cc05e1eb2 100644 --- a/apps/maple-ios/Sources/MapleAppCore/Dictation/DictationContracts.swift +++ b/apps/maple-ios/Sources/MapleAppCore/Dictation/DictationContracts.swift @@ -3,7 +3,7 @@ import MapleSDK public enum DictationIssue: String, Error, Sendable { case permissionDenied, captureUnavailable, emptyRecording, recordingTooLarge - case transcriptionUnavailable, invalidTranscription, tooManyOperations, draftUnavailable + case transcriptionUnavailable, emptyTranscription, invalidTranscription, tooManyOperations, draftUnavailable } public enum DictationPhase: String, Sendable { diff --git a/apps/maple-ios/Sources/MapleAppCore/Dictation/DictationCoordinator.swift b/apps/maple-ios/Sources/MapleAppCore/Dictation/DictationCoordinator.swift index 7e70b69d5..8a7e17e4b 100644 --- a/apps/maple-ios/Sources/MapleAppCore/Dictation/DictationCoordinator.swift +++ b/apps/maple-ios/Sources/MapleAppCore/Dictation/DictationCoordinator.swift @@ -226,6 +226,9 @@ public enum DictationActivity: Sendable { case capturing, transcribing, idle } guard text.utf8.count <= 256 * 1024 else { fail(job, issue: .invalidTranscription, retainingClip: true); return } + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + fail(job, issue: .emptyTranscription, retainingClip: false); return + } job.result = text; job.wav = nil; job.task = nil insertResult(job) } catch { diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatQueueTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatQueueTests.swift index 84ae15357..dbcc785fe 100644 --- a/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatQueueTests.swift +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatQueueTests.swift @@ -286,7 +286,7 @@ import Testing let saved = try #require(try await store.load(chatID: "conversation", scope: scope)) #expect(saved.storageOwner == owner && saved == runtime.snapshot) #expect(saved.draft.text == "Edited A" && saved.queue?.edit?.heldDraft.text == "Held B") - #expect(saved.unsentTurn?.text == "Original turn" && runtime.lastIssue == nil) + #expect(saved.unsentTurn?.text == "Original turn" && runtime.lastIssue == .stoppedBeforeSending) #expect(await service.posts.isEmpty) runtime.invalidate(); await autosave.release(); try await store.retire(scope) } diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatRuntimeTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatRuntimeTests.swift index d1da44f74..a7328059b 100644 --- a/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatRuntimeTests.swift +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatRuntimeTests.swift @@ -202,6 +202,7 @@ import Testing #expect(await service.creations == 0) #expect(await service.posts.isEmpty) #expect(runtime.draftText == "Keep this turn" && !runtime.hasUnresolvedRun) + #expect(runtime.lastIssue == .stoppedBeforeSending) runtime.invalidate() } @@ -215,6 +216,7 @@ import Testing #expect(await service.posts.isEmpty) #expect(await store.recorded(ChatSnapshot.newChatKey) == nil) #expect(await store.recorded("conversation")?.draft.text == "Keep this turn") + #expect(runtime.lastIssue == .stoppedBeforeSending) runtime.invalidate() } @@ -223,6 +225,7 @@ import Testing runtime.draftText = "Synthetic turn"; #expect(runtime.send()); await service.waitForPosts() runtime.stop() #expect(runtime.hasUnresolvedRun) + #expect(runtime.lastIssue == .stoppingWhenResponseReady) await service.emit(.created(try chatLifecycle(chatResponse("response", .inProgress)))) await service.waitForCancellation(); await runtime.waitForCurrentWork() #expect(await service.cancellations == ["response"]) @@ -240,7 +243,7 @@ import Testing runtime.stop(); await runtime.waitForCurrentWork() #expect(runtime.hasUnresolvedRun == (status == 503)) #expect(await service.retrievals.count == (status == 400 ? 1 : 0)) - if status == 503 { #expect(runtime.lastIssue == .cancellationUnavailable) } + #expect(runtime.lastIssue == (status == 503 ? .cancellationUnavailable : .responseFinishedBeforeStop)) runtime.invalidate() } @@ -350,7 +353,7 @@ import Testing let savedFirst = try #require(try await store.load(chatID: "conversation", scope: scope)) #expect((queued ? savedFirst.queue?.items.first?.draft.text : savedFirst.draft.text) == "First turn") #expect(try await store.load(chatID: ChatSnapshot.newChatKey, scope: scope)?.draft.text == "New account draft") - #expect(first.lastIssue == nil && !first.hasUnresolvedRun) + #expect(first.lastIssue == .stoppedBeforeSending && !first.hasUnresolvedRun) first.invalidate(); second.invalidate(); try await store.retire(scope) } @@ -390,7 +393,121 @@ import Testing #expect(calls[0].metadata == calls[1].metadata && runtime.lastOutcome == .completed) } else { #expect(runtime.draftText == "Synthetic turn" && runtime.lastOutcome == .rejected) + #expect(runtime.lastIssue == .inferenceCapacityUnavailable) } runtime.invalidate() } + + @Test(arguments: [ + (ResearchAPIError.RejectionReason.contextLimit, ChatIssue.contextLimit), + (.freeTierTokenLimit, .freeTierTokenLimit), (.usageLimit, .usageLimit), (.modelUnavailable, .modelUnavailable), + (.unsupportedValue, .unsupportedValue), (.imageDescriptionUnavailable, .imageDescriptionUnavailable), + ]) + func classifiedRejectionRestoresTextAndImagesBeforePublishingIssue( + _ reason: ResearchAPIError.RejectionReason, _ issue: ChatIssue + ) async { + let (runtime, service, _) = fixture() + let image = ChatImageAttachment(data: Data([1, 2, 3]), contentType: "image/png") + let draft = ChatDraft(id: runtime.snapshot.draft.id, text: "Restore this turn", images: [image]) + runtime.updateDraft(draft) + await service.setResponseErrors([ + ResearchAPIError.http(status: reason == .imageDescriptionUnavailable ? 503 : 400, reason: reason) + ]) + #expect(runtime.send()); await runtime.waitForCurrentWork() + #expect(runtime.lastIssue == issue && runtime.snapshot.draft == draft) + #expect(runtime.lastOutcome == .rejected && !runtime.hasUnresolvedRun) + #expect(runtime.messages.isEmpty) + runtime.invalidate() + } + + @Test(arguments: [ResponseStatus.failed, .incomplete]) + func failedTerminalShowsFailureAndKeepsPartialText(_ status: ResponseStatus) async throws { + let (runtime, service, _) = fixture() + await service.setScript([ + .created(try chatLifecycle(chatResponse("response", .inProgress))), + .outputTextDelta(try chatDelta("Partial response")), + .failed(try chatLifecycle(chatResponse("response", status))), + ]) + runtime.draftText = "A turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + #expect(runtime.lastOutcome == .failed && runtime.lastIssue == .generationFailed) + #expect(runtime.messages.first { $0.id == "assistant" }?.content?.first?.text == "Partial response") + #expect(!runtime.hasUnresolvedRun) + runtime.invalidate() + } + + @Test func failedStreamEventShowsFailureAndKeepsPartialText() async throws { + let (runtime, service, _) = fixture() + let failure = try JSONDecoder().decode(ResponseStreamFailure.self, from: Data(#"{"code":"server_error"}"#.utf8)) + await service.setScript([ + .created(try chatLifecycle(chatResponse("response", .inProgress))), + .outputTextDelta(try chatDelta("Partial response")), .error(failure), + ]) + runtime.draftText = "A turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + #expect(runtime.lastOutcome == .failed && runtime.lastIssue == .generationFailed) + #expect(runtime.messages.first { $0.id == "assistant" }?.content?.first?.text == "Partial response") + runtime.invalidate() + } + + @Test(arguments: [false, true]) + func reasoningStatusTracksItsOwnCompletionBeforeTheAnswerFinishes(final: Bool) async throws { + let (runtime, service, _) = fixture() + var events: [ResponseStreamEvent] = [ + .created(try chatLifecycle(chatResponse("response", .inProgress))), + .reasoningTextDelta(try chatDelta("A thought", item: "reasoning")), + ] + if final { + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + let done = try decoder.decode( + ResponseTextEvent.self, + from: Data( + #"{"item_id":"reasoning","output_index":0,"content_index":0,"text":"A finished thought"}"#.utf8)) + events.append(.reasoningTextDone(done)) + } + events.append(.outputTextDelta(try chatDelta("Answer still streaming"))) + await service.setScript(events) + runtime.draftText = "A turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + let reasoning = try #require(runtime.messages.first { $0.id == "reasoning" }) + #expect(reasoning.status == (final ? "completed" : "in_progress")) + #expect(reasoning.content?.first?.text == (final ? "A finished thought" : "A thought")) + #expect(runtime.hasUnresolvedRun && runtime.lastOutcome == nil) + runtime.invalidate() + } + + @Test func stopMarksOnlyThisRunsOutputsIncompleteAndKeepsPartialText() async throws { + let (runtime, service, _) = fixture() + let older = ConversationItem( + id: "older", responseId: "previous", type: "message", status: "completed", role: "assistant") + await service.setPage(APIPage(data: [older])); await runtime.load(chatID: "conversation") + await service.setScript([ + .created(try chatLifecycle(chatResponse("response", .inProgress))), + .reasoningTextDelta(try chatDelta("Partial reasoning", item: "reasoning")), + .outputTextDelta(try chatDelta("Partial answer")), + ]) + runtime.draftText = "Stop this turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + runtime.stop(); await runtime.waitForCurrentWork() + #expect(runtime.lastOutcome == .cancelled && !runtime.hasUnresolvedRun && runtime.lastIssue == nil) + #expect(runtime.messages.first { $0.id == "assistant" }?.status == "incomplete") + #expect(runtime.messages.first { $0.id == "reasoning" }?.status == "incomplete") + #expect(runtime.messages.first { $0.id == "assistant" }?.content?.first?.text == "Partial answer") + #expect(runtime.messages.first { $0.id == "older" }?.status == "completed") + runtime.invalidate() + } + + @Test(arguments: [ResponseStatus.completed, .cancelled, .failed]) + func recoveredTerminalPublishesItsResolutionAndKeepsQueue(_ status: ResponseStatus) async throws { + let (runtime, service, _) = fixture() + await service.setScript([.created(try chatLifecycle(chatResponse("response", .inProgress)))]) + runtime.draftText = "Interrupted turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + runtime.draftText = "Queued turn"; #expect(runtime.enqueueDraft()) + await service.setRetrieved(chatResponse("response", status)) + await runtime.reconcile() + #expect( + runtime.lastIssue + == (status == .completed + ? .recoveredCompletion : status == .cancelled ? .recoveredStop : .generationFailed)) + #expect(runtime.queuedTurns.count == 1 && runtime.queuedTurns.first?.draft.text == "Queued turn") + #expect(runtime.isQueuePaused && !runtime.hasUnresolvedRun) + #expect(await service.posts.count == 1) + runtime.invalidate() + } } diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Dictation/DictationWorkflowTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Dictation/DictationWorkflowTests.swift index b054a95e6..a8b8e8869 100644 --- a/apps/maple-ios/Tests/MapleAppCoreTests/Dictation/DictationWorkflowTests.swift +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Dictation/DictationWorkflowTests.swift @@ -46,6 +46,18 @@ import Testing } @MainActor @Suite struct DictationWorkflowTests { + @Test(arguments: ["", " \n\t"]) + func emptyTranscriptionShowsNoSpeechIssueAndReleasesDraftWithoutChangingIt(_ text: String) async throws { + let (runtime, dictation, capture, service, _) = dictationFixture() + runtime.draftText = "Keep this draft"; let original = runtime.snapshot.draft + #expect(dictation.start(runtime: runtime)); try await dictationWait { capture.starts == 1 } + dictation.stop(runtime: runtime); try await service.waitForCalls(); await service.complete(text: text) + try await dictationWait { dictation.statuses.isEmpty } + #expect(dictation.issue(for: runtime) == .emptyTranscription) + #expect(runtime.snapshot.draft == original && runtime.canSend && !runtime.hasUnresolvedRun) + dictation.invalidate(); runtime.invalidate() + } + @Test func pendingTextUsesStopMarkerDespiteLaterCaretAndEditsAndNeverSends() async throws { let (runtime, dictation, capture, service, _) = dictationFixture() runtime.draftText = "Hello world"; dictationEditor(runtime, dictation, offset: 5) From 45074c06de297f99b2be1d8569e2ace57d25f984 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:46:37 +0000 Subject: [PATCH 06/17] ios-native parity items 14g and 18: persist sidebar and default speech rate --- .../Preferences/LocalPreferences.swift | 23 ++++++++- .../Preferences/PreferencesController.swift | 3 ++ .../Preferences/PreferencesTests.swift | 51 +++++++++++++++++-- 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/apps/maple-ios/Sources/MapleAppCore/Preferences/LocalPreferences.swift b/apps/maple-ios/Sources/MapleAppCore/Preferences/LocalPreferences.swift index 7e29a64f9..869477198 100644 --- a/apps/maple-ios/Sources/MapleAppCore/Preferences/LocalPreferences.swift +++ b/apps/maple-ios/Sources/MapleAppCore/Preferences/LocalPreferences.swift @@ -5,7 +5,7 @@ public enum MapleChatFont: String, Codable, CaseIterable, Sendable { case manrop /// This is the playback rate. Request synthesis at speed 1 to avoid applying it twice. public enum MaplePlaybackSpeed: Double, Codable, CaseIterable, Sendable { - case normal = 1, relaxedFast = 1.25, fast = 1.5, double = 2 + case normal = 1, slightlyFast = 1.2, relaxedFast = 1.25, fast = 1.5, double = 2 } /// The supported provider identifiers, deliberately independent of translated labels. @@ -27,14 +27,33 @@ public struct LocalPreferences: Codable, Equatable, Sendable { public var webSearchPreferred: Bool public var voice: MapleVoice public var playbackSpeed: MaplePlaybackSpeed + public var sidebarIsOpen: Bool public init( theme: MapleTheme = .system, chatFont: MapleChatFont = .manrope, selectedModelID: String = "auto:quick", webSearchPreferred: Bool = true, - voice: MapleVoice = .casualFemale, playbackSpeed: MaplePlaybackSpeed = .normal + voice: MapleVoice = .casualFemale, playbackSpeed: MaplePlaybackSpeed = .slightlyFast, + sidebarIsOpen: Bool = true ) { self.theme = theme; self.chatFont = chatFont; self.selectedModelID = selectedModelID self.webSearchPreferred = webSearchPreferred; self.voice = voice; self.playbackSpeed = playbackSpeed + self.sidebarIsOpen = sidebarIsOpen + } + + private enum CodingKeys: String, CodingKey { + case theme, chatFont, selectedModelID, webSearchPreferred, voice, playbackSpeed, sidebarIsOpen + } + + public init(from decoder: any Decoder) throws { + let record = try decoder.container(keyedBy: CodingKeys.self) + theme = try record.decode(MapleTheme.self, forKey: .theme) + chatFont = try record.decode(MapleChatFont.self, forKey: .chatFont) + selectedModelID = try record.decode(String.self, forKey: .selectedModelID) + webSearchPreferred = try record.decode(Bool.self, forKey: .webSearchPreferred) + voice = try record.decode(MapleVoice.self, forKey: .voice) + playbackSpeed = try record.decode(MaplePlaybackSpeed.self, forKey: .playbackSpeed) + // Existing records predate the sidebar choice; their selected speech rate remains authoritative. + sidebarIsOpen = record.contains(.sidebarIsOpen) ? try record.decode(Bool.self, forKey: .sidebarIsOpen) : true } public static func isValidModelID(_ value: String) -> Bool { diff --git a/apps/maple-ios/Sources/MapleAppCore/Preferences/PreferencesController.swift b/apps/maple-ios/Sources/MapleAppCore/Preferences/PreferencesController.swift index f5a944cea..1563b5275 100644 --- a/apps/maple-ios/Sources/MapleAppCore/Preferences/PreferencesController.swift +++ b/apps/maple-ios/Sources/MapleAppCore/Preferences/PreferencesController.swift @@ -38,6 +38,9 @@ import Observation @discardableResult public func setWebSearchPreferred(_ enabled: Bool) -> Bool { update { $0.webSearchPreferred = enabled } } + @discardableResult public func setSidebarIsOpen(_ isOpen: Bool) -> Bool { + update { $0.sidebarIsOpen = isOpen } + } @discardableResult public func setSelectedModelID(_ id: String) -> Bool { guard LocalPreferences.isValidModelID(id) else { return false } return update { $0.selectedModelID = id } diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Preferences/PreferencesTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Preferences/PreferencesTests.swift index 3500bc62d..935e22f2e 100644 --- a/apps/maple-ios/Tests/MapleAppCoreTests/Preferences/PreferencesTests.swift +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Preferences/PreferencesTests.swift @@ -11,8 +11,9 @@ import Testing let value = LocalPreferences() #expect(value.theme == .system && value.chatFont == .manrope) #expect(value.selectedModelID == "auto:quick" && value.webSearchPreferred) - #expect(value.voice == .casualFemale && value.playbackSpeed == .normal) - #expect(MaplePlaybackSpeed.allCases.map(\.rawValue) == [1, 1.25, 1.5, 2]) + #expect(value.voice == .casualFemale && value.playbackSpeed.rawValue == 1.2) + #expect(value.sidebarIsOpen) + #expect(MaplePlaybackSpeed.allCases.map(\.rawValue) == [1, 1.2, 1.25, 1.5, 2]) #expect( Set(MapleVoice.allCases.map(\.rawValue)) == Set([ @@ -23,7 +24,41 @@ import Testing let object = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(value)) as? [String: Any]) #expect( Set(object.keys) - == Set(["theme", "chatFont", "selectedModelID", "webSearchPreferred", "voice", "playbackSpeed"])) + == Set([ + "theme", "chatFont", "selectedModelID", "webSearchPreferred", "voice", "playbackSpeed", + "sidebarIsOpen", + ])) + } + + @Test(arguments: [1.0, 1.25, 1.5, 2.0]) + func legacyRecordDefaultsSidebarOpenAndPreservesSelectedPlaybackRate(rate: Double) async throws { + let suite = "preferences-tests.\(UUID().uuidString)" + defer { UserDefaults.standard.removePersistentDomain(forName: suite) } + let defaults = try #require(UserDefaults(suiteName: suite)), owner = scope() + let legacy: [String: Any] = [ + "theme": "dark", "chatFont": "serif", "selectedModelID": "auto:powerful", "webSearchPreferred": false, + "voice": "fr_male", "playbackSpeed": rate, + ] + let data = try JSONSerialization.data(withJSONObject: ["version": 1, "preferences": legacy]) + let key = try PreferencesStore.key(owner) + defaults.set(data, forKey: key) + let store = PreferencesStore(suiteName: suite) + var migrated = try await store.activate(owner) + #expect(migrated.sidebarIsOpen && migrated.playbackSpeed.rawValue == rate) + #expect(migrated.theme == .dark && migrated.voice == .frenchMale) + #expect(defaults.data(forKey: key) == data) + migrated.sidebarIsOpen = false + try await store.save(migrated, scope: owner, revision: 1) + try await store.retire(owner) + let restarted = PreferencesStore(suiteName: suite) + #expect(try await restarted.activate(scope()) == migrated) + } + + @Test func sidebarChoiceAndNewPlaybackRateRoundTrip() throws { + let choice = LocalPreferences(sidebarIsOpen: false) + let decoded = try JSONDecoder().decode(LocalPreferences.self, from: JSONEncoder().encode(choice)) + #expect(decoded == choice) + #expect(!decoded.sidebarIsOpen && decoded.playbackSpeed.rawValue == 1.2) } @Test func originAndPrincipalAreIsolatedAndLogoutRetainsChoicesAcrossRestart() async throws { @@ -107,6 +142,13 @@ import Testing record["voice"] = "not-a-provider-voice" defaults.set(try JSONSerialization.data(withJSONObject: ["version": 1, "preferences": record]), forKey: key) await #expect(throws: PreferencesIssue.invalidRecord) { try await store.activate(owner) } + record["voice"] = "casual_female" + for invalidSidebar in [NSNull(), "false"] as [Any] { + record["sidebarIsOpen"] = invalidSidebar + defaults.set( + try JSONSerialization.data(withJSONObject: ["version": 1, "preferences": record]), forKey: key) + await #expect(throws: PreferencesIssue.invalidRecord) { try await store.activate(owner) } + } } @Test func confirmedOldAccountDeletionDoesNotDisturbAnotherSignedInAccount() async throws { @@ -146,12 +188,15 @@ import Testing #expect(controller.setPlaybackSpeed(.double)) #expect(controller.setVoice(.italianFemale)) #expect(controller.setWebSearchPreferred(false)) + #expect(controller.setSidebarIsOpen(false)) try await controller.retire() #expect(controller.state == .retired && !controller.setTheme(.light)) + #expect(!controller.setSidebarIsOpen(true)) let replacement = AccountScope(apiOrigin: scope.apiOrigin, principalID: scope.principalID) let retained = try await store.activate(replacement) #expect(retained.theme == .dark && retained.playbackSpeed == .double) #expect(retained.voice == .italianFemale && !retained.webSearchPreferred) + #expect(!retained.sidebarIsOpen) } @Test func lateLoadCannotPublishAfterRetirement() async throws { From 99da4af654acf35d2e729b5e3f130e155b00a595 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:46:37 +0000 Subject: [PATCH 07/17] ios-native parity items 16 and 17: align email signup and legal footer --- .../MapleAppCore/Auth/AuthCeremonies.swift | 8 ++-- .../MapleAppCore/Auth/SDKSessionService.swift | 2 +- .../MapleAppCore/Auth/SessionController.swift | 7 ++- .../Sources/MapleAppUI/Auth/SignInView.swift | 47 +++++++++++++----- .../MapleAppUI/Resources/Auth.xcstrings | 36 ++++++++++++++ .../SessionControllerTests.swift | 48 ++++++++++++++++++- .../UITests/AuthWorkflowUITests.swift | 32 +++++++++++++ 7 files changed, 160 insertions(+), 20 deletions(-) diff --git a/apps/maple-ios/Sources/MapleAppCore/Auth/AuthCeremonies.swift b/apps/maple-ios/Sources/MapleAppCore/Auth/AuthCeremonies.swift index 9ea9f973d..dcb08eb76 100644 --- a/apps/maple-ios/Sources/MapleAppCore/Auth/AuthCeremonies.swift +++ b/apps/maple-ios/Sources/MapleAppCore/Auth/AuthCeremonies.swift @@ -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 @@ -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 @@ -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" diff --git a/apps/maple-ios/Sources/MapleAppCore/Auth/SDKSessionService.swift b/apps/maple-ios/Sources/MapleAppCore/Auth/SDKSessionService.swift index 74c012adc..9ea051819 100644 --- a/apps/maple-ios/Sources/MapleAppCore/Auth/SDKSessionService.swift +++ b/apps/maple-ios/Sources/MapleAppCore/Auth/SDKSessionService.swift @@ -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 { diff --git a/apps/maple-ios/Sources/MapleAppCore/Auth/SessionController.swift b/apps/maple-ios/Sources/MapleAppCore/Auth/SessionController.swift index 8b46ba73e..73c4da6a1 100644 --- a/apps/maple-ios/Sources/MapleAppCore/Auth/SessionController.swift +++ b/apps/maple-ios/Sources/MapleAppCore/Auth/SessionController.swift @@ -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? { diff --git a/apps/maple-ios/Sources/MapleAppUI/Auth/SignInView.swift b/apps/maple-ios/Sources/MapleAppUI/Auth/SignInView.swift index 6d0bfb1fc..1c238ed36 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Auth/SignInView.swift +++ b/apps/maple-ios/Sources/MapleAppUI/Auth/SignInView.swift @@ -10,9 +10,9 @@ @State private var mode = Mode.email @State private var email = "" @State private var password = "" - @State private var name = "" + @State private var externalLink: URL? @FocusState private var focused: Field? - private enum Field { case name, email, password } + private enum Field { case email, password } var body: some View { NavigationStack { @@ -28,14 +28,6 @@ ) .font(MapleTypography.title) VStack(spacing: 16) { - if mode == .signup { - TextField( - MapleStrings.text("Name (optional)"), text: $name, - prompt: Text(MapleStrings.text("Name (optional)")).foregroundStyle( - MapleFieldPrompt()) - ) - .textContentType(.name).focused($focused, equals: .name) - } TextField( mode == .guest ? MapleStrings.text("Guest account ID") : MapleStrings.text("Email"), text: $email, @@ -57,6 +49,10 @@ .onSubmit { submit() }.accessibilityIdentifier("auth.password") } .textFieldStyle(.roundedBorder) + if mode == .signup { + Text("Use at least eight characters.", tableName: "Auth", bundle: .module) + .font(.footnote).foregroundStyle(.secondary) + } if let issue = session.issue { Text(message(for: issue)).foregroundStyle(MapleRedText()).accessibilityIdentifier( "auth.error") @@ -90,16 +86,42 @@ ) { mode = mode == .guest ? .email : .guest; email = ""; password = "" }.disabled(session.phase == .authenticating) + VStack(spacing: 8) { + Text( + String( + localized: "auth.copyright", + defaultValue: + "© \(String(Calendar(identifier: .gregorian).component(.year, from: Date()))) Maple Privacy Labs Inc.", + table: "Auth", bundle: .module) + ) + .foregroundStyle(.secondary) + HStack(spacing: 24) { + Button { + externalLink = URL(string: "https://trymaple.ai/terms") + } label: { + Text("Terms", tableName: "Auth", bundle: .module) + .frame(minWidth: 44, minHeight: 44) + }.accessibilityIdentifier("auth.terms") + Button { + externalLink = URL(string: "https://trymaple.ai/privacy") + } label: { + Text("Privacy", tableName: "Auth", bundle: .module) + .frame(minWidth: 44, minHeight: 44) + }.accessibilityIdentifier("auth.privacy") + } + } + .font(.footnote).frame(maxWidth: .infinity) } .padding(28).frame(maxWidth: 480).frame(maxWidth: .infinity) } .scrollDismissesKeyboard(.interactively) } + .externalLinks($externalLink) } private var canSubmit: Bool { session.phase == .signedOut && !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - && !password.isEmpty + && !password.isEmpty && (mode != .signup || AuthCeremonies.validPassword(password)) } private func submit() { @@ -112,8 +134,7 @@ case .email: await session.signIn(email: submittedID, password: submittedPassword) case .guest: await session.signInGuest(id: submittedID, password: submittedPassword) case .signup: - await session.signUp( - email: submittedID, password: submittedPassword, name: name.isEmpty ? nil : name) + await session.signUp(email: submittedID, password: submittedPassword) } password = "" } diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/Auth.xcstrings b/apps/maple-ios/Sources/MapleAppUI/Resources/Auth.xcstrings index 4001f7d02..df151a993 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Resources/Auth.xcstrings +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/Auth.xcstrings @@ -289,6 +289,18 @@ } } }, + "Privacy": { + "comment": "Auth footer link to Privacy policy.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Privacy" + } + } + } + }, "Request deletion code": { "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", "extractionState": "manual", @@ -373,6 +385,18 @@ } } }, + "Terms": { + "comment": "Auth footer link to Terms of service.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terms" + } + } + } + }, "The current password is incorrect.": { "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", "extractionState": "manual", @@ -576,6 +600,18 @@ } } } + }, + "auth.copyright": { + "comment": "Auth footer with the current Gregorian year as a string placeholder.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "© %@ Maple Privacy Labs Inc." + } + } + } } }, "version": "1.0" diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/SessionControllerTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/SessionControllerTests.swift index aff2ec9f9..d65362caa 100644 --- a/apps/maple-ios/Tests/MapleAppCoreTests/SessionControllerTests.swift +++ b/apps/maple-ios/Tests/MapleAppCoreTests/SessionControllerTests.swift @@ -4,6 +4,7 @@ import Testing @testable import MapleAppCore private actor SessionFixture: SessionServing { + struct SignupRequest: Equatable, Sendable { let email: String; let password: String; let name: String? } let principal = "test-principal" var current: SessionIdentity? var failUser = false @@ -12,6 +13,7 @@ private actor SessionFixture: SessionServing { var userWaiter: CheckedContinuation? var entryWaiter: CheckedContinuation? var loginCount = 0 + var signupRequests: [SignupRequest] = [] var verified = false init(signedIn: Bool = true) { if signedIn { @@ -38,7 +40,10 @@ private actor SessionFixture: SessionServing { current = SessionIdentity(origin: "http://127.0.0.1:1234", principal: principal, generation: UUID()) } func signInGuest(id: String, password: String) { signIn(email: "", password: password) } - func signUp(email: String, password: String, name: String?) { signIn(email: email, password: password) } + func signUp(email: String, password: String, name: String?) { + signupRequests.append(.init(email: email, password: password, name: name)) + signIn(email: email, password: password) + } func signOut() { current = nil } } @@ -61,7 +66,48 @@ private actor LifecycleFixture: AccountDataLifecycle { @MainActor private final class RetirementFailureControl { var shouldFail = true } +enum SignupPasswordSample: String, Sendable { + case empty, sevenASCII, sixUTF16, aboveByteLimit, eightASCII, eightUTF16, maximumBytes + + var value: String { + switch self { + case .empty: "" + case .sevenASCII: "seven77" + case .sixUTF16: "😀😀😀" + case .aboveByteLimit: String(repeating: "a", count: 16 * 1024 + 1) + case .eightASCII: "eight888" + case .eightUTF16: "😀😀😀😀" + case .maximumBytes: String(repeating: "a", count: 16 * 1024) + } + } +} + @MainActor struct SessionControllerTests { + @Test(arguments: [SignupPasswordSample.empty, .sevenASCII, .sixUTF16, .aboveByteLimit]) + func signupRejectsInvalidPasswordWithoutSendingIt(sample: SignupPasswordSample) async { + let service = SessionFixture(signedIn: false), storage = LifecycleFixture() + let controller = SessionController(service: service, storage: storage) + await controller.restore() + await controller.signUp(email: "fixture@example.test", password: sample.value) + #expect(controller.phase == .signedOut && controller.issue == .invalidInput) + #expect(await service.signupRequests.isEmpty) + #expect(await storage.activations.isEmpty) + #expect(await service.identity() == nil) + } + + @Test(arguments: [SignupPasswordSample.eightASCII, .eightUTF16, .maximumBytes]) + func signupUsesResearchNameAndPasswordUTF16Contract(sample: SignupPasswordSample) async { + let service = SessionFixture(signedIn: false), storage = LifecycleFixture() + let controller = SessionController(service: service, storage: storage) + await controller.restore() + await controller.signUp(email: "fixture@example.test", password: sample.value) + #expect(controller.phase == .signedIn && controller.issue == nil) + #expect( + await service.signupRequests + == [.init(email: "fixture@example.test", password: sample.value, name: "ANON")]) + #expect(await storage.activations.count == 1) + } + @Test func verificationRefreshPreservesScopeAndIgnoresReplacementAuthority() async throws { let service = SessionFixture(); let storage = LifecycleFixture() let controller = SessionController(service: service, storage: storage) diff --git a/apps/maple-ios/UITests/AuthWorkflowUITests.swift b/apps/maple-ios/UITests/AuthWorkflowUITests.swift index 5cff82eff..dc89bc21b 100644 --- a/apps/maple-ios/UITests/AuthWorkflowUITests.swift +++ b/apps/maple-ios/UITests/AuthWorkflowUITests.swift @@ -78,6 +78,38 @@ import XCTest } private func closeForm() { tap("auth.fixture.close") } + func testEmailSignupRequiresEightCharactersAndLegalLinksUseConfirmation() { + launchFixture() + tap("auth.terms") + XCTAssertTrue(app.alerts["Open external link?"].waitForExistence(timeout: 5)) + XCTAssertTrue( + app.alerts.staticTexts.containing( + NSPredicate( + format: "label CONTAINS %@", "https://trymaple.ai/terms") + ).firstMatch.exists) + app.alerts.buttons["Cancel"].tap() + let year = Calendar(identifier: .gregorian).component(.year, from: Date()) + XCTAssertTrue(app.staticTexts["© \(year) Maple Privacy Labs Inc."].exists) + tap("auth.privacy") + XCTAssertTrue(app.alerts["Open external link?"].waitForExistence(timeout: 5)) + XCTAssertTrue( + app.alerts.staticTexts.containing( + NSPredicate( + format: "label CONTAINS %@", "https://trymaple.ai/privacy") + ).firstMatch.exists) + app.alerts.buttons["Cancel"].tap() + app.swipeDown() + tap("Create an account") + XCTAssertFalse(app.textFields["Name (optional)"].exists) + text("auth.identifier", "fixture@example.test") + text("auth.password", "1234567", secure: true) + XCTAssertFalse(app.buttons["auth.submit"].isEnabled) + app.secureTextFields["auth.password"].typeText("8") + let valid = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "enabled == true"), object: app.buttons["auth.submit"]) + XCTAssertEqual(XCTWaiter.wait(for: [valid], timeout: 5), .completed) + } + /// Terminal refresh rejection is injected; authority events, ownership /// retirement, and the native shipping sign-in screen are actual code. func testTerminalRefreshRejectionRetiresAuthorityAndShowsSignInWithoutAutomaticRetry() { From 225a0aca486d8571110d8eed731eca89326929be Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:47:10 +0000 Subject: [PATCH 08/17] ios-native parity items 4 7-9 11-13: align chat presentation and composer copy --- .../Chat/ChatRecoveryControls.swift | 84 +++-- .../Sources/MapleAppUI/Chat/ChatScreen.swift | 148 +++++++- .../MapleAppUI/Dictation/Dictation.xcstrings | 12 + .../Dictation/DictationControls.swift | 2 + .../MapleAppUI/Rendering/MessageView.swift | 40 ++- .../Resources/Localizable.xcstrings | 338 +++++++++++++++--- .../Composer/ComposerFlowTests.swift | 61 ++++ .../UITests/ChatWorkflowUITests.swift | 4 +- apps/maple-ios/UITests/RendererUITests.swift | 2 + 9 files changed, 593 insertions(+), 98 deletions(-) diff --git a/apps/maple-ios/Sources/MapleAppUI/Chat/ChatRecoveryControls.swift b/apps/maple-ios/Sources/MapleAppUI/Chat/ChatRecoveryControls.swift index d1d9c8eec..f125f2f9c 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Chat/ChatRecoveryControls.swift +++ b/apps/maple-ios/Sources/MapleAppUI/Chat/ChatRecoveryControls.swift @@ -1,14 +1,71 @@ +import MapleAppCore + +/// Read-only chat feedback copied from the Research client. +enum ChatRecoveryPresentation { + static func message(_ issue: ChatIssue, plan: BillingPlan = .unknown) -> String { + switch issue { + case .offline: MapleStrings.text("You’re offline. Your draft is saved when protected storage is available.") + case .storageUnavailable: + MapleStrings.text("Your draft hasn’t been saved. Unlock your device and try again.") + case .uncertainResponse: + MapleStrings.text("The response is still being checked. Your message will not be sent again.") + case .cancellationUnavailable: + MapleStrings.text("Stop hasn’t been confirmed. Check the response to try again.") + case .usageLimit: + switch plan { + case .free: + MapleStrings.text("You've reached your daily usage limit. Upgrade to Pro for more chats.") + case .paid(.pro): + MapleStrings.text("You've reached your monthly Pro limit. Upgrade to Max for 10x more usage.") + default: + MapleStrings.text("You've reached your monthly usage limit. Please wait for the next billing cycle.") + } + case .freeTierTokenLimit: + MapleStrings.text( + "This conversation is too long for the free tier. Upgrade to Pro for longer conversations.") + case .contextLimit: MapleStrings.text("Your message exceeds the context limit for this model.") + case .modelUnavailable: MapleStrings.text("This model or feature is not available on your current plan.") + case .generationFailed: MapleStrings.text("Failed to generate response. Please try again.") + case .stoppedBeforeSending: MapleStrings.text("Stopped before sending. Your message was restored.") + case .stoppingWhenResponseReady: MapleStrings.text("Stopping as soon as the response is ready…") + case .responseFinishedBeforeStop: MapleStrings.text("The response finished before it could be stopped.") + case .recoveredCompletion: + MapleStrings.text("The response completed after the connection was restored. Queued messages were kept.") + case .recoveredStop: MapleStrings.text("The interrupted response stopped. Queued messages were kept.") + case .imageDescriptionUnavailable: + MapleStrings.text( + "Image description is temporarily unavailable. Your message and images were restored; please try again." + ) + case .inferenceCapacityUnavailable: + MapleStrings.text( + "Inference capacity is temporarily unavailable. Your message was restored. Please try again shortly.") + case .historyUnavailable: MapleStrings.text("Couldn’t load the conversation. Try again when connected.") + case .invalidImages: MapleStrings.text("Check the image attachments and try again.") + case .draftTooLarge: MapleStrings.text("This message is too large. Shorten it and try again.") + case .signedOut: MapleStrings.text("Your session has ended. Sign in again.") + default: MapleStrings.text("The request couldn’t finish. Your unsent work is preserved.") + } + } + + static var contextLimitMessage: String { + MapleStrings.text( + "Your message exceeds the context limit for the current model.\n\nHere's what you can try:\nShorten your message - Try reducing the amount of text or content" + ) + } +} + #if os(iOS) - import MapleAppCore import SwiftUI /// Shared shipping presentation for recoverable account-owned chat work. struct ChatRecoveryControls: View { @Bindable var runtime: ChatRuntime + var plan: BillingPlan = .unknown var body: some View { if let issue = runtime.lastIssue { - Text(issueMessage(issue)).font(.footnote).foregroundStyle(MapleRedText()).accessibilityIdentifier( - "chat.error") + Text(ChatRecoveryPresentation.message(issue, plan: plan)) + .font(.footnote).foregroundStyle(MapleRedText()) + .accessibilityIdentifier("chat.error") } if runtime.phase == .recovering, runtime.lastIssue != nil { Button(MapleStrings.text("Check response"), systemImage: "arrow.clockwise") { @@ -19,26 +76,7 @@ } if runtime.snapshot.unsentTurn != nil { Button(MapleStrings.text("Restore unsent message")) { runtime.restoreUnsentTurn() } - } - } - private func issueMessage(_ issue: ChatIssue) -> String { - switch issue { - case .offline: MapleStrings.text("You’re offline. Your draft is saved when protected storage is available.") - case .storageUnavailable: - MapleStrings.text("Your draft hasn’t been saved. Unlock your device and try again.") - case .uncertainResponse: - MapleStrings.text("The response is still being checked. Your message will not be sent again.") - case .cancellationUnavailable: - MapleStrings.text("Stop hasn’t been confirmed. Check the response to try again.") - case .usageLimit: MapleStrings.text("You’ve reached your usage limit.") - case .contextLimit: - MapleStrings.text("This conversation is too long for the selected model. Start a new chat.") - case .modelUnavailable: MapleStrings.text("This model is unavailable. Choose another model.") - case .historyUnavailable: MapleStrings.text("Couldn’t load the conversation. Try again when connected.") - case .invalidImages: MapleStrings.text("Check the image attachments and try again.") - case .draftTooLarge: MapleStrings.text("This message is too large. Shorten it and try again.") - case .signedOut: MapleStrings.text("Your session has ended. Sign in again.") - default: MapleStrings.text("The request couldn’t finish. Your unsent work is preserved.") + .frame(minHeight: 44) } } } diff --git a/apps/maple-ios/Sources/MapleAppUI/Chat/ChatScreen.swift b/apps/maple-ios/Sources/MapleAppUI/Chat/ChatScreen.swift index d27884c18..6f0930ac5 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Chat/ChatScreen.swift +++ b/apps/maple-ios/Sources/MapleAppUI/Chat/ChatScreen.swift @@ -1,4 +1,5 @@ import Foundation +import MapleAppCore import MapleSDK struct ChatTranscriptRow: Identifiable { @@ -48,6 +49,30 @@ struct ChatTranscriptRow: Identifiable { } } +enum ChatTranscriptPresentation { + static func isGenerating(_ phase: ChatRunPhase?) -> Bool { phase == .sending || phase == .streaming } + + static func showsPending(phase: ChatRunPhase?, items: [ConversationItem]) -> Bool { + isGenerating(phase) + && !items.contains { + $0.role == "assistant" || $0.type == "reasoning" || ChatTranscriptRow.isTool($0) + } + } + + static func thoughtDuration(_ text: String) -> Int { + let words = text.split(whereSeparator: { $0.isWhitespace }).count + return max(1, Int((Double(words) / 26).rounded())) + } + + static func thinkingLabel(text: String, streaming: Bool, locale: Locale = .current) -> String { + if streaming { return MapleStrings.text("Thinking", locale: locale) } + let seconds = thoughtDuration(text) + return String( + localized: "chat.thought-duration", defaultValue: "Thought for \(seconds) seconds", + table: "Localizable", bundle: .module, locale: locale) + } +} + enum ChatComposerPresentation { static func expanded(newConversation: Bool, editingQueue: Bool, focused: Bool, keyboardVisible: Bool?) -> Bool { newConversation || editingQueue || (focused && keyboardVisible != false) @@ -66,7 +91,11 @@ enum ChatComposerPresentation { @Environment(\.horizontalSizeClass) private var sizeClass @Environment(\.layoutDirection) private var layoutDirection @Environment(\.mapleLaunchTransition) private var launchTransition - @State private var columns: NavigationSplitViewVisibility = .all + private var columns: Binding { + Binding( + get: { environment.preferences?.value.sidebarIsOpen == false ? .detailOnly : .all }, + set: { environment.preferences?.setSidebarIsOpen($0 != .detailOnly) }) + } @Environment(\.accessibilityReduceMotion) private var reduceMotion @AccessibilityFocusState private var menuButtonFocused: Bool @State private var compactMenuLease = CompactMenuLease() @@ -74,7 +103,7 @@ enum ChatComposerPresentation { var body: some View { Group { if sizeClass == .regular { - NavigationSplitView(columnVisibility: $columns) { + NavigationSplitView(columnVisibility: columns) { library(wide: true) } detail: { NavigationStack { @@ -265,6 +294,7 @@ enum ChatComposerPresentation { await environment.selectNewChat(projectID: projectID) } }, + selectedChatID: environment.selectedChat?.conversationID, settings: { AnyView(SettingsScreen(environment: environment, context: context)) }, projectExpansion: context.projectExpansion, menu: context.menu, @@ -326,7 +356,7 @@ enum ChatComposerPresentation { context.scope == environment.session.account?.scope else { return } if sizeClass == .regular { - columns = columns == .all ? .detailOnly : .all + columns.wrappedValue = columns.wrappedValue == .all ? .detailOnly : .all } else { environment.menuPresented = true } @@ -391,6 +421,8 @@ enum ChatComposerPresentation { #endif @State private var queuePresented = false @State private var modelPresented = false + @State private var contextLimitPresented = false + @State private var contextLimitPending = false @State private var externalLink: URL? @State private var browserPresentationID = UUID() @State private var mediaNotice: MediaFeature? @@ -720,6 +752,8 @@ enum ChatComposerPresentation { theme: preferences.value.theme.messageRenderTheme, font: preferences.value.chatFont.messageRenderFont, isReplyReady: item.status == "completed" || !runtime.hasUnresolvedRun, + isGenerating: turn.id == turns.last?.id + && ChatTranscriptPresentation.isGenerating(runtime.phase), speechAccess: account.speechAccess, onReadAloud: { environment.readAloud(item, runtime: runtime) }, onSpeechUnavailable: { mediaNotice = .speech } @@ -745,6 +779,14 @@ enum ChatComposerPresentation { } #endif } + if turn.id == turns.last?.id, + ChatTranscriptPresentation.showsPending( + phase: runtime.phase, items: turn.items) + { + ProgressView() + .accessibilityLabel(MapleStrings.text("Maple is responding")) + .accessibilityIdentifier("chat.pending") + } } .frame( maxWidth: .infinity, @@ -887,10 +929,20 @@ enum ChatComposerPresentation { if !menuState.occluded { readingAnchor = nil; pendingScrollMessage = nil } } .environment(\.retainedMessageRendering, retainsMessageRendering) - .tracksChatPresentation(queuePresented || modelPresented || mediaNotice != nil) + .tracksChatPresentation(queuePresented || modelPresented || mediaNotice != nil || contextLimitPresented) .environment(\.chatPresentationChanged) { id, presented in environment.setPresentationBlocked(presented, id: id, scope: runtime.scope) } + .onChange(of: runtime.lastIssue, initial: true) { _, issue in + // Rejected drafts have already been restored by the runtime. + contextLimitPending = issue == .contextLimit + presentContextLimitIfNeeded() + } + .alert(MapleStrings.text("Message Too Large"), isPresented: $contextLimitPresented) { + Button(MapleStrings.text("Got it"), role: .cancel) {} + } message: { + Text(ChatRecoveryPresentation.contextLimitMessage) + } .onChange(of: composerFocused) { _, focused in #if DEBUG ComposerFocusTrace.record("shipping.focusChanged=\(focused)") @@ -901,6 +953,8 @@ enum ChatComposerPresentation { if occluded { composerFocused = false; correctionTask?.cancel(); acceptedTurnScrollTask?.cancel() cancelFollowTasks(); userScrollPhase = false + } else { + presentContextLimitIfNeeded() } } .onChange(of: menuState.returnRevision) { _, _ in @@ -910,6 +964,7 @@ enum ChatComposerPresentation { } .onAppear { environment.updatePresentationVisibility() + presentContextLimitIfNeeded() if !menuState.occluded, runtime.conversationID == nil { composerFocused = true } } .externalLinks($externalLink) { presented in @@ -974,6 +1029,11 @@ enum ChatComposerPresentation { } } + private func presentContextLimitIfNeeded() { + guard contextLimitPending, currentProjection, !menuState.occluded else { return } + contextLimitPending = false; contextLimitPresented = true + } + private func composer(maximumHeight: CGFloat) -> some View { let owner = runtime.snapshot.draft.id let projectionOwner = runtime.snapshot.storageOwner, scope = runtime.scope @@ -1019,7 +1079,7 @@ enum ChatComposerPresentation { if account.chatAccess != .allowed { AccountAccessNotice(account: account) } - ChatRecoveryControls(runtime: runtime) + ChatRecoveryControls(runtime: runtime, plan: account.plan) if let importer = environment.imageImporters[ObjectIdentifier(runtime)] { DraftImageStrip(importer: importer) if let issue = importer.issue { ImageImportIssueView(issue: issue) } @@ -1051,6 +1111,20 @@ enum ChatComposerPresentation { .allowsHitTesting(false).accessibilityHidden(true) } .layoutValue(key: ComposerEditorLayoutKey.self, value: true) + HStack(spacing: 4) { + MaplePrivacyLock().environment(\.mapleLaunchTransition, nil).accessibilityHidden(true) + Text(MapleStrings.text("Encrypted and private at every step")) + } + .font(.footnote).foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("composer.privacy") + if runtime.conversationID != nil { + Text(MapleStrings.text("AI can make mistakes. Check important info.")) + .font(.footnote).foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("composer.disclaimer") + } } // Keep the trailing controls clear of the keyboard's prediction strip. .padding(padding).padding(.bottom, bottomPadding).frame(maxWidth: 874).frame(maxWidth: .infinity) @@ -1075,7 +1149,7 @@ enum ChatComposerPresentation { runtime.scope == account.scope && runtime.snapshot.draft.id == owner }, isCurrentPresentation: { currentProjection && !menuState.occluded }, - placeholder: MapleStrings.text("Message Maple"), + placeholder: MapleStrings.text("Message Maple..."), isCompact: !composerExpanded, externalSelection: environment.dictation?.editorState(runtime: runtime, draftID: owner), onChange: { state, edits in @@ -1115,17 +1189,26 @@ enum ChatComposerPresentation { onUnavailable: { mediaNotice = .images }) } } + private var modelShortName: String { + account.selectedModel.map { $0.shortName.isEmpty ? $0.label : $0.shortName } ?? account.selectedModelID + } + private var modelButton: some View { Button { environment.dictation?.stopCaptureForNavigation() composerFocused = false modelPresented = true } label: { - Image(systemName: "slider.horizontal.3") - .frame(minWidth: 44, minHeight: 44) - .contentShape(Rectangle()) + HStack(spacing: 4) { + Image(systemName: "slider.horizontal.3") + Text(verbatim: modelShortName) + .font(.footnote.weight(.medium)) + } + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) } .accessibilityLabel(Text("Chat options", tableName: "Settings", bundle: .module)) + .accessibilityValue(Text(verbatim: modelShortName)) .accessibilityIdentifier("chat.model") } @ViewBuilder private var microphoneButton: some View { @@ -1188,12 +1271,16 @@ enum ChatComposerPresentation { let theme: MessageRenderTheme let font: MessageRenderFont let isReplyReady: Bool + let isGenerating: Bool let speechAccess: AccountAccess let onReadAloud: () -> Void let onSpeechUnavailable: () -> Void let onOpenLink: (URL) -> Void @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var appeared = false + @State private var copied = false + @State private var copyRevision = UUID() + @State private var thinkingPulse = false private var text: String { (item.content ?? []).compactMap(\.text).joined(separator: "\n\n") } private var readableContent: Bool { !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -1205,6 +1292,10 @@ enum ChatComposerPresentation { } private var isUserMessage: Bool { item.role == "user" && item.type == "message" } + private var isThinking: Bool { + item.type == "reasoning" && isGenerating + && (["in_progress", "streaming"].contains(item.status ?? "") || item.status == nil) + } var body: some View { HStack(alignment: .top, spacing: 0) { @@ -1224,6 +1315,11 @@ enum ChatComposerPresentation { } else { messageContent } + if item.status == "incomplete" { + Text(MapleStrings.text("Chat Canceled")) + .font(.caption).foregroundStyle(.secondary) + .accessibilityIdentifier("message.cancelled." + item.id) + } if item.type != "reasoning", !ChatTranscriptRow.isTool(item), !text.isEmpty { messageActions } @@ -1231,9 +1327,17 @@ enum ChatComposerPresentation { .frame(maxWidth: isUserMessage ? 650 : .infinity, alignment: isUserMessage ? .trailing : .leading) } .frame(maxWidth: .infinity, alignment: isUserMessage ? .trailing : .leading) - .opacity(revealsContent && !appeared && !reduceMotion ? 0 : 1) + .opacity(readableContent && revealsContent && !appeared && !reduceMotion ? 0 : 1) .onAppear { revealContent() } .onChange(of: readableContent) { _, _ in revealContent() } + .task(id: copyRevision) { + guard copied else { return } + try? await Task.sleep(nanoseconds: 2_000_000_000) + if !Task.isCancelled { copied = false } + } + .onChange(of: isThinking, initial: true) { _, thinking in + thinkingPulse = thinking + } .accessibilityElement(children: .contain) .accessibilityIdentifier((item.role == "assistant" ? "assistant.message." : "message.") + item.id) } @@ -1244,10 +1348,20 @@ enum ChatComposerPresentation { MessageImages(content: item.content ?? []) } if item.type == "reasoning" { - DisclosureGroup(MapleStrings.text("Thinking")) { + DisclosureGroup { MessageView( text: text, theme: theme, font: font, accountGeneration: generation, onOpenLink: onOpenLink) + } label: { + Text(ChatTranscriptPresentation.thinkingLabel(text: text, streaming: isThinking)) + .foregroundStyle(.secondary) + .opacity(isThinking && thinkingPulse && !reduceMotion ? 0.5 : 1) + .animation( + isThinking && !reduceMotion + ? .easeInOut(duration: 1).repeatForever(autoreverses: true) : nil, + value: thinkingPulse) } + .frame(minHeight: 44) + .accessibilityIdentifier("message.thinking." + item.id) } else if ChatTranscriptRow.isTool(item) { ToolActivityView( item: item, result: toolResult, theme: theme, font: font, generation: generation, @@ -1255,16 +1369,26 @@ enum ChatComposerPresentation { } else if !text.isEmpty { MessageView( text: text, theme: theme, font: font, accountGeneration: generation, onOpenLink: onOpenLink) + } else if item.role == "assistant", isGenerating, + !["completed", "incomplete", "failed"].contains(item.status ?? "") + { + ProgressView() + .accessibilityLabel(MapleStrings.text("Maple is responding")) + .accessibilityIdentifier("message.pending." + item.id) } } } private var messageActions: some View { HStack(spacing: 8) { - Button(MapleStrings.text("Copy message"), systemImage: "doc.on.doc") { + Button( + MapleStrings.text(copied ? "Copied" : "Copy message"), + systemImage: copied ? "checkmark" : "doc.on.doc" + ) { UIPasteboard.general.setItems( [[UTType.utf8PlainText.identifier: text]], options: [.localOnly: true, .expirationDate: Date().addingTimeInterval(120)]) + copied = true; copyRevision = UUID() }.labelStyle(.iconOnly).buttonStyle(.borderless).frame(minWidth: 44, minHeight: 44) .accessibilityIdentifier("message.copy." + item.id) if item.role == "assistant" { diff --git a/apps/maple-ios/Sources/MapleAppUI/Dictation/Dictation.xcstrings b/apps/maple-ios/Sources/MapleAppUI/Dictation/Dictation.xcstrings index 54bb5d8b3..431b2ed7d 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Dictation/Dictation.xcstrings +++ b/apps/maple-ios/Sources/MapleAppUI/Dictation/Dictation.xcstrings @@ -209,6 +209,18 @@ } } } + }, + "No speech detected. Please try again.": { + "comment": "Dictation result contained no speech; exact Research copy.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No speech detected. Please try again." + } + } + } } }, "version": "1.0" diff --git a/apps/maple-ios/Sources/MapleAppUI/Dictation/DictationControls.swift b/apps/maple-ios/Sources/MapleAppUI/Dictation/DictationControls.swift index 5d07dcf07..9e20a0774 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Dictation/DictationControls.swift +++ b/apps/maple-ios/Sources/MapleAppUI/Dictation/DictationControls.swift @@ -85,6 +85,8 @@ tableName: "Dictation", bundle: .module) case .emptyRecording: Text("No audio was recorded. Your draft is unchanged.", tableName: "Dictation", bundle: .module) + case .emptyTranscription: + Text("No speech detected. Please try again.", tableName: "Dictation", bundle: .module) case .recordingTooLarge: Text( "This recording could not be processed. Record up to five minutes at a time.", diff --git a/apps/maple-ios/Sources/MapleAppUI/Rendering/MessageView.swift b/apps/maple-ios/Sources/MapleAppUI/Rendering/MessageView.swift index 778e08d26..61504541b 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Rendering/MessageView.swift +++ b/apps/maple-ios/Sources/MapleAppUI/Rendering/MessageView.swift @@ -268,18 +268,8 @@ HStack { Text(code.block.language).font(MapleTypography.metadata).foregroundStyle(.secondary) Spacer() - Button { - UIPasteboard.general.setItems( - [[UTType.utf8PlainText.identifier: code.block.source]], - options: [.localOnly: true, .expirationDate: Date().addingTimeInterval(120)]) - } label: { - Image(systemName: "document.on.document") - .frame(minWidth: 44, minHeight: 44) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .textual.textSelectionInteractionExcluded() - .accessibilityLabel(Text("Copy code", bundle: .module)) + MessageCodeCopyButton(source: code.block.source) + .textual.textSelectionInteractionExcluded() } Overflow { InlineText(code.block.source, parser: PreparedMarkup(content: highlighted(code.tokens))) @@ -324,6 +314,32 @@ } } + private struct MessageCodeCopyButton: View { + let source: String + @State private var copied = false + @State private var copyRevision = UUID() + + var body: some View { + Button { + UIPasteboard.general.setItems( + [[UTType.utf8PlainText.identifier: source]], + options: [.localOnly: true, .expirationDate: Date().addingTimeInterval(120)]) + copied = true; copyRevision = UUID() + } label: { + Image(systemName: copied ? "checkmark" : "document.on.document") + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(MapleStrings.text(copied ? "Copied" : "Copy code")) + .task(id: copyRevision) { + guard copied else { return } + try? await Task.sleep(nanoseconds: 2_000_000_000) + if !Task.isCancelled { copied = false } + } + } + } + private struct NoMessageAttachments: AttachmentLoader { struct Rejected: Error {} func attachment(for url: URL, text: String, environment: ColorEnvironmentValues) async throws -> NeverAttachment diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/Localizable.xcstrings b/apps/maple-ios/Sources/MapleAppUI/Resources/Localizable.xcstrings index 826680a3f..8ef67af66 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Resources/Localizable.xcstrings +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/Localizable.xcstrings @@ -482,7 +482,7 @@ } }, "Encrypted and private at every step": { - "comment": "Privacy reassurance in the empty-chat view, matching the Maple Research composer. Describes encrypted communication without promising absolute anonymity.", + "comment": "Research composer privacy assurance displayed beside a lock glyph.", "extractionState": "manual", "localizations": { "en": { @@ -709,18 +709,6 @@ } } }, - "Message Maple": { - "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Message Maple" - } - } - } - }, "Model": { "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", "extractionState": "manual", @@ -1476,18 +1464,6 @@ } } }, - "This conversation is too long for the selected model. Start a new chat.": { - "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "This conversation is too long for the selected model. Start a new chat." - } - } - } - }, "This device’s date or time appears incorrect. Check Date & Time and the time zone in Settings, then try again.": { "comment": "Safe guidance for a typed SDK attestation time failure. Do not imply trust validation can be bypassed. Settings refers to device system Settings.", "extractionState": "manual", @@ -1572,18 +1548,6 @@ } } }, - "This model is unavailable. Choose another model.": { - "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "This model is unavailable. Choose another model." - } - } - } - }, "This model requires a paid plan. Choose an available model.": { "comment": "Account-access status shown beside the composer; preserve the distinction between unavailable details and a confirmed restriction.", "extractionState": "manual", @@ -1956,18 +1920,6 @@ } } }, - "You’ve reached your usage limit.": { - "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "You’ve reached your usage limit." - } - } - } - }, "Zoom in": { "comment": "Attached-image preview, fullscreen zoom, dismissal, or VoiceOver action.", "extractionState": "manual", @@ -2051,6 +2003,294 @@ } } } + }, + "You've reached your daily usage limit. Upgrade to Pro for more chats.": { + "comment": "Research usage-limit feedback for a known Free account; upgrade is explanatory copy only.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You've reached your daily usage limit. Upgrade to Pro for more chats." + } + } + } + }, + "You've reached your monthly Pro limit. Upgrade to Max for 10x more usage.": { + "comment": "Research usage-limit feedback for a known Pro account; preserve the plan names and multiplier.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You've reached your monthly Pro limit. Upgrade to Max for 10x more usage." + } + } + } + }, + "You've reached your monthly usage limit. Please wait for the next billing cycle.": { + "comment": "Research usage-limit feedback for all other or unknown plans.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You've reached your monthly usage limit. Please wait for the next billing cycle." + } + } + } + }, + "This conversation is too long for the free tier. Upgrade to Pro for longer conversations.": { + "comment": "Research free-tier conversation token-limit feedback, distinct from a single-message context limit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This conversation is too long for the free tier. Upgrade to Pro for longer conversations." + } + } + } + }, + "Your message exceeds the context limit for this model.": { + "comment": "Research rejected-message context-limit feedback. The message, not the conversation, exceeded the model limit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your message exceeds the context limit for this model." + } + } + } + }, + "This model or feature is not available on your current plan.": { + "comment": "Research server plan-access-denial feedback.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This model or feature is not available on your current plan." + } + } + } + }, + "Failed to generate response. Please try again.": { + "comment": "Research generation-failure feedback; partial reply text stays visible.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to generate response. Please try again." + } + } + } + }, + "Stopped before sending. Your message was restored.": { + "comment": "Research feedback when Stop prevented dispatch and the draft was restored.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stopped before sending. Your message was restored." + } + } + } + }, + "Stopping as soon as the response is ready…": { + "comment": "Research feedback while Stop is waiting for the server response identifier.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stopping as soon as the response is ready…" + } + } + } + }, + "The response finished before it could be stopped.": { + "comment": "Research feedback when a requested Stop raced a completed response.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The response finished before it could be stopped." + } + } + } + }, + "The response completed after the connection was restored. Queued messages were kept.": { + "comment": "Research successful completion after connection recovery; queued work was retained.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The response completed after the connection was restored. Queued messages were kept." + } + } + } + }, + "The interrupted response stopped. Queued messages were kept.": { + "comment": "Research stopped response after connection recovery; queued work was retained.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The interrupted response stopped. Queued messages were kept." + } + } + } + }, + "Image description is temporarily unavailable. Your message and images were restored; please try again.": { + "comment": "Research image-description capacity feedback after restoring the rejected draft and its images.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Image description is temporarily unavailable. Your message and images were restored; please try again." + } + } + } + }, + "Inference capacity is temporarily unavailable. Your message was restored. Please try again shortly.": { + "comment": "Research inference-capacity feedback after restoring the rejected draft.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Inference capacity is temporarily unavailable. Your message was restored. Please try again shortly." + } + } + } + }, + "Message Too Large": { + "comment": "Title of the Research context-limit alert.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Message Too Large" + } + } + } + }, + "Got it": { + "comment": "Dismiss button in the Research message-context-limit alert.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Got it" + } + } + } + }, + "Your message exceeds the context limit for the current model.\n\nHere's what you can try:\nShorten your message - Try reducing the amount of text or content": { + "comment": "Research context-limit dialog text, without the document suggestion because native document uploads are outside this pass.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your message exceeds the context limit for the current model.\n\nHere's what you can try:\nShorten your message - Try reducing the amount of text or content" + } + } + } + }, + "Chat Canceled": { + "comment": "Research status caption on incomplete assistant output after Stop; retain US spelling.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat Canceled" + } + } + } + }, + "Copied": { + "comment": "Research two-second confirmation label after copying a whole message or code block.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copied" + } + } + } + }, + "Message Maple...": { + "comment": "Research composer placeholder; Maple is the application name and the punctuation is three periods.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Message Maple..." + } + } + } + }, + "AI can make mistakes. Check important info.": { + "comment": "Research informational footnote shown below the docked composer.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "AI can make mistakes. Check important info." + } + } + } + }, + "chat.thought-duration": { + "comment": "Research completed reasoning label. Duration is estimated from word count; preserve the complete message.", + "extractionState": "manual", + "localizations": { + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Thought for %lld seconds" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Thought for %lld seconds" + } + } + } + } + } + } + }, + "Maple is responding": { + "comment": "Research VoiceOver label for the pending response progress indicator before assistant text arrives.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Maple is responding" + } + } + } } }, "version": "1.0" diff --git a/apps/maple-ios/Tests/MapleAppUITests/Composer/ComposerFlowTests.swift b/apps/maple-ios/Tests/MapleAppUITests/Composer/ComposerFlowTests.swift index e1f065c66..5621b15ed 100644 --- a/apps/maple-ios/Tests/MapleAppUITests/Composer/ComposerFlowTests.swift +++ b/apps/maple-ios/Tests/MapleAppUITests/Composer/ComposerFlowTests.swift @@ -1,4 +1,5 @@ import Foundation +import MapleAppCore import MapleSDK import Testing @testable import MapleAppUI @@ -207,4 +208,64 @@ import Testing #expect(orphan.count == 1 && orphan[0].item.type == "tool_output" && orphan[0].toolResult == nil) } + @Test func pendingIndicatorAppearsOnlyBeforeAssistantOutputDuringGeneration() { + let user = ConversationItem(id: "user", type: "message", role: "user") + let assistant = ConversationItem(id: "assistant", type: "message", status: "in_progress", role: "assistant") + let reasoning = ConversationItem(id: "reasoning", type: "reasoning") + let tool = ConversationItem(id: "tool", type: "web_search_call", status: "in_progress") + #expect(ChatTranscriptPresentation.showsPending(phase: .sending, items: [user])) + #expect(ChatTranscriptPresentation.showsPending(phase: .streaming, items: [user])) + for phase: ChatRunPhase? in [nil, .preparing, .stopping, .recovering] { + #expect(!ChatTranscriptPresentation.showsPending(phase: phase, items: [user])) + } + for output in [assistant, reasoning, tool] { + #expect(!ChatTranscriptPresentation.showsPending(phase: .streaming, items: [user, output])) + } + } + + @Test func reasoningDurationMatchesResearchRoundingAndWhitespaceRules() { + let english = Locale(identifier: "en") + #expect(ChatTranscriptPresentation.thoughtDuration("") == 1) + #expect(ChatTranscriptPresentation.thoughtDuration(" \n\t one word \n") == 1) + #expect( + ChatTranscriptPresentation.thoughtDuration(Array(repeating: "word", count: 38).joined(separator: " ")) == 1) + #expect( + ChatTranscriptPresentation.thoughtDuration(Array(repeating: "word", count: 39).joined(separator: "\t")) == 2 + ) + #expect(ChatTranscriptPresentation.thinkingLabel(text: "", streaming: true, locale: english) == "Thinking") + #expect( + ChatTranscriptPresentation.thinkingLabel(text: "one", streaming: false, locale: english) + == "Thought for 1 seconds") + #expect( + ChatTranscriptPresentation.thinkingLabel( + text: Array(repeating: "word", count: 52).joined(separator: " "), streaming: false, locale: english) + == "Thought for 2 seconds") + } + + @Test func serverLimitFeedbackDistinguishesKnownPlansAndMessageLimit() { + #expect( + ChatRecoveryPresentation.message(.usageLimit, plan: .free) + == "You've reached your daily usage limit. Upgrade to Pro for more chats.") + #expect( + ChatRecoveryPresentation.message(.usageLimit, plan: .paid(.pro)) + == "You've reached your monthly Pro limit. Upgrade to Max for 10x more usage.") + for plan: BillingPlan in [.unknown, .paid(.max), .paid(.team)] { + #expect( + ChatRecoveryPresentation.message(.usageLimit, plan: plan) + == "You've reached your monthly usage limit. Please wait for the next billing cycle.") + } + #expect( + ChatRecoveryPresentation.message(.contextLimit) == "Your message exceeds the context limit for this model.") + #expect( + ChatRecoveryPresentation.message(.freeTierTokenLimit) + == "This conversation is too long for the free tier. Upgrade to Pro for longer conversations.") + #expect( + ChatRecoveryPresentation.message(.modelUnavailable) + == "This model or feature is not available on your current plan.") + #expect( + ChatRecoveryPresentation.contextLimitMessage + == "Your message exceeds the context limit for the current model.\n\nHere's what you can try:\nShorten your message - Try reducing the amount of text or content" + ) + } + } diff --git a/apps/maple-ios/UITests/ChatWorkflowUITests.swift b/apps/maple-ios/UITests/ChatWorkflowUITests.swift index 1ac006522..475847dc4 100644 --- a/apps/maple-ios/UITests/ChatWorkflowUITests.swift +++ b/apps/maple-ios/UITests/ChatWorkflowUITests.swift @@ -294,7 +294,7 @@ import XCTest waitLabel("chat.fixture.posts", "POSTs 1"); waitLabel("chat.fixture.context-gate", "held") try replaceComposer("New composer while rejection is pending") tap("chat.fixture.reject-context"); waitLabel("chat.fixture.phase", "idle") - waitLabel("chat.error", "This conversation is too long for the selected model. Start a new chat.") + waitLabel("chat.error", "Your message exceeds the context limit for this model.") XCTAssertEqual(composer.value as? String, "New composer while rejection is pending") waitLabel("chat.fixture.unsent", "Rejected original message") tap("Restore unsent message") @@ -320,7 +320,7 @@ import XCTest waitLabel("chat.fixture.posts", "POSTs 1"); waitLabel("chat.fixture.context-gate", "held") try replaceComposer("Held composer survives queued rejection") tap("chat.fixture.reject-context"); waitLabel("chat.fixture.phase", "idle") - waitLabel("chat.error", "This conversation is too long for the selected model. Start a new chat.") + waitLabel("chat.error", "Your message exceeds the context limit for this model.") waitLabel("chat.fixture.queue-state", "paused"); waitLabel("chat.fixture.queued", "Queued A") waitLabel("chat.fixture.composer-image-ids", heldImages) waitLabel("chat.fixture.queued-image-ids", queuedImages) diff --git a/apps/maple-ios/UITests/RendererUITests.swift b/apps/maple-ios/UITests/RendererUITests.swift index f5ed42c62..89526401e 100644 --- a/apps/maple-ios/UITests/RendererUITests.swift +++ b/apps/maple-ios/UITests/RendererUITests.swift @@ -145,6 +145,8 @@ final class RendererUITests: XCTestCase { XCTAssertTrue(paragraph.waitForExistence(timeout: 15)) XCTAssertTrue(app.buttons["Copy code"].waitForExistence(timeout: 10)) app.buttons["Copy code"].tap() + XCTAssertTrue(app.buttons["Copied"].waitForExistence(timeout: 1)) + XCTAssertTrue(app.buttons["Copy code"].waitForExistence(timeout: 4)) pasteSelection() let expected = "let synthetic = \"copy code\" // preserved\nprint(synthetic)" let copied = XCTNSPredicateExpectation( From 198791a0c6a5d7e04f66b59304888213d7fc9cb3 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:47:10 +0000 Subject: [PATCH 09/17] ios-native parity item 10: poll generated titles with lifecycle cancellation --- .../Library/LibraryContracts.swift | 19 +++ .../Sources/MapleAppUI/AppEnvironment.swift | 60 ++++++++- .../Navigation/LibraryContext.swift | 7 ++ .../ConversationTitlePollingTests.swift | 119 ++++++++++++++++++ 4 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 apps/maple-ios/Tests/MapleAppCoreTests/Library/ConversationTitlePollingTests.swift diff --git a/apps/maple-ios/Sources/MapleAppCore/Library/LibraryContracts.swift b/apps/maple-ios/Sources/MapleAppCore/Library/LibraryContracts.swift index f991f6faf..92913040e 100644 --- a/apps/maple-ios/Sources/MapleAppCore/Library/LibraryContracts.swift +++ b/apps/maple-ios/Sources/MapleAppCore/Library/LibraryContracts.swift @@ -74,6 +74,25 @@ public enum LibraryFilter: Sendable, Equatable { case all, pinned, recent, unass public enum LibraryConversationDestination: Sendable, Equatable, Hashable { case project(String), pinned, recent } + +/// The caller owns the selected chat and account lease. Recheck that ownership +/// after each delay before refreshing a title generated in the background. +public enum ConversationTitlePolling { + @MainActor public static func run( + shouldContinue: @MainActor () -> Bool, + refresh: @MainActor () async -> Void, + sleep: @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) } + ) async { + var delay: Duration = .milliseconds(500) + while !Task.isCancelled, shouldContinue() { + do { try await sleep(delay) } catch { return } + guard !Task.isCancelled, shouldContinue() else { return } + await refresh() + delay = min(delay * 2, .seconds(10)) + } + } +} + public enum LibraryIssue: Error, Sendable, Equatable { case connection, invalidInput, invalidResponse, busy, retired, quiescence, cleanupRequired, partialMutation static func present(_ error: Error) -> LibraryIssue { diff --git a/apps/maple-ios/Sources/MapleAppUI/AppEnvironment.swift b/apps/maple-ios/Sources/MapleAppUI/AppEnvironment.swift index 639873366..796ce3d7b 100644 --- a/apps/maple-ios/Sources/MapleAppUI/AppEnvironment.swift +++ b/apps/maple-ios/Sources/MapleAppUI/AppEnvironment.swift @@ -17,7 +17,12 @@ let signIn: NativeSignInCoordinator let preferenceStore = PreferencesStore() private(set) var chats: [String: ChatRuntime] = [:] - private(set) var selectedKey = ChatSnapshot.newChatKey { didSet { updatePresentationVisibility() } } + private(set) var selectedKey = ChatSnapshot.newChatKey { + didSet { + updatePresentationVisibility() + updateTitlePolling() + } + } private(set) var accountAPI: ResearchAPI? private(set) var entitlements: AccountEntitlements? private(set) var deletion: ChatDeletionCoordinator? @@ -28,6 +33,9 @@ private(set) var readyScope: AccountScope? private(set) var conversationDetails: [String: Conversation] = [:] private var conversationDetailTickets: [String: UUID] = [:] + @ObservationIgnored private var titlePoll: Task? + private var titlePollConversationID: String? + private var titlePollTicket: UUID? var accountFlow: AccountFlow? { didSet { updatePresentationVisibility() } } var menuPresented = false { didSet { @@ -65,6 +73,7 @@ } for runtime in chats.values { runtime.isForeground = foreground } updatePresentationVisibility() + updateTitlePolling() if foreground && !oldValue { reconcileAfterForeground() } } } @@ -105,6 +114,7 @@ } session.onRetire = { [weak self] scope in guard let self else { return } + self.cancelTitlePolling() for ceremony in self.accountCeremonies { ceremony.invalidate() } self.accountCeremonies.removeAll() self.accountFlow?.ceremonies.invalidate(); self.accountFlow = nil @@ -299,6 +309,7 @@ guard let self, self.session.account?.scope == account.scope else { return } self.conversationDetails[conversation.id] = conversation self.conversationDetailTickets[conversation.id] = UUID() + self.updateTitlePolling() } context.onConversationProjectUpdated = { [weak self] ids, projectID in guard let self, self.session.account?.scope == account.scope else { return } @@ -459,13 +470,58 @@ do { let conversation = try await api.getConversation(id: id) guard session.account?.scope == account.scope, conversation.id == id, - deletion?.isConversationDeleted(id) != true, conversationDetailTickets[id] == ticket + deletion?.isConversationDeleted(id) != true, conversationDetailTickets[id] == ticket, + !Task.isCancelled else { return } + let changed = conversationDetails[id] != conversation conversationDetails[id] = conversation deletion?.updateProject(conversationID: id, projectID: conversation.projectId) + if changed, let context = libraryContext, context.scope == account.scope { + context.receiveConversationDetails(conversation) + } + updateTitlePolling() } catch { /* History and drafts remain available when metadata cannot refresh. */ } } + private func cancelTitlePolling() { + titlePoll?.cancel() + titlePoll = nil + titlePollConversationID = nil + titlePollTicket = nil + } + + /// Title generation outlives the response request. Keep checking only + /// the selected conversation, with the same 0.5→10 second backoff as Research. + private func updateTitlePolling() { + guard foreground, let account = session.account, let api = accountAPI, + let id = selectedChat?.conversationID, + deletion?.isConversationDeleted(id) != true, + conversationDetails[id]?.metadata?["title"] == .string("New Conversation") + else { + cancelTitlePolling() + return + } + guard titlePollConversationID != id || titlePoll == nil else { return } + cancelTitlePolling() + let ticket = UUID() + titlePollConversationID = id + titlePollTicket = ticket + titlePoll = Task { [weak self] in + await ConversationTitlePolling.run( + shouldContinue: { [weak self] in + guard let self else { return false } + return self.titlePollTicket == ticket && self.foreground + && self.session.account?.scope == account.scope + && self.selectedChat?.conversationID == id + && self.deletion?.isConversationDeleted(id) != true + && self.conversationDetails[id]?.metadata?["title"] == .string("New Conversation") + }, + refresh: { [weak self] in + await self?.refreshConversationDetails(id, account: account, api: api) + }) + } + } + var unfinishedChats: [ChatRuntime] { chats.values.filter { $0.snapshot.storageKey.hasPrefix("pending:") && $0.conversationID == nil } .sorted { $0.snapshot.storageKey < $1.snapshot.storageKey } diff --git a/apps/maple-ios/Sources/MapleAppUI/Navigation/LibraryContext.swift b/apps/maple-ios/Sources/MapleAppUI/Navigation/LibraryContext.swift index 7939d5e6c..72fd9a17c 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Navigation/LibraryContext.swift +++ b/apps/maple-ios/Sources/MapleAppUI/Navigation/LibraryContext.swift @@ -48,6 +48,13 @@ await menu.refresh { [weak projectExpansion] in projectExpansion?.isExpanded(id: $0) ?? false } } + func receiveConversationDetails(_ conversation: Conversation) { + guard active else { return } + for model in models.compactMap(\.value) { + model.receiveConversationUpdate(conversation, from: scope) + } + } + func makeLibrary() -> LibraryModel { let model = LibraryModel(scope: scope, service: service, deletions: deletions) attach(model) diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Library/ConversationTitlePollingTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Library/ConversationTitlePollingTests.swift new file mode 100644 index 000000000..15a34bd25 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Library/ConversationTitlePollingTests.swift @@ -0,0 +1,119 @@ +import Foundation +import Testing +@testable import MapleAppCore + +private actor ConversationTitleSleepRecorder { + private(set) var delays: [Duration] = [] + let gate: ChatTestGate? + let fails: Bool + + init(gate: ChatTestGate? = nil, fails: Bool = false) { + self.gate = gate; self.fails = fails + } + + func sleep(_ duration: Duration) async throws { + delays.append(duration) + if let gate { await gate.hold() } + if fails { throw URLError(.timedOut) } + } +} + +@MainActor @Suite struct ConversationTitlePollingTests { + @Test func titleRefreshBackoffDoublesAndRetainsTenSecondCap() async { + let clock = ConversationTitleSleepRecorder() + var refreshes = 0 + await ConversationTitlePolling.run( + shouldContinue: { refreshes < 7 }, + refresh: { refreshes += 1 }, + sleep: { try await clock.sleep($0) }) + #expect(refreshes == 7) + #expect( + await clock.delays == [ + .milliseconds(500), .seconds(1), .seconds(2), .seconds(4), + .seconds(8), .seconds(10), .seconds(10), + ]) + } + + @Test func generatedTitleStopsFurtherSleepsAndRefreshes() async { + let clock = ConversationTitleSleepRecorder() + var title = "New Conversation", refreshes = 0 + await ConversationTitlePolling.run( + shouldContinue: { title == "New Conversation" }, + refresh: { + refreshes += 1 + if refreshes == 3 { title = "Generated title" } + }, + sleep: { try await clock.sleep($0) }) + #expect(title == "Generated title" && refreshes == 3) + #expect(await clock.delays == [.milliseconds(500), .seconds(1), .seconds(2)]) + } + + enum OwnershipChange: CaseIterable, Sendable { + case deselected, backgrounded, accountReplaced, taskReplaced + } + + @Test(arguments: OwnershipChange.allCases) + func ownershipChangesDuringSleepPreventTheNextRefresh(_ change: OwnershipChange) async { + let gate = ChatTestGate(), clock = ConversationTitleSleepRecorder(gate: gate) + var selectedID: String? = "chat", foreground = true + var accountGeneration = UUID(), taskTicket = UUID() + let capturedGeneration = accountGeneration, capturedTicket = taskTicket + var refreshes = 0 + let polling = Task { + await ConversationTitlePolling.run( + shouldContinue: { + selectedID == "chat" && foreground && accountGeneration == capturedGeneration + && taskTicket == capturedTicket + }, + refresh: { refreshes += 1 }, + sleep: { try await clock.sleep($0) }) + } + await gate.waitForArrival() + switch change { + case .deselected: selectedID = nil + case .backgrounded: foreground = false + case .accountReplaced: accountGeneration = UUID() + case .taskReplaced: taskTicket = UUID() + } + await gate.release(); await polling.value + #expect(refreshes == 0) + #expect(await clock.delays == [.milliseconds(500)]) + } + + @Test func cancellationDuringSleepPreventsRefreshEvenIfInjectedSleepReturnsNormally() async { + let gate = ChatTestGate(), clock = ConversationTitleSleepRecorder(gate: gate) + var refreshes = 0 + let polling = Task { + await ConversationTitlePolling.run( + shouldContinue: { true }, refresh: { refreshes += 1 }, + sleep: { try await clock.sleep($0) }) + } + await gate.waitForArrival(); polling.cancel() + await gate.release(); await polling.value + #expect(refreshes == 0) + #expect(await clock.delays == [.milliseconds(500)]) + } + + @Test func alreadyCancelledTaskDoesNotSleepOrRefresh() async { + let clock = ConversationTitleSleepRecorder() + var refreshes = 0 + let polling = Task { + await ConversationTitlePolling.run( + shouldContinue: { true }, refresh: { refreshes += 1 }, + sleep: { try await clock.sleep($0) }) + } + polling.cancel(); await polling.value + #expect(refreshes == 0) + #expect(await clock.delays.isEmpty) + } + + @Test func sleepFailureStopsWithoutFetching() async { + let clock = ConversationTitleSleepRecorder(fails: true) + var refreshes = 0 + await ConversationTitlePolling.run( + shouldContinue: { true }, refresh: { refreshes += 1 }, + sleep: { try await clock.sleep($0) }) + #expect(refreshes == 0) + #expect(await clock.delays == [.milliseconds(500)]) + } +} From f159e07b3c97474fa2c23bd13f027f3b28fb0661 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:47:10 +0000 Subject: [PATCH 10/17] ios-native parity item 14: align library rows refresh and bulk behavior --- .../MapleAppCore/Library/LibraryModel.swift | 7 +- .../MapleAppUI/Library/HistoryView.swift | 42 ++++- .../MapleAppUI/Library/LibraryView.swift | 175 +++++++++++------- .../MapleAppUI/Resources/Library.xcstrings | 48 +++++ .../Library/LibraryModelTests.swift | 24 ++- .../UITests/LibraryControlsUITests.swift | 6 + 6 files changed, 224 insertions(+), 78 deletions(-) diff --git a/apps/maple-ios/Sources/MapleAppCore/Library/LibraryModel.swift b/apps/maple-ios/Sources/MapleAppCore/Library/LibraryModel.swift index 8351710d1..3fe61a965 100644 --- a/apps/maple-ios/Sources/MapleAppCore/Library/LibraryModel.swift +++ b/apps/maple-ios/Sources/MapleAppCore/Library/LibraryModel.swift @@ -261,8 +261,9 @@ import Observation } } - public func deleteConversations(ids: Set) async { - guard !ids.isEmpty, ids.count <= 10_000 else { issue = .invalidInput; return } + @discardableResult public func deleteConversations(ids: Set) async -> Bool { + guard !ids.isEmpty, ids.count <= 10_000 else { issue = .invalidInput; return false } + var completed = false await mutate { [self] in let lease = try deletions.begin(.conversations(ids)); defer { deletions.release(lease) } try await deletions.quiesce(lease); try check(); try deletions.assertQuiescent(lease) @@ -278,7 +279,9 @@ import Observation try await deletions.finish(lease, deletedConversationIDs: deleted) } if deletedAll != ids { throw LibraryIssue.partialMutation } + completed = true } + return completed && valid } public func deleteAllConversations() async { await mutate { [self] in diff --git a/apps/maple-ios/Sources/MapleAppUI/Library/HistoryView.swift b/apps/maple-ios/Sources/MapleAppUI/Library/HistoryView.swift index 1b31d233d..d4c8d92ab 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Library/HistoryView.swift +++ b/apps/maple-ios/Sources/MapleAppUI/Library/HistoryView.swift @@ -52,6 +52,7 @@ var dragContext: LibraryChatDragContext? = nil var dragEnabled = true var dropDestination: LibraryConversationDestination? = nil + var selectedChatID: String? = nil var body: some View { Group { if selecting { @@ -66,7 +67,9 @@ Image( systemName: selectionLibrary.selectedIDs.contains(conversation.id) ? "checkmark.circle.fill" : "circle") - ConversationTitle(conversation: conversation) + ConversationTitle( + conversation: conversation, + emphasized: selectionLibrary.selectedIDs.contains(conversation.id)) Spacer(minLength: 0) } .frame(minHeight: 50).contentShape(Rectangle()).foregroundStyle(.primary) @@ -80,7 +83,8 @@ conversation: conversation, library: library, onSelect: onSelect, onMutation: onMutation, onSelectForSelection: { selectionLibrary.selectedIDs.insert(conversation.id); selecting = true - }, leadingInset: leadingInset, contextMenuEnabled: dragContext == nil || !dragEnabled) + }, leadingInset: leadingInset, contextMenuEnabled: dragContext == nil || !dragEnabled, + selectedChatID: selectedChatID) } } .modifier( @@ -164,8 +168,10 @@ Button(role: .destructive) { let ids = library.selectedIDs Task { - await library.deleteConversations(ids: ids) - if library.issue == nil { await onMutation?() } + if await library.deleteConversations(ids: ids) { + selecting = false; library.selectedIDs = [] + await onMutation?() + } } } label: { libraryText("Delete selected chats") @@ -174,7 +180,11 @@ libraryText("Deleted chats cannot be restored.") } .sheet(isPresented: $moving, onDismiss: { Task { await onMutation?() } }) { - NavigationStack { MoveChatsView(library: library, ids: library.selectedIDs) } + NavigationStack { + MoveChatsView(library: library, ids: library.selectedIDs) { + selecting = false; library.selectedIDs = [] + } + } } .sheet(isPresented: $deletingAll) { NavigationStack { @@ -273,12 +283,19 @@ struct ConversationTitle: View { let conversation: Conversation + var emphasized = false var body: some View { let title = LibraryModel.title(conversation) - Group { - if title.isEmpty { libraryText("Untitled chat") } else { Text(verbatim: title) } + HStack(spacing: 6) { + if conversation.pinned { + Image(systemName: "pin").font(.caption).accessibilityHidden(true) + } + Group { + if title.isEmpty { libraryText("Untitled chat") } else { Text(verbatim: title) } + } + .fontWeight(emphasized ? .bold : .regular) + .lineLimit(1).truncationMode(.tail) } - .lineLimit(1).truncationMode(.tail) } } @@ -348,6 +365,7 @@ var onSelectForSelection: (@MainActor () -> Void)? = nil var leadingInset: CGFloat = 20 var contextMenuEnabled = true + var selectedChatID: String? = nil @Environment(\.requestChatDeletion) private var requestChatDeletion @State private var renaming = false @State private var name = "" @@ -426,12 +444,13 @@ Button { onSelect(conversation.id) } label: { - ConversationTitle(conversation: conversation) + ConversationTitle(conversation: conversation, emphasized: selectedChatID == conversation.id) .frame(maxWidth: .infinity, minHeight: 50, alignment: .leading) .contentShape(Rectangle()).foregroundStyle(.primary) } .buttonStyle(.plain) .accessibilityIdentifier("library.chat." + conversation.id) + .accessibilityAddTraits(selectedChatID == conversation.id ? [.isSelected] : []) } @ViewBuilder private var actions: some View { if let onSelectForSelection { @@ -492,6 +511,7 @@ struct MoveChatsView: View { @Bindable var library: LibraryModel let ids: Set + var onMoved: (@MainActor () -> Void)? = nil @Environment(\.dismiss) private var dismiss var body: some View { List { @@ -523,7 +543,9 @@ } private func move(_ project: String?) { Task { - await library.moveConversations(ids: ids, projectID: project); if library.issue == nil { dismiss() } + if await library.moveConversations(ids: ids, projectID: project) { + onMoved?(); dismiss() + } } } } diff --git a/apps/maple-ios/Sources/MapleAppUI/Library/LibraryView.swift b/apps/maple-ios/Sources/MapleAppUI/Library/LibraryView.swift index 4126ff0ab..fd99cc262 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Library/LibraryView.swift +++ b/apps/maple-ios/Sources/MapleAppUI/Library/LibraryView.swift @@ -24,6 +24,7 @@ private let makeInstructionEditor: @MainActor (InstructionDestination) -> InstructionEditor private let onSelectChat: @MainActor (String) -> Void private let onNewChat: @MainActor (String?) -> Void + private let selectedChatID: String? private let settings: @MainActor () -> AnyView private let settingsFullScreen: Bool private let settingsPresented: Binding? @@ -44,6 +45,7 @@ makeInstructionEditor: @escaping @MainActor (InstructionDestination) -> InstructionEditor, onSelectChat: @escaping @MainActor (String) -> Void, onNewChat: @escaping @MainActor (String?) -> Void, + selectedChatID: String? = nil, settings: @escaping @MainActor () -> AnyView, projectExpansion: LibraryProjectExpansionController? = nil, menu: LibraryMenuModel? = nil, @@ -61,6 +63,7 @@ ) { self.library = library; self.makeInstructionEditor = makeInstructionEditor self.onSelectChat = onSelectChat; self.onNewChat = onNewChat; self.settings = settings; + self.selectedChatID = selectedChatID self.onClose = onClose; self.settingsFullScreen = settingsFullScreen self.settingsPresented = settingsPresented; self.onSettingsDismiss = onSettingsDismiss self.supplementary = supplementary @@ -99,83 +102,105 @@ } .listSectionMargins(.vertical, 0) if let supplementary { supplementary() } - Section { - ForEach(visibleProjects, id: \.id) { project in - if let projectModel = models.projects[project.id] { - ProjectFolderView( - project: project, model: projectModel, search: search, - onSelectChat: selectChat, onNewChat: selectNewChat, - onManageProject: manageProject, - onMutation: refresh, selectionLibrary: library, selecting: $selecting, - drag: drag, dragEnabled: navigationAvailable, - expanded: Binding( - get: { - projectExpansion?.isExpanded(id: project.id) - ?? models.expandedProjects.contains(project.id) - }, - set: { value in - if let projectExpansion { - projectExpansion.setExpanded(id: project.id, expanded: value) - } else if value { - models.expandedProjects.insert(project.id) - } else { - models.expandedProjects.remove(project.id) - } - })) - } + if showsNoSearchResults { + VStack(alignment: .leading, spacing: 4) { + Text( + String( + localized: "No chats found matching \"%@\"", + defaultValue: "No chats found matching \"\(trimmedSearch)\"", + table: "Library", bundle: .module)) + libraryText("Try a different search term").font(.footnote) } - } header: { - HStack { - libraryText("Projects").font(.headline).foregroundStyle(.primary) - Spacer() - Button { - suspendSearch() - if let onManageProjects { - onManageProjects(nil) - } else { - projectDestination = .create + .foregroundStyle(.secondary) + .padding(.vertical, 8) + .accessibilityIdentifier("library.search.empty") + .listRowSeparator(.hidden).listRowBackground(Color.clear) + } else { + Section { + ForEach(visibleProjects, id: \.id) { project in + if let projectModel = models.projects[project.id] { + ProjectFolderView( + project: project, model: projectModel, search: search, + onSelectChat: selectChat, onNewChat: selectNewChat, + selectedChatID: selectedChatID, + onManageProject: manageProject, + onMutation: refresh, selectionLibrary: library, selecting: $selecting, + drag: drag, dragEnabled: navigationAvailable, + expanded: Binding( + get: { + projectExpansion?.isExpanded(id: project.id) + ?? models.expandedProjects.contains(project.id) + }, + set: { value in + if let projectExpansion { + projectExpansion.setExpanded(id: project.id, expanded: value) + } else if value { + models.expandedProjects.insert(project.id) + } else { + models.expandedProjects.remove(project.id) + } + })) } - } label: { - Image(systemName: "folder.badge.plus").foregroundStyle(MapleAccent.action) - .frame(minWidth: 44, minHeight: 44) } - .buttonStyle(.plain) - .accessibilityLabel(libraryText("New project")) - .accessibilityIdentifier("library.new-project") + } header: { + HStack { + libraryText("Projects").font(.headline).foregroundStyle(.primary) + Spacer() + Button { + suspendSearch() + if let onManageProjects { + onManageProjects(nil) + } else { + projectDestination = .create + } + } label: { + Image(systemName: "folder.badge.plus").foregroundStyle(MapleAccent.action) + .frame(minWidth: 44, minHeight: 44) + } + .buttonStyle(.plain) + .disabled(library.projects.count >= 10) + .accessibilityLabel(libraryText("New project")) + .accessibilityHint( + libraryText("Maximum of 10 projects reached"), + isEnabled: library.projects.count >= 10 + ) + .accessibilityIdentifier("library.new-project") + } + .listRowInsets(.top, 8).listRowInsets(.bottom, 0) + } + .textCase(nil) + .listSectionMargins(.vertical, 0) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + if drag.isDragging || !models.pinned.visibleConversations.isEmpty || models.pinned.hasMore + || models.pinned.issue != nil + { + Section { + conversationRows( + models.pinned, loadMoreID: "library.pinned.load-more", destination: .pinned) + } header: { + dropHeading("Pinned", destination: .pinned, id: "library.drop.pinned") + } + .textCase(nil) + .listSectionMargins(.vertical, 0) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) } - .listRowInsets(.top, 8).listRowInsets(.bottom, 0) - } - .textCase(nil) - .listSectionMargins(.vertical, 0) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - if drag.isDragging || !models.pinned.visibleConversations.isEmpty || models.pinned.hasMore - || models.pinned.issue != nil - { Section { conversationRows( - models.pinned, loadMoreID: "library.pinned.load-more", destination: .pinned) + models.recent, loadMoreID: "library.recent.load-more", destination: .recent) } header: { - dropHeading("Pinned", destination: .pinned, id: "library.drop.pinned") + dropHeading("Recent", destination: .recent, id: "library.drop.recent") } .textCase(nil) .listSectionMargins(.vertical, 0) .listRowSeparator(.hidden) .listRowBackground(Color.clear) } - Section { - conversationRows( - models.recent, loadMoreID: "library.recent.load-more", destination: .recent) - } header: { - dropHeading("Recent", destination: .recent, id: "library.drop.recent") - } - .textCase(nil) - .listSectionMargins(.vertical, 0) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) if let issue = library.issue { LibraryIssueView(issue: issue) } } .accessibilityIdentifier("library.sidebar-list") + .refreshable { await refresh() } .allowsHitTesting(navigationAvailable) .accessibilityHidden(!navigationAvailable) .safeAreaInset(edge: .bottom, spacing: 0) { @@ -376,7 +401,7 @@ .shadow(color: .black.opacity(0.16), radius: 14, x: 0, y: 6) } private var visibleProjects: [ConversationProjectListItem] { - let query = search.trimmingCharacters(in: .whitespacesAndNewlines) + let query = trimmedSearch return library.projects.filter { project in query.isEmpty || project.name.localizedCaseInsensitiveContains(query) || models.projects[project.id]?.conversations.contains { @@ -384,6 +409,19 @@ } == true } } + private var trimmedSearch: String { search.trimmingCharacters(in: .whitespacesAndNewlines) } + private var showsNoSearchResults: Bool { + !trimmedSearch.isEmpty && models.searchText == search && models.isPrepared + && !library.isLoadingProjects && library.issue == nil && visibleProjects.isEmpty + && models.pinned.hasLoadedConversations && !models.pinned.isLoading && models.pinned.issue == nil + && models.pinned.visibleConversations.isEmpty + && models.recent.hasLoadedConversations && !models.recent.isLoading && models.recent.issue == nil + && models.recent.visibleConversations.isEmpty + && library.projects.allSatisfy { project in + guard let model = models.projects[project.id] else { return false } + return model.hasLoadedConversations && !model.isLoading && model.issue == nil + } + } private func dropHeading(_ title: String, destination: LibraryConversationDestination, id: String) -> some View { libraryText(title).font(.headline).foregroundStyle(.primary) @@ -402,7 +440,7 @@ LibrarySelectableConversationRow( conversation: conversation, library: model, selectionLibrary: library, selecting: $selecting, onSelect: selectChat, onMutation: refresh, dragContext: drag, - dragEnabled: navigationAvailable, dropDestination: destination + dragEnabled: navigationAvailable, dropDestination: destination, selectedChatID: selectedChatID ) .onScrollVisibilityChange(threshold: 0.1) { visible in guard visible, destination == .recent, navigationAvailable, focusAvailable else { return } @@ -421,6 +459,12 @@ }.disabled(model.isLoading).accessibilityIdentifier(loadMoreID) } if let issue = model.issue { LibraryIssueView(issue: issue) } + if destination == .recent, model.hasLoadedConversations, !model.isLoading, + model.visibleConversations.isEmpty, model.issue == nil + { + libraryText("No recent chats.").font(.footnote).foregroundStyle(.secondary) + .accessibilityIdentifier("library.recent.empty") + } } private func refresh() async { models.setSearch(search) @@ -436,6 +480,7 @@ let search: String let onSelectChat: @MainActor (String) -> Void let onNewChat: @MainActor (String?) -> Void + let selectedChatID: String? let onManageProject: @MainActor (String) -> Void let onMutation: @MainActor () async -> Void @Bindable var selectionLibrary: LibraryModel @@ -511,12 +556,12 @@ } .onChange(of: search) { _, value in model.searchText = value } if showingChildren { - ForEach(model.visibleConversations.filter { !$0.pinned }, id: \.id) { conversation in + ForEach(model.visibleConversations, id: \.id) { conversation in LibrarySelectableConversationRow( conversation: conversation, library: model, selectionLibrary: selectionLibrary, selecting: $selecting, onSelect: onSelectChat, onMutation: onMutation, leadingInset: 56, dragContext: drag, dragEnabled: dragEnabled, - dropDestination: .project(project.id) + dropDestination: .project(project.id), selectedChatID: selectedChatID ) .listRowInsets(.vertical, 0) .listRowInsets(.leading, 56).listRowInsets(.trailing, 12) diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/Library.xcstrings b/apps/maple-ios/Sources/MapleAppUI/Resources/Library.xcstrings index 3e7726e7c..29e1e303b 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Resources/Library.xcstrings +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/Library.xcstrings @@ -374,6 +374,18 @@ } } }, + "Maximum of 10 projects reached": { + "comment": "Accessibility hint for the disabled New project control when the account has reached Research's ten-project maximum.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Maximum of 10 projects reached" + } + } + } + }, "Move selected chats": { "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", "extractionState": "manual", @@ -434,6 +446,18 @@ } } }, + "No chats found matching \"%@\"": { + "comment": "Library empty search result. The placeholder is the complete trimmed search query. Preserve its quotes and do not translate the query.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No chats found matching \"%@\"" + } + } + } + }, "No chats yet": { "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", "extractionState": "manual", @@ -458,6 +482,18 @@ } } }, + "No recent chats.": { + "comment": "Empty state under the Recent section of the main menu.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No recent chats." + } + } + } + }, "Pin": { "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", "extractionState": "manual", @@ -710,6 +746,18 @@ } } }, + "Try a different search term": { + "comment": "Library empty search result suggestion below the searched query.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Try a different search term" + } + } + } + }, "Try again": { "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", "extractionState": "manual", diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Library/LibraryModelTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Library/LibraryModelTests.swift index 277ff137e..0d815f9a4 100644 --- a/apps/maple-ios/Tests/MapleAppCoreTests/Library/LibraryModelTests.swift +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Library/LibraryModelTests.swift @@ -68,12 +68,34 @@ private final class LibraryObservationChanges: Sendable { #expect(moved) #expect(await service.moveCalls.map(\.count) == [20, 20, 5]) #expect(model.conversations.allSatisfy { $0.projectId == "project" }) + #expect(model.selectedIDs.isEmpty) model.selectedIDs = ids - await model.deleteConversations(ids: ids) + let deleted = await model.deleteConversations(ids: ids) + #expect(!deleted) #expect(await service.deleteCalls.map(\.count) == [20, 20, 5]) #expect(model.conversations.map(\.id) == ["3"]) #expect(model.selectedIDs == ["3"] && model.issue == .partialMutation) } + @Test func bulkDeleteSignalsSuccessOnlyAfterEverySelectedChatIsDeleted() async { + let (model, service, _, _, _) = fixture() + let rows = (0..<25).map { libraryConversation(String($0)) }, ids = Set(rows.map(\.id)) + await service.configure(conversations: rows) + await model.load(); model.selectedIDs = ids + #expect(await model.deleteConversations(ids: ids)) + #expect(await service.deleteCalls.map(\.count) == [20, 5]) + #expect(model.selectedIDs.isEmpty && model.conversations.isEmpty && model.issue == nil) + } + @Test func failedBulkDeleteKeepsSelectionAndDoesNotSignalSuccess() async { + let (model, service, _, _, _) = fixture() + await service.configure(conversations: [libraryConversation("a")], failDelete: true) + await model.load(); model.selectedIDs = ["a"] + #expect(await model.deleteConversations(ids: ["a"]) == false) + #expect(model.selectedIDs == ["a"] && model.conversations.map(\.id) == ["a"]) + #expect(model.issue == .connection) + model.invalidate() + #expect(await model.deleteConversations(ids: ["a"]) == false) + #expect(await service.deleteCalls.count == 1) + } @Test func projectDiscoveryFencesBeforeAwaitAndFailureReleasesWithoutDeleting() async throws { let (model, service, deletion, chat, store) = fixture(), gate = ChatTestGate() let runtime = ChatRuntime(scope: model.scope, service: chat, persistence: store, conversationID: "a") diff --git a/apps/maple-ios/UITests/LibraryControlsUITests.swift b/apps/maple-ios/UITests/LibraryControlsUITests.swift index 4ebd598b8..338d289b1 100644 --- a/apps/maple-ios/UITests/LibraryControlsUITests.swift +++ b/apps/maple-ios/UITests/LibraryControlsUITests.swift @@ -78,6 +78,9 @@ import XCTest launch(); openHistory(); tap("library.actions"); tap("Select chats") selectAlphaAndBeta(); tap("Move selected chats"); tap("Synthetic project") label("library.fixture.moved", equals: "alpha,beta to fixture-project") + XCTAssertFalse(app.buttons["Move selected chats"].exists) + XCTAssertFalse(app.buttons["library.selection-done"].exists) + tap("library.actions"); tap("Select chats") selectAlphaAndBeta(); tap("Delete selected chats") XCTAssertTrue(app.staticTexts["Delete selected chats?"].waitForExistence(timeout: 10)) let confirmationReady = XCTNSPredicateExpectation( @@ -97,6 +100,9 @@ import XCTest label("library.fixture.deleted", equals: "alpha,beta") label("library.fixture.remaining", equals: "Remaining active,outside") label("library.fixture.project-state", equals: "project retained") + XCTAssertFalse(app.buttons["Delete selected chats"].exists) + XCTAssertFalse(app.buttons["library.selection-done"].exists) + XCTAssertTrue(app.buttons["library.chat-actions.active"].isHittable) screenshot("Bulk selection preserves unrelated chats and project") } From 8dff1377422bb427e06f2755e48b2f875416941d Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:47:10 +0000 Subject: [PATCH 11/17] ios-native parity items 13 15 17 18: align model details and account preferences --- .../Account/EntitlementFixtureView.swift | 9 +- .../Account/MediaFeatureNotice.swift | 50 +++- .../Settings/PreferencePresentation.swift | 13 + .../MapleAppUI/Settings/Settings.xcstrings | 232 ++++++++++++++++-- .../MapleAppUI/Settings/SettingsView.swift | 77 ++++-- .../UITests/EntitlementUITests.swift | 144 +++++++---- 6 files changed, 448 insertions(+), 77 deletions(-) diff --git a/apps/maple-ios/Sources/MapleAppUI/Account/EntitlementFixtureView.swift b/apps/maple-ios/Sources/MapleAppUI/Account/EntitlementFixtureView.swift index 9148e4eb6..eee37722d 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Account/EntitlementFixtureView.swift +++ b/apps/maple-ios/Sources/MapleAppUI/Account/EntitlementFixtureView.swift @@ -108,7 +108,7 @@ } private enum EntitlementScenario: String, CaseIterable, Identifiable, Sendable { - case loading, failed, free, pro, seat, usage, unknownPlan + case loading, failed, free, pro, pass, zaprite, seat, usage, unknownPlan var id: String { rawValue } } @@ -168,11 +168,15 @@ func subscriptionStatus() async throws -> BillingStatus { if scenario == .loading { await withCheckedContinuation { waiters.append($0) } } if scenario == .failed { throw AccountServiceError.unavailable } - let plan = scenario == .free ? "Free" : scenario == .unknownPlan ? "Unrecognized fixture plan" : "Pro" + let plan = + scenario == .free ? "Free" : scenario == .unknownPlan ? "Unrecognized fixture plan" : "Pro Annual" return try decode([ "is_subscribed": scenario != .free, "product_id": "synthetic", "product_name": plan, "subscription_status": "active", "can_chat": scenario != .usage, "chats_remaining": scenario == .usage ? 0 : 50, + "current_period_end": 1_900_000_000, + "payment_provider": scenario == .pass + ? "subscription_pass" : scenario == .zaprite ? "zaprite" : "stripe", // Acquisition fields are intentionally inert input to the shipping decoder. "ios_iap_enabled": true, "ios_us_external_link_enabled": true, ]) @@ -191,6 +195,7 @@ id: id, object: "model", created: 0, ownedBy: "synthetic", displayName: label, shortName: label, contextWindow: 32768, maxContextTokens: 32768, access: paid ? "pro" : "free", capabilities: ModelCapabilities(chat: true, vision: media, reasoning: true, toolUse: media), + badges: paid ? ["Pro", "Reasoning", "New"] : ["free"], enabled: enabled, deprecated: false) } let capabilities = ModelCapabilities(chat: true, vision: false, reasoning: true, toolUse: false) diff --git a/apps/maple-ios/Sources/MapleAppUI/Account/MediaFeatureNotice.swift b/apps/maple-ios/Sources/MapleAppUI/Account/MediaFeatureNotice.swift index 95fdf949c..5855e774b 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Account/MediaFeatureNotice.swift +++ b/apps/maple-ios/Sources/MapleAppUI/Account/MediaFeatureNotice.swift @@ -3,7 +3,7 @@ import SwiftUI enum MediaFeature: String, Identifiable { - case images, dictation, speech, webSearch + case images, dictation, speech, webSearch, model var id: String { rawValue } var title: String { @@ -12,6 +12,7 @@ case .dictation: MapleStrings.text("Dictation") case .speech: MapleStrings.text("Read aloud") case .webSearch: MapleStrings.text("Web search") + case .model: String(localized: "Powerful AI Models", table: "Settings", bundle: .module) } } @@ -21,6 +22,7 @@ case .dictation: account.transcriptionAccess case .speech: account.speechAccess case .webSearch: account.webSearchAccess + case .model: .paidPlanRequired } } } @@ -30,18 +32,38 @@ struct MediaFeatureNotice: View { let feature: MediaFeature @Bindable var account: AccountEntitlements + var modelName: String? = nil @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 16) { - Label(feature.title, systemImage: "info.circle") + Label(title, systemImage: "info.circle") .font(.title2).accessibilityAddTraits(.isHeader) Text(message).accessibilityIdentifier("media.access.message") + if feature == .model { + VStack(alignment: .leading, spacing: 12) { + Text( + "All models run in secure, encrypted environments", tableName: "Settings", + bundle: .module) + Text( + "Access to GLM 5.3, Kimi K2.6, and the full model lineup", tableName: "Settings", + bundle: .module) + Text( + "OpenAI GPT-OSS, Gemma, and other advanced models", tableName: "Settings", + bundle: .module) + Text( + "Higher token limits for longer conversations", tableName: "Settings", + bundle: .module) + Text( + "Priority access to new models as they launch", tableName: "Settings", + bundle: .module) + }.font(.subheadline) + } }.frame(maxWidth: .infinity, alignment: .leading).padding(24) } - .navigationTitle(feature.title).navigationBarTitleDisplayMode(.inline) + .navigationTitle(title).navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button(MapleStrings.text("Done")) { dismiss() } @@ -52,8 +74,20 @@ .presentationDetents([.medium, .large]).presentationDragIndicator(.visible) } + private var title: String { + guard feature == .model, let modelName else { return feature.title } + return String( + localized: "settings.model-access-title", defaultValue: "Access \(modelName)", + table: "Settings", bundle: .module) + } + private var message: String { - switch feature.access(in: account) { + if feature == .model { + return String( + localized: "Get access to our most advanced AI models for superior performance", + table: "Settings", bundle: .module) + } + return switch feature.access(in: account) { case .allowed: MapleStrings.text("This feature is available for your account.") case .unavailable: if account.billing == .loading || account.catalog == .loading { @@ -80,6 +114,10 @@ case .dictation: MapleStrings.text("Dictation requires an eligible paid plan.") case .speech: MapleStrings.text("Read aloud requires an eligible paid plan.") case .webSearch: MapleStrings.text("Web search requires an eligible paid plan.") + case .model: + String( + localized: "Get access to our most advanced AI models for superior performance", + table: "Settings", bundle: .module) } } @@ -89,6 +127,10 @@ case .webSearch: MapleStrings.text("Choose a model that supports web search.") case .dictation: MapleStrings.text("Dictation is currently unavailable. Try again later.") case .speech: MapleStrings.text("Read aloud is currently unavailable. Try again later.") + case .model: + String( + localized: "Get access to our most advanced AI models for superior performance", + table: "Settings", bundle: .module) } } } diff --git a/apps/maple-ios/Sources/MapleAppUI/Settings/PreferencePresentation.swift b/apps/maple-ios/Sources/MapleAppUI/Settings/PreferencePresentation.swift index 89bd96488..0708e73a4 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Settings/PreferencePresentation.swift +++ b/apps/maple-ios/Sources/MapleAppUI/Settings/PreferencePresentation.swift @@ -41,9 +41,22 @@ case .serif: "Serif" } } + var settingsDescription: String { + switch self { + case .manrope: "Maple's default, with a compact modern feel." + case .system: "The familiar interface font for your device." + case .serif: "A traditional reading style with distinct letterforms." + } + } } extension MapleVoice { + var settingsGroup: String { + switch self { + case .neutralFemale, .neutralMale, .casualFemale, .casualMale, .cheerfulFemale: "Default voices" + default: "Reference accents" + } + } var settingsLabel: String { switch self { case .neutralFemale: "Neutral — Female" diff --git a/apps/maple-ios/Sources/MapleAppUI/Settings/Settings.xcstrings b/apps/maple-ios/Sources/MapleAppUI/Settings/Settings.xcstrings index e5a2a8403..d4b1ff4ec 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Settings/Settings.xcstrings +++ b/apps/maple-ios/Sources/MapleAppUI/Settings/Settings.xcstrings @@ -1,6 +1,30 @@ { "sourceLanguage": "en", "strings": { + "A private AI workspace built for secure research and collaboration.": { + "comment": "About Maple tagline copied from Research.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A private AI workspace built for secure research and collaboration." + } + } + } + }, + "A traditional reading style with distinct letterforms.": { + "comment": "Description of selected Serif chat font.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A traditional reading style with distinct letterforms." + } + } + } + }, "About Maple": { "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", "extractionState": "manual", @@ -13,6 +37,18 @@ } } }, + "Access to GLM 5.3, Kimi K2.6, and the full model lineup": { + "comment": "Model access notice benefit copied from Research. Model names are brand names.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Access to GLM 5.3, Kimi K2.6, and the full model lineup" + } + } + } + }, "Account": { "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", "extractionState": "manual", @@ -49,6 +85,30 @@ } } }, + "All models run in secure, encrypted environments": { + "comment": "Model access notice benefit copied from Research.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All models run in secure, encrypted environments" + } + } + } + }, + "Any voice can read any supported language; your text determines the spoken language. Matching the voice accent to the text generally sounds most natural.": { + "comment": "Read-aloud voice help copied from Research.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Any voice can read any supported language; your text determines the spoken language. Matching the voice accent to the text generally sounds most natural." + } + } + } + }, "Appearance": { "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", "extractionState": "manual", @@ -169,62 +229,62 @@ } } }, - "Current period ends": { + "Current plan": { "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Current period ends" + "value": "Current plan" } } } }, - "Current plan": { + "Dark": { "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Current plan" + "value": "Dark" } } } }, - "Dark": { + "Default instructions": { "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Dark" + "value": "Default instructions" } } } }, - "Default instructions": { + "Default model": { "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Default instructions" + "value": "Default model" } } } }, - "Default model": { - "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "Default voices": { + "comment": "Group of the five default read-aloud voices.", "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Default model" + "value": "Default voices" } } } @@ -301,6 +361,18 @@ } } }, + "Expires on": { + "comment": "Label for pass or Zaprite expiration date.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Expires on" + } + } + } + }, "Free": { "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", "extractionState": "manual", @@ -361,6 +433,18 @@ } } }, + "Get access to our most advanced AI models for superior performance": { + "comment": "Model access notice description copied from Research.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Get access to our most advanced AI models for superior performance" + } + } + } + }, "Guest account": { "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", "extractionState": "manual", @@ -373,6 +457,18 @@ } } }, + "Higher token limits for longer conversations": { + "comment": "Model access notice benefit copied from Research.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Higher token limits for longer conversations" + } + } + } + }, "Hindi-accented — Female": { "comment": "Read-aloud voice label. Describes the provider voice or reference accent; this does not select the interface language.", "extractionState": "manual", @@ -457,6 +553,18 @@ } } }, + "Maple's default, with a compact modern feel.": { + "comment": "Description of selected Manrope chat font.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Maple's default, with a compact modern feel." + } + } + } + }, "Max": { "comment": "Font or product plan name. Preserve the proper name unless the product has an official localized name.", "extractionState": "manual", @@ -517,6 +625,18 @@ } } }, + "OpenAI GPT-OSS, Gemma, and other advanced models": { + "comment": "Model access notice benefit copied from Research. Model names are brand names.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "OpenAI GPT-OSS, Gemma, and other advanced models" + } + } + } + }, "Plan": { "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", "extractionState": "manual", @@ -577,6 +697,18 @@ } } }, + "Powerful AI Models": { + "comment": "Model availability notice title when no model name is supplied.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Powerful AI Models" + } + } + } + }, "Preferences": { "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", "extractionState": "manual", @@ -589,6 +721,18 @@ } } }, + "Priority access to new models as they launch": { + "comment": "Model access notice benefit copied from Research.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Priority access to new models as they launch" + } + } + } + }, "Privacy policy": { "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", "extractionState": "manual", @@ -637,6 +781,18 @@ } } }, + "Reference accents": { + "comment": "Group of read-aloud voices based on language accents.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reference accents" + } + } + } + }, "Refresh account status": { "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", "extractionState": "manual", @@ -649,6 +805,18 @@ } } }, + "Renews on": { + "comment": "Label for subscription renewal date.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Renews on" + } + } + } + }, "Reset password": { "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", "extractionState": "manual", @@ -817,6 +985,18 @@ } } }, + "The familiar interface font for your device.": { + "comment": "Description of selected System chat font.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The familiar interface font for your device." + } + } + } + }, "This change could not be saved on this device.": { "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", "extractionState": "manual", @@ -949,14 +1129,38 @@ } } }, - "Your web search preference is saved. Web search requires an eligible paid plan and a compatible model.": { - "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "Your web search preference is saved. Web search requires an eligible paid plan.": { + "comment": "Saved web-search preference explanation; access is determined by plan.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your web search preference is saved. Web search requires an eligible paid plan." + } + } + } + }, + "settings.model-access-title": { + "comment": "Model availability notice title. Placeholder is the verbatim model name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Access %@" + } + } + } + }, + "settings.plan-name": { + "comment": "Raw server product name followed by Plan. Placeholder is a product name; preserve it verbatim.", "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Your web search preference is saved. Web search requires an eligible paid plan and a compatible model." + "value": "%@ Plan" } } } diff --git a/apps/maple-ios/Sources/MapleAppUI/Settings/SettingsView.swift b/apps/maple-ios/Sources/MapleAppUI/Settings/SettingsView.swift index 9d692e49c..71d1533b6 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Settings/SettingsView.swift +++ b/apps/maple-ios/Sources/MapleAppUI/Settings/SettingsView.swift @@ -95,6 +95,8 @@ settingsText("Account management") } Section { + settingsText("A private AI workspace built for secure research and collaboration.") + .font(.subheadline).foregroundStyle(.secondary) LabeledContent { Text(verbatim: version) } label: { @@ -154,7 +156,16 @@ settingsText("Your plan could not be loaded. Try refreshing account status.") case let .loaded(status): LabeledContent { - planName(status.plan) + if let name = status.productName { + Text( + String( + localized: "settings.plan-name", defaultValue: "\(name) Plan", + table: "Settings", bundle: .module) + ) + .accessibilityIdentifier("settings.plan-name") + } else { + Text(verbatim: "—").foregroundStyle(.secondary) + } } label: { settingsText("Current plan") } @@ -162,7 +173,9 @@ LabeledContent { Text(date, format: .dateTime.year().month().day()) } label: { - settingsText("Current period ends") + settingsText( + status.paymentProvider == "subscription_pass" || status.paymentProvider == "zaprite" + ? "Expires on" : "Renews on") } } if status.plan == .unknown { @@ -213,6 +226,9 @@ settingsText("Chat font") } .accessibilityIdentifier("settings.font") + settingsText(preferences.value.chatFont.settingsDescription) + .font(.footnote).foregroundStyle(.secondary) + .accessibilityIdentifier("settings.font-description") NavigationLink { SettingsModelPicker(entitlements: entitlements) { id in preferences.setSelectedModelID(id) } } label: { @@ -230,16 +246,29 @@ .accessibilityIdentifier("settings.web-search") if entitlements.webSearchAccess != .allowed { settingsText( - "Your web search preference is saved. Web search requires an eligible paid plan and a compatible model." + "Your web search preference is saved. Web search requires an eligible paid plan." ) .font(.footnote).foregroundStyle(.secondary) } Picker(selection: Binding(get: { preferences.value.voice }, set: { preferences.setVoice($0) })) { - ForEach(MapleVoice.allCases, id: \.self) { settingsText($0.settingsLabel).tag($0) } + ForEach(["Default voices", "Reference accents"], id: \.self) { group in + Section { + ForEach(MapleVoice.allCases.filter { $0.settingsGroup == group }, id: \.self) { + settingsText($0.settingsLabel).tag($0) + } + } header: { + settingsText(group) + } + } } label: { settingsText("Read aloud voice") } .accessibilityIdentifier("settings.voice") + settingsText( + "Any voice can read any supported language; your text determines the spoken language. Matching the voice accent to the text generally sounds most natural." + ) + .font(.footnote).foregroundStyle(.secondary) + .accessibilityIdentifier("settings.voice-description") Picker( selection: Binding( get: { preferences.value.playbackSpeed }, set: { preferences.setPlaybackSpeed($0) } @@ -279,15 +308,6 @@ } } - private func planName(_ plan: BillingPlan) -> Text { - switch plan { - case .unknown: settingsText("Unknown") - case .free: settingsText("Free") - case .paid(.pro): settingsText("Pro") - case .paid(.max): settingsText("Max") - case .paid(.team): settingsText("Team") - } - } } /// Fixed app-owned keys are maintained in Settings.xcstrings. Constructing @@ -305,6 +325,12 @@ private let canInteract: @MainActor () -> Bool private let onSelection: (String) -> Void @State private var webSearchNoticePresented = false + @State private var modelNotice: ModelNotice? + + private struct ModelNotice: Identifiable { + let id = UUID() + let name: String + } public init( entitlements: AccountEntitlements, webSearchPreference: Binding? = nil, @@ -422,6 +448,9 @@ .sheet(isPresented: $webSearchNoticePresented) { MediaFeatureNotice(feature: .webSearch, account: entitlements) } + .sheet(item: $modelNotice) { notice in + MediaFeatureNotice(feature: .model, account: entitlements, modelName: notice.name) + } } private var hasOptionsGroup: Bool { projectRow != nil || webSearchPreference != nil } @@ -455,13 +484,32 @@ private func modelRow(_ model: AccountModelChoice) -> some View { Button { guard canInteract() else { return } - if entitlements.selectModel(model.id) == .allowed { onSelection(model.id) } + if entitlements.selectModel(model.id) == .allowed { + onSelection(model.id) + } else { + let name = entitlements.advancedModels.first { $0.id == model.targetModel }?.label ?? model.label + modelNotice = ModelNotice(name: name) + } } label: { HStack(spacing: 16) { VStack(alignment: .leading, spacing: 4) { Text(verbatim: model.label) .font(model.isAlias ? .title3.weight(.semibold) : MapleTypography.button) .foregroundStyle(.primary) + let badges = model.badges.filter { + $0 != "Pro" && $0.lowercased() != model.accessTier.lowercased() + } + if !badges.isEmpty { + HStack(spacing: 6) { + ForEach(Array(badges.enumerated()), id: \.offset) { _, badge in + Text(verbatim: badge) + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 8).padding(.vertical, 4) + .background(MapleSurface.canvas, in: Capsule()) + } + } + } if let description = model.description, !description.isEmpty { Text(verbatim: description) .font(.subheadline) @@ -485,7 +533,6 @@ .buttonStyle(.plain) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 12, leading: 24, bottom: 12, trailing: 24)) - .disabled(entitlements.modelAccess(model) != .allowed) .accessibilityAddTraits(entitlements.selectedModelID == model.id ? .isSelected : []) .accessibilityValue( entitlements.modelAccess(model) == .allowed diff --git a/apps/maple-ios/UITests/EntitlementUITests.swift b/apps/maple-ios/UITests/EntitlementUITests.swift index 70f6c9910..1d724d9b7 100644 --- a/apps/maple-ios/UITests/EntitlementUITests.swift +++ b/apps/maple-ios/UITests/EntitlementUITests.swift @@ -49,38 +49,74 @@ import XCTest tap("media.access.close") } - func testUnknownLoadingFailureRemainBlockedAndPreserveDraft() { + private func waitSendEnabled(_ expected: Bool) { + let changed = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "enabled == %@", NSNumber(value: expected)), + object: app.buttons["entitlement.send"]) + XCTAssertEqual(XCTWaiter.wait(for: [changed], timeout: 10), .completed) + } + + private func tapPickerChoice(_ label: String) { + let choices = app.descendants(matching: .any).matching( + NSPredicate( + format: "label == %@ AND (elementType == %d OR elementType == %d)", label, + XCUIElement.ElementType.button.rawValue, XCUIElement.ElementType.menuItem.rawValue)) + let ready = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + choices.allElementsBoundByIndex.filter { $0.isEnabled && $0.isHittable }.count == 1 + }, object: nil) + XCTAssertEqual(XCTWaiter.wait(for: [ready], timeout: 5), .completed) + let visible = choices.allElementsBoundByIndex.filter { $0.isEnabled && $0.isHittable } + XCTAssertEqual(visible.count, 1) + visible.first?.tap() + } + + func testBillingLoadingAndFailurePreserveAvailableChatAndDraft() { launch() XCTAssertFalse(app.buttons["entitlement.send"].isEnabled) notice("images", containing: "unavailable") scenario("loading", billing: "loading") - XCTAssertFalse(app.buttons["entitlement.send"].isEnabled) - XCTAssertFalse(app.staticTexts["Checking account access…"].exists) + waitSendEnabled(true) notice("dictation", containing: "still loading") scenario("failed", billing: "failed") - XCTAssertFalse(app.buttons["entitlement.send"].isEnabled) - XCTAssertTrue(app.staticTexts["Account access is unavailable. Your draft is preserved."].exists) + waitSendEnabled(true) + XCTAssertFalse(app.staticTexts["Account access is unavailable. Your draft is preserved."].exists) tap("entitlement.settings") XCTAssertTrue( app.staticTexts["Your plan could not be loaded. Try refreshing account status."].waitForExistence( timeout: 5)) - XCTAssertFalse(app.staticTexts["Free"].exists) - referenceScreenshot("failed read-only plan") + XCTAssertFalse(app.staticTexts["Free Plan"].exists) + referenceScreenshot("failed read-only plan with chat still available") tap("entitlement.settings.close") scenario("pro", billing: "paid") - XCTAssertTrue(app.buttons["entitlement.send"].isEnabled) + waitSendEnabled(true) } - func testFreeAndPaidModelMediaGatesStayReadOnly() { + func testFreeLockedModelsExplainAccessAndPaidMediaIgnoreModelCapabilities() { launch(); scenario("free", billing: "Free") - XCTAssertTrue(app.buttons["entitlement.send"].isEnabled) + waitSendEnabled(true) tap("entitlement.models") - XCTAssertTrue(app.buttons["settings.model.auto:quick"].waitForExistence(timeout: 5)) - XCTAssertTrue(app.buttons["settings.model.auto:quick"].isEnabled) - XCTAssertFalse(app.buttons["settings.model.auto:powerful"].isEnabled) + let quick = app.buttons["settings.model.auto:quick"] + let powerful = app.buttons["settings.model.auto:powerful"] + XCTAssertTrue(quick.waitForExistence(timeout: 5)) + XCTAssertTrue(quick.isEnabled) + XCTAssertTrue(powerful.isEnabled) + XCTAssertTrue(powerful.label.contains("Reasoning")) + XCTAssertTrue(powerful.label.contains("New")) + XCTAssertFalse(powerful.label.contains("Pro")) XCTAssertFalse(app.buttons["settings.model.synthetic-disabled"].exists) - referenceScreenshot("Free model picker") + powerful.tap() + let message = app.staticTexts["media.access.message"] + XCTAssertTrue(message.waitForExistence(timeout: 5)) + XCTAssertEqual(message.label, "Get access to our most advanced AI models for superior performance") + XCTAssertTrue(app.navigationBars["Access Power target"].exists) + XCTAssertTrue(app.staticTexts["All models run in secure, encrypted environments"].exists) + XCTAssertFalse(app.buttons["Upgrade"].exists) + XCTAssertFalse(app.buttons["Buy"].exists) + referenceScreenshot("locked model read-only explanation with badges") + tap("media.access.close") tap("entitlement.models.close") + waitLabel("entitlement.selected-model", "auto:quick") notice("images", containing: "paid plan") notice("dictation", containing: "paid plan") notice("speech", containing: "paid plan") @@ -94,39 +130,37 @@ import XCTest waitLabel("entitlement.web-effective", "Effective web on") tap("entitlement.models"); tap("settings.model.synthetic-text") waitLabel("entitlement.selected-model", "synthetic-text") - XCTAssertTrue(app.buttons["entitlement.send"].isEnabled) - notice("images", containing: "supports images") - notice("webSearch", containing: "supports web search") - XCTAssertEqual(app.buttons["entitlement.media.dictation"].value as? String, "available") + waitSendEnabled(true) + for feature in ["images", "dictation", "speech", "webSearch"] { + XCTAssertEqual(app.buttons["entitlement.media." + feature].value as? String, "available") + } waitLabel("entitlement.web-preference", "Web preference on") - waitLabel("entitlement.web-effective", "Effective web off") + waitLabel("entitlement.web-effective", "Effective web on") XCTAssertEqual(editor.value as? String, "Keep this unsent draft") } - func testGuestSeatUsageAndUnknownPlanShowDistinctAuthorityReasons() { + func testOnlyKnownFreeGuestsAreBlockedAndUnknownPlansKeepPaidFeaturesUnavailable() { launch(guest: true); scenario("free", billing: "Free") - XCTAssertFalse(app.buttons["entitlement.send"].isEnabled) + waitSendEnabled(false) XCTAssertTrue( app.staticTexts["This guest account can’t start a chat. Sign in with an eligible account."].exists) referenceScreenshot("existing guest chat restriction") scenario("pro", billing: "paid") - XCTAssertTrue(app.buttons["entitlement.send"].isEnabled) + waitSendEnabled(true) scenario("seat", billing: "paid") - let seat = app.staticTexts["Your team’s seat allocation needs attention."] - XCTAssertTrue(seat.waitForExistence(timeout: 5)) - XCTAssertFalse(app.buttons["entitlement.send"].isEnabled) - notice("images", containing: "seat allocation") + waitSendEnabled(true) + XCTAssertEqual(app.buttons["entitlement.media.images"].value as? String, "available") scenario("usage", billing: "paid") - XCTAssertTrue(app.staticTexts["You’ve reached your current chat allowance."].waitForExistence(timeout: 5)) - XCTAssertFalse(app.buttons["entitlement.send"].isEnabled) - notice("dictation", containing: "usage limit") + waitSendEnabled(true) + XCTAssertEqual(app.buttons["entitlement.media.dictation"].value as? String, "available") scenario("unknownPlan", billing: "unknownPlan") - XCTAssertFalse(app.buttons["entitlement.send"].isEnabled) + waitSendEnabled(true) notice("webSearch", containing: "plan details are unavailable") tap("entitlement.settings") XCTAssertTrue( app.staticTexts["Plan details are unavailable. Paid features remain unavailable until your plan is known."] .waitForExistence(timeout: 5)) + XCTAssertEqual(app.staticTexts["settings.plan-name"].label, "Unrecognized fixture plan Plan") for label in ["Upgrade", "Manage subscription", "Restore purchases", "Developer API", "Manage team"] { XCTAssertFalse(app.buttons[label].exists) } @@ -134,24 +168,50 @@ import XCTest tap("entitlement.settings.close") XCTAssertEqual(editor.value as? String, "Keep this unsent draft") } - func testShippingRetryFetchesRecoveredServiceAndPreservesUnsentDraft() { + + func testSettingsRefreshRecoversBillingWithoutBlockingAvailableChat() { launch(); scenario("failed", billing: "failed") - XCTAssertFalse(app.buttons["entitlement.send"].isEnabled) - XCTAssertTrue(app.buttons["Retry"].exists) - referenceScreenshot("account error with shipping Retry control and preserved draft") + waitSendEnabled(true) tap("entitlement.prepare-retry") waitLabel("entitlement.retry-service", "service ready; account unchanged") waitLabel("entitlement.billing", "failed") - XCTAssertFalse(app.buttons["entitlement.send"].isEnabled) - XCTAssertEqual(editor.value as? String, "Keep this unsent draft") - tap("Retry") + tap("entitlement.settings") + tap("Refresh account status") + XCTAssertTrue(app.staticTexts["settings.plan-name"].waitForExistence(timeout: 10)) + XCTAssertEqual(app.staticTexts["settings.plan-name"].label, "Pro Annual Plan") + tap("entitlement.settings.close") waitLabel("entitlement.billing", "paid") - let enabled = XCTNSPredicateExpectation( - predicate: NSPredicate(format: "enabled == true"), object: app.buttons["entitlement.send"]) - XCTAssertEqual(XCTWaiter.wait(for: [enabled], timeout: 10), .completed) + waitSendEnabled(true) XCTAssertEqual(editor.value as? String, "Keep this unsent draft") - XCTAssertFalse(app.buttons["Retry"].exists) - XCTAssertFalse(app.buttons["Upgrade"].exists) } + func testPlanDatesAndPreferenceDescriptionsMatchResearch() { + launch(); scenario("pro", billing: "paid") + tap("entitlement.settings") + XCTAssertEqual(app.staticTexts["settings.plan-name"].label, "Pro Annual Plan") + XCTAssertTrue(app.staticTexts["Renews on"].exists) + let fontDescription = app.staticTexts["settings.font-description"] + if !fontDescription.isHittable { app.swipeUp() } + XCTAssertEqual(fontDescription.label, "Maple's default, with a compact modern feel.") + tap("settings.voice") + XCTAssertTrue( + app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Default voices")).firstMatch + .exists) + XCTAssertTrue( + app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Reference accents")).firstMatch + .exists) + tapPickerChoice("Casual — Female") + tap("settings.speed") + tapPickerChoice("1.2×") + tap("entitlement.settings.close") + for id in ["pass", "zaprite"] { + scenario(id, billing: "paid") + tap("entitlement.settings") + XCTAssertEqual(app.staticTexts["settings.plan-name"].label, "Pro Annual Plan") + XCTAssertTrue(app.staticTexts["Expires on"].exists) + XCTAssertFalse(app.staticTexts["Renews on"].exists) + referenceScreenshot(id + " expiry label") + tap("entitlement.settings.close") + } + } } From 307754c44edbbbeb40e840a709fc444d9c7dbc47 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:56:46 +0000 Subject: [PATCH 12/17] ios-native parity validation: reveal settings rows and load retained library page --- .../UITests/EntitlementUITests.swift | 34 +++++++++++++++++-- .../UITests/LibraryControlsUITests.swift | 11 ++++-- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/apps/maple-ios/UITests/EntitlementUITests.swift b/apps/maple-ios/UITests/EntitlementUITests.swift index 1d724d9b7..57861f867 100644 --- a/apps/maple-ios/UITests/EntitlementUITests.swift +++ b/apps/maple-ios/UITests/EntitlementUITests.swift @@ -71,6 +71,36 @@ import XCTest visible.first?.tap() } + private func tapSettingsRow(_ id: String) { + let forms = app.collectionViews.matching(identifier: "settings.screen") + let form = forms.firstMatch + XCTAssertTrue(form.waitForExistence(timeout: 5)) + XCTAssertEqual(forms.count, 1) + let controls = form.buttons.matching(identifier: id) + let button = controls.firstMatch + func rowIsClear() -> Bool { + let window = app.windows.firstMatch, navigation = app.navigationBars["Settings"].firstMatch + guard controls.count == 1, button.exists, button.isEnabled, button.isHittable, + window.exists, navigation.exists + else { return false } + let region = window.frame.intersection(form.frame) + return !region.isNull && !region.isEmpty && region.contains(button.frame) + && button.frame.minY >= navigation.frame.maxY + 8 + && button.frame.maxY <= region.maxY - 8 + } + // A Settings List may not create a row until it scrolls into view. + // Reveal the exact owned control before requiring existence or tapping. + for _ in 0..<8 { + if rowIsClear() { break } + form.swipeUp() + } + XCTAssertEqual(controls.count, 1) + let clear = rowIsClear() + XCTAssertTrue(clear, "The Settings picker must clear the navigation and window boundaries") + guard clear else { return } + button.tap() + } + func testBillingLoadingAndFailurePreserveAvailableChatAndDraft() { launch() XCTAssertFalse(app.buttons["entitlement.send"].isEnabled) @@ -193,7 +223,7 @@ import XCTest let fontDescription = app.staticTexts["settings.font-description"] if !fontDescription.isHittable { app.swipeUp() } XCTAssertEqual(fontDescription.label, "Maple's default, with a compact modern feel.") - tap("settings.voice") + tapSettingsRow("settings.voice") XCTAssertTrue( app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Default voices")).firstMatch .exists) @@ -201,7 +231,7 @@ import XCTest app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Reference accents")).firstMatch .exists) tapPickerChoice("Casual — Female") - tap("settings.speed") + tapSettingsRow("settings.speed") tapPickerChoice("1.2×") tap("entitlement.settings.close") for id in ["pass", "zaprite"] { diff --git a/apps/maple-ios/UITests/LibraryControlsUITests.swift b/apps/maple-ios/UITests/LibraryControlsUITests.swift index 338d289b1..c6509f0bf 100644 --- a/apps/maple-ios/UITests/LibraryControlsUITests.swift +++ b/apps/maple-ios/UITests/LibraryControlsUITests.swift @@ -75,7 +75,12 @@ import XCTest } func testMultiSelectionMovesAndDeletesOnlySelectedChats() { - launch(); openHistory(); tap("library.actions"); tap("Select chats") + launch(); openHistory() + // This fixture intentionally returns two chats per page. Load the + // retained rows before asserting their controls survive bulk actions. + tap("Load more") + XCTAssertTrue(app.buttons["library.chat.active"].waitForExistence(timeout: 15)) + tap("library.actions"); tap("Select chats") selectAlphaAndBeta(); tap("Move selected chats"); tap("Synthetic project") label("library.fixture.moved", equals: "alpha,beta to fixture-project") XCTAssertFalse(app.buttons["Move selected chats"].exists) @@ -102,7 +107,9 @@ import XCTest label("library.fixture.project-state", equals: "project retained") XCTAssertFalse(app.buttons["Delete selected chats"].exists) XCTAssertFalse(app.buttons["library.selection-done"].exists) - XCTAssertTrue(app.buttons["library.chat-actions.active"].isHittable) + let retainedActions = app.buttons["library.chat-actions.active"] + XCTAssertTrue(retainedActions.waitForExistence(timeout: 15)) + XCTAssertTrue(retainedActions.isHittable) screenshot("Bulk selection preserves unrelated chats and project") } From 136bb9bde083507bb52b7b7fa63a75c8c6da3742 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:11:23 +0000 Subject: [PATCH 13/17] fix(swift-sdk): preserve legacy plan errors and simplify cleanup --- .../MapleAppCore/Auth/AuthCeremonies.swift | 2 +- .../Sources/MapleSDK/API/MapleTransport.swift | 2 - .../Sources/MapleSDK/API/ResearchAPI.swift | 1 + sdk/swift/Sources/MapleSDK/MapleClient.swift | 4 +- .../Transport/EncryptedTransport.swift | 18 +++---- .../MapleSDKTests/API/ResearchAPITests.swift | 48 +++++++++++++++++-- .../Transport/EncryptedTransportTests.swift | 2 +- .../Transport/LiveTransportTests.swift | 2 +- .../NativeHandoffBoundaryTests.swift | 4 +- .../MapleSDKTests/Trust/PCRTrustTests.swift | 7 +++ 10 files changed, 69 insertions(+), 21 deletions(-) diff --git a/apps/maple-ios/Sources/MapleAppCore/Auth/AuthCeremonies.swift b/apps/maple-ios/Sources/MapleAppCore/Auth/AuthCeremonies.swift index dcb08eb76..2d1b1e703 100644 --- a/apps/maple-ios/Sources/MapleAppCore/Auth/AuthCeremonies.swift +++ b/apps/maple-ios/Sources/MapleAppCore/Auth/AuthCeremonies.swift @@ -652,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 { diff --git a/sdk/swift/Sources/MapleSDK/API/MapleTransport.swift b/sdk/swift/Sources/MapleSDK/API/MapleTransport.swift index a908ea8c9..394e4b53e 100644 --- a/sdk/swift/Sources/MapleSDK/API/MapleTransport.swift +++ b/sdk/swift/Sources/MapleSDK/API/MapleTransport.swift @@ -74,7 +74,6 @@ public enum ResearchAPIError: Error, Sendable, Equatable, LocalizedError { case http(status: Int, reason: RejectionReason) case truncatedStream case invalidStream - case streamConsumerTooSlow case invalidCallback case expiredAuthenticationAttempt @@ -94,7 +93,6 @@ public enum ResearchAPIError: Error, Sendable, Equatable, LocalizedError { case .http: "The service could not complete the request." case .truncatedStream: "The response was interrupted before its final status arrived." case .invalidStream: "The service returned an invalid response stream." - case .streamConsumerTooSlow: "The response could not be processed quickly enough." case .invalidCallback: "The authentication callback is invalid. Restart sign-in." case .expiredAuthenticationAttempt: "Sign-in expired. Please try again." } diff --git a/sdk/swift/Sources/MapleSDK/API/ResearchAPI.swift b/sdk/swift/Sources/MapleSDK/API/ResearchAPI.swift index 259a2a5c5..d8287ac00 100644 --- a/sdk/swift/Sources/MapleSDK/API/ResearchAPI.swift +++ b/sdk/swift/Sources/MapleSDK/API/ResearchAPI.swift @@ -427,6 +427,7 @@ public struct ResearchAPI: Sendable { case "Usage limit reached": code = "usage_limit_reached" case "Free tier token limit exceeded": code = "free_tier_token_limit_exceeded" case "Message exceeds context limit": code = "message_exceeds_context_limit" + case "Model not available on current plan": code = "model_not_available_on_plan" default: return nil } } diff --git a/sdk/swift/Sources/MapleSDK/MapleClient.swift b/sdk/swift/Sources/MapleSDK/MapleClient.swift index 67db599f3..b15cf0c04 100644 --- a/sdk/swift/Sources/MapleSDK/MapleClient.swift +++ b/sdk/swift/Sources/MapleSDK/MapleClient.swift @@ -67,7 +67,7 @@ public final class MapleClient: Sendable { /// cleanup. Replacement credentials, including same-account re-login, are preserved. public func signOut(expected: CredentialSnapshot? = nil) async throws { let refresh = try await credentials.revoke(expected: expected) - try await transport.cancelNativeOAuthHandoff() + await transport.cancelNativeOAuthHandoff() if let refresh { try? await anonymousAPI.signOut(refreshToken: refresh) } } @@ -110,7 +110,7 @@ public final class MapleClient: Sendable { expectedPrincipalId: snapshot.principalId) try await api.confirmAccountDeletion(code: code, plaintextSecret: plaintextSecret) try await credentials.clearAccount(expected: snapshot) - try await transport.cancelNativeOAuthHandoff() + await transport.cancelNativeOAuthHandoff() } } diff --git a/sdk/swift/Sources/MapleSDK/Transport/EncryptedTransport.swift b/sdk/swift/Sources/MapleSDK/Transport/EncryptedTransport.swift index 5e138ae6c..970eb7ac8 100644 --- a/sdk/swift/Sources/MapleSDK/Transport/EncryptedTransport.swift +++ b/sdk/swift/Sources/MapleSDK/Transport/EncryptedTransport.swift @@ -172,7 +172,7 @@ public actor EncryptedTransport: OAuthSessionTransport { return result } - public func invalidateSession() throws { + public func invalidateSession() { establishing?.task.cancel(); establishing = nil; session = nil // Never restore a retired session in this process, even if deleting its // best-effort durable cache fails. @@ -181,8 +181,8 @@ public actor EncryptedTransport: OAuthSessionTransport { try? store.remove(key: sessionStorageKey) } - private func retire(_ old: AttestedSession) throws { - if session?.identity == old.identity { try invalidateSession() } + private func retire(_ old: AttestedSession) { + if session?.identity == old.identity { invalidateSession() } } public func send(_ request: MapleRequest) async throws -> MapleResponse { @@ -297,7 +297,7 @@ public actor EncryptedTransport: OAuthSessionTransport { body: AsyncThrowingStream { $0.yield(data); $0.finish() }, inferenceSendCount: 0) - } catch { try? retire(retained); throw error } + } catch { retire(retained); throw error } } public func prepareNativeOAuthHandoff() async throws -> PreparedNativeOAuthHandoff { @@ -348,12 +348,12 @@ public actor EncryptedTransport: OAuthSessionTransport { throw TransportError.authorityChanged } try await credentials.assertCurrent(snapshot) - try retire(retained) + retire(retained) let id = UUID() confirmations.removeAll() confirmations[id] = (snapshot, login, deadline) return NativeSignInConfirmation(id: id, principalID: login.id, email: login.email) - } catch { try? retire(retained); throw error } + } catch { retire(retained); throw error } } public func confirmNativeOAuthHandoff(_ confirmation: NativeSignInConfirmation) async throws -> CredentialSnapshot { @@ -364,9 +364,9 @@ public actor EncryptedTransport: OAuthSessionTransport { accessToken: login.accessToken, refreshToken: login.refreshToken, expected: snapshot) } - public func cancelNativeOAuthHandoff() throws { + public func cancelNativeOAuthHandoff() { confirmations.removeAll() - try invalidateSession() + invalidateSession() } private func refreshCredentials(expected: CredentialSnapshot) async throws { @@ -435,7 +435,7 @@ public actor EncryptedTransport: OAuthSessionTransport { session: current, requestID: TransportCrypto.random(count: 16), inferenceSendCount: inference ? attempt : 0) } catch TransportError.untrustedRecoveryHint(let code) { - try retire(current) + retire(current) guard attempt == 1, request.allowSessionRecovery, ![ "/auth/google/callback", "/auth/github/callback", "/auth/apple/callback", diff --git a/sdk/swift/Tests/MapleSDKTests/API/ResearchAPITests.swift b/sdk/swift/Tests/MapleSDKTests/API/ResearchAPITests.swift index 9874195fe..44fc3f564 100644 --- a/sdk/swift/Tests/MapleSDKTests/API/ResearchAPITests.swift +++ b/sdk/swift/Tests/MapleSDKTests/API/ResearchAPITests.swift @@ -323,6 +323,7 @@ private func object(_ request: MapleRequest) throws -> [String: JSONValue] { (403, "Usage limit reached", .usageLimit), (403, "Free tier token limit exceeded", .freeTierTokenLimit), (413, "Message exceeds context limit", .contextLimit), + (403, "Model not available on current plan", .modelUnavailable), ] for (status, message, reason) in cases { let body = String( @@ -340,7 +341,6 @@ private func object(_ request: MapleRequest) throws -> [String: JSONValue] { #"{"status":"403","message":"Usage limit reached"}"#, #"{"status":413,"message":"Usage limit reached"}"#, #"{"status":403,"message":"Usage limit reached soon"}"#, - #"{"status":403,"message":"Model not available on current plan"}"#, #"{"status":403,"message":"Usage limit reached""#, #"{"status":403,"message":"Usage limit reached"} trailing"#, #"{"status":403,"message":"Usage limit reached","padding":""# + String(repeating: "x", count: 4_096) + #""}"#, @@ -364,6 +364,46 @@ private func object(_ request: MapleRequest) throws -> [String: JSONValue] { await #expect(throws: ResearchAPIError.invalidStream) { try await api.checked(response) } } +@Test func legacyModelPlanDenialKeepsStructuredCodePrecedence() async throws { + let message = "Model not available on current plan" + let legacy = #"{"status":403,"message":"Model not available on current plan"}"# + let cases: [(String, [String: String], ResearchAPIError.RejectionReason)] = [ + (legacy, [:], .modelUnavailable), + (#"{"message":"Model not available on current plan"}"#, [:], .forbidden), + (#"{"status":403,"message":"Model not available on current plan","code":null}"#, [:], .modelUnavailable), + (#"{"status":413,"message":"Model not available on current plan"}"#, [:], .forbidden), + (#"{"status":403,"message":"Model not available on current plan soon"}"#, [:], .forbidden), + (legacy, ["x-opensecret-error-contract": "2"], .forbidden), + (#"{"status":403,"message":"Model not available on current plan","code":"unknown"}"#, [:], .forbidden), + (#"{"status":403,"message":"Model not available on current plan","error":{"code":false}}"#, [:], .forbidden), + ( + #"{"status":403,"message":"Model not available on current plan","code":"usage_limit_reached"}"#, [:], + .usageLimit + ), + ( + legacy, ["x-opensecret-error-contract": "1", "x-opensecret-error-code": "usage_limit_reached"], + .usageLimit + ), + ( + #"{"status":403,"message":"Model not available on current plan","code":"model_not_available_on_plan"}"#, + ["x-opensecret-error-contract": "1", "x-opensecret-error-code": "usage_limit_reached"], .forbidden + ), + ( + String( + data: try JSONEncoder().encode([ + "status": JSONValue.integer(403), "message": .string(message), + "padding": .string(String(repeating: "x", count: 4_096)), + ]), encoding: .utf8)!, [:], .forbidden + ), + ] + for (body, headers, reason) in cases { + let api = ResearchAPI(transport: APIRecorder([.init(status: 403, body: body, headers: headers)])) + await #expect(throws: ResearchAPIError.http(status: 403, reason: reason)) { + try await api.getConversation(id: "c") + } + } +} + @Test(arguments: ["0", "60", "61", "999999999999999999999", "01", "not-a-delay"]) func capacityContractAndBoundedRetryAfter(_ delay: String) throws { let response = MapleResponse( @@ -406,12 +446,14 @@ func capacityContractAndBoundedRetryAfter(_ delay: String) throws { (404, "User not found", .userNotFound), (400, "No valid email found for the Oauth account", .oauthEmailUnavailable), (400, "User is already verified", .alreadyVerified), - (403, "Model not available on current plan", .forbidden), + (403, "Model not available on current plan", .modelUnavailable), (413, "Payload too large", .payloadTooLarge), (429, "Too many requests", .rateLimited), ] for (status, message, expected) in cases { - let body = String(data: try JSONEncoder().encode(["message": message]), encoding: .utf8)! + let body = String( + data: try JSONEncoder().encode(["status": JSONValue.integer(Int64(status)), "message": .string(message)]), + encoding: .utf8)! let api = ResearchAPI(transport: APIRecorder([.init(status: status, body: body)])) await #expect(throws: ResearchAPIError.http(status: status, reason: expected)) { try await api.getUser() } } diff --git a/sdk/swift/Tests/MapleSDKTests/Transport/EncryptedTransportTests.swift b/sdk/swift/Tests/MapleSDKTests/Transport/EncryptedTransportTests.swift index 81efac862..93a73b2de 100644 --- a/sdk/swift/Tests/MapleSDKTests/Transport/EncryptedTransportTests.swift +++ b/sdk/swift/Tests/MapleSDKTests/Transport/EncryptedTransportTests.swift @@ -94,7 +94,7 @@ import Testing _ = try await collect(transport.send(request)) store.setReadFailure(false) store.setWriteFailure(true) - try await transport.invalidateSession() + await transport.invalidateSession() _ = try await collect(transport.send(request)) #expect(await network.handshakes == 2) #expect(await network.observations.count == 2) diff --git a/sdk/swift/Tests/MapleSDKTests/Transport/LiveTransportTests.swift b/sdk/swift/Tests/MapleSDKTests/Transport/LiveTransportTests.swift index f30a9be4d..f12574d0d 100644 --- a/sdk/swift/Tests/MapleSDKTests/Transport/LiveTransportTests.swift +++ b/sdk/swift/Tests/MapleSDKTests/Transport/LiveTransportTests.swift @@ -58,7 +58,7 @@ struct LiveTransportTests { // Reserve a real attested native handoff without minting or redeeming a grant. let prepared = try await client.transport.prepareNativeOAuthHandoff() #expect(prepared.sessionID.count == 32 && prepared.requestID.count == 32) - try await client.transport.cancelNativeOAuthHandoff() + await client.transport.cancelNativeOAuthHandoff() } } diff --git a/sdk/swift/Tests/MapleSDKTests/Transport/NativeHandoffBoundaryTests.swift b/sdk/swift/Tests/MapleSDKTests/Transport/NativeHandoffBoundaryTests.swift index dd9cc1974..4553cc0cc 100644 --- a/sdk/swift/Tests/MapleSDKTests/Transport/NativeHandoffBoundaryTests.swift +++ b/sdk/swift/Tests/MapleSDKTests/Transport/NativeHandoffBoundaryTests.swift @@ -36,9 +36,9 @@ import Testing let transport = try await EncryptedTransportTests().make(network) let prepared = try await transport.prepareNativeOAuthHandoff() if cancel { - try await transport.cancelNativeOAuthHandoff() + await transport.cancelNativeOAuthHandoff() } else { - try await transport.invalidateSession() + await transport.invalidateSession() } let replacement = try await transport.prepareNativeOAuthHandoff() #expect(replacement.sessionID != prepared.sessionID) diff --git a/sdk/swift/Tests/MapleSDKTests/Trust/PCRTrustTests.swift b/sdk/swift/Tests/MapleSDKTests/Trust/PCRTrustTests.swift index a460bc9e7..1f75ffde6 100644 --- a/sdk/swift/Tests/MapleSDKTests/Trust/PCRTrustTests.swift +++ b/sdk/swift/Tests/MapleSDKTests/Trust/PCRTrustTests.swift @@ -4,6 +4,13 @@ import Testing struct PCRTrustTests { @Test func embeddedRootsMatchCurrentAndRollbackApprovals() { + let production = PCRTrustPolicy.productionRoots + let development = PCRTrustPolicy.developmentRoots + for root in production + development { + #expect(root.utf8.count == 96) + #expect(root.utf8.allSatisfy { (48...57).contains($0) || (97...102).contains($0) }) + } + #expect(Set(production).isDisjoint(with: development)) #expect( PCRTrustPolicy.productionRoots == [ "21d27bd19cdd02632485a6e8874753e42cf7f098f1335bb62a9c23e7f64da9100c02dba582da17ac68052fa6be16b7a9", From ee8e0d21849f36bb17104e0495f5d3c3be925e0c Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:30:08 +0000 Subject: [PATCH 14/17] fix(ios): align model and media access with Research --- apps/maple-ios/README.md | 9 +- .../Account/AccountEntitlements.swift | 27 +--- .../Account/BillingContracts.swift | 14 +- .../Account/MediaFeatureNotice.swift | 4 +- .../Resources/Localizable.xcstrings | 34 +---- .../MapleAppUI/Settings/Settings.xcstrings | 36 ----- .../MapleAppUI/Settings/SettingsView.swift | 27 +--- .../Account/AccountEntitlementsTests.swift | 127 ++++++++++++++---- .../Account/BillingServiceTests.swift | 41 ++++++ .../LocalAccountIntegrationTests.swift | 11 +- .../Account/MediaAccessTests.swift | 4 +- .../UITests/EntitlementUITests.swift | 30 ++++- 12 files changed, 203 insertions(+), 161 deletions(-) diff --git a/apps/maple-ios/README.md b/apps/maple-ios/README.md index e5ddfa01c..1c04821d1 100644 --- a/apps/maple-ios/README.md +++ b/apps/maple-ios/README.md @@ -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 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 diff --git a/apps/maple-ios/Sources/MapleAppCore/Account/AccountEntitlements.swift b/apps/maple-ios/Sources/MapleAppCore/Account/AccountEntitlements.swift index fd10a80f2..52bef94b7 100644 --- a/apps/maple-ios/Sources/MapleAppCore/Account/AccountEntitlements.swift +++ b/apps/maple-ios/Sources/MapleAppCore/Account/AccountEntitlements.swift @@ -32,7 +32,7 @@ public struct AccountModelChoice: Equatable, Sendable, Identifiable { public private(set) var team: AccountLoadState = .unknown public private(set) var catalog: AccountLoadState = .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() @@ -179,11 +179,7 @@ public struct AccountModelChoice: Equatable, Sendable, Identifiable { public func modelAccess(_ model: AccountModelChoice) -> AccountAccess { guard active else { return .retired } - switch model.accessTier { - case "free": return .allowed - case "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 { @@ -204,13 +200,9 @@ public struct AccountModelChoice: Equatable, Sendable, Identifiable { public var chatAccess: AccountAccess { let account = accountChatAccess guard account == .allowed else { return account } - guard let selectedModel else { return .modelUnavailable } - // The picker gates new paid selections. Existing selections reach the - // server, which owns plan and usage authorization for each request. - return switch modelAccess(selectedModel) { - case .allowed, .paidPlanRequired: .allowed - case let access: access - } + // 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 { @@ -221,7 +213,7 @@ public struct AccountModelChoice: Equatable, Sendable, Identifiable { return plan.isPaid ? .allowed : .paidPlanRequired } - public var webSearchAccess: AccountAccess { paidFeatureAccess } + public var webSearchAccess: AccountAccess { active ? .allowed : .retired } public var effectiveWebSearch: Bool { webSearchPreferred && webSearchAccess == .allowed } public var imageAccess: AccountAccess { paidFeatureAccess } @@ -241,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 } } diff --git a/apps/maple-ios/Sources/MapleAppCore/Account/BillingContracts.swift b/apps/maple-ios/Sources/MapleAppCore/Account/BillingContracts.swift index 8ff1ba0ff..7fb3e0b9b 100644 --- a/apps/maple-ios/Sources/MapleAppCore/Account/BillingContracts.swift +++ b/apps/maple-ios/Sources/MapleAppCore/Account/BillingContracts.swift @@ -65,17 +65,17 @@ public enum BillingPlan: Equatable, Sendable { } public struct BillingConflict: Decodable, Equatable, Sendable { - public let otherProvider: String + public let otherProvider: String? /// Inert server data. It must never be interpreted as a route or action. - public let action: String + 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 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 { diff --git a/apps/maple-ios/Sources/MapleAppUI/Account/MediaFeatureNotice.swift b/apps/maple-ios/Sources/MapleAppUI/Account/MediaFeatureNotice.swift index 5855e774b..4d56e8249 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Account/MediaFeatureNotice.swift +++ b/apps/maple-ios/Sources/MapleAppUI/Account/MediaFeatureNotice.swift @@ -113,7 +113,7 @@ case .images: MapleStrings.text("Image attachments require an eligible paid plan.") case .dictation: MapleStrings.text("Dictation requires an eligible paid plan.") case .speech: MapleStrings.text("Read aloud requires an eligible paid plan.") - case .webSearch: MapleStrings.text("Web search requires an eligible paid plan.") + case .webSearch: MapleStrings.text("This feature is available for your account.") case .model: String( localized: "Get access to our most advanced AI models for superior performance", @@ -124,7 +124,7 @@ private var capabilityMessage: String { switch feature { case .images: MapleStrings.text("Choose a model that supports images.") - case .webSearch: MapleStrings.text("Choose a model that supports web search.") + case .webSearch: MapleStrings.text("This feature is available for your account.") case .dictation: MapleStrings.text("Dictation is currently unavailable. Try again later.") case .speech: MapleStrings.text("Read aloud is currently unavailable. Try again later.") case .model: diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/Localizable.xcstrings b/apps/maple-ios/Sources/MapleAppUI/Resources/Localizable.xcstrings index 8ef67af66..69b5f4964 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Resources/Localizable.xcstrings +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/Localizable.xcstrings @@ -229,18 +229,6 @@ } } }, - "Choose a model that supports web search.": { - "comment": "Read-only explanation or VoiceOver state for an unavailable image, dictation, read-aloud, or web-search control. Distinguish unknown account details from a confirmed plan, quota, or capability restriction; this screen does not offer purchases.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Choose a model that supports web search." - } - } - } - }, "Close image": { "comment": "Attached-image preview, fullscreen zoom, dismissal, or VoiceOver action.", "extractionState": "manual", @@ -482,7 +470,7 @@ } }, "Encrypted and private at every step": { - "comment": "Research composer privacy assurance displayed beside a lock glyph.", + "comment": "Research privacy assurance shown only below the empty new-chat composer beside a lock glyph.", "extractionState": "manual", "localizations": { "en": { @@ -1728,18 +1716,6 @@ } } }, - "Web search requires an eligible paid plan.": { - "comment": "Read-only explanation or VoiceOver state for an unavailable image, dictation, read-aloud, or web-search control. Distinguish unknown account details from a confirmed plan, quota, or capability restriction; this screen does not offer purchases.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Web search requires an eligible paid plan." - } - } - } - }, "Web sources": { "comment": "Compact chat tool disclosure. Use a friendly description instead of an internal tool identifier; failures remain visible and actionable.", "extractionState": "manual", @@ -2005,7 +1981,7 @@ } }, "You've reached your daily usage limit. Upgrade to Pro for more chats.": { - "comment": "Research usage-limit feedback for a known Free account; upgrade is explanatory copy only.", + "comment": "Research usage-limit feedback for a Free account or missing/empty billing product name; upgrade is explanatory copy only.", "extractionState": "manual", "localizations": { "en": { @@ -2017,7 +1993,7 @@ } }, "You've reached your monthly Pro limit. Upgrade to Max for 10x more usage.": { - "comment": "Research usage-limit feedback for a known Pro account; preserve the plan names and multiplier.", + "comment": "Research usage-limit feedback for product names containing Pro and not Max; preserve the plan names and multiplier.", "extractionState": "manual", "localizations": { "en": { @@ -2029,7 +2005,7 @@ } }, "You've reached your monthly usage limit. Please wait for the next billing cycle.": { - "comment": "Research usage-limit feedback for all other or unknown plans.", + "comment": "Research usage-limit feedback for any other nonempty billing product name, including unrecognized names.", "extractionState": "manual", "localizations": { "en": { @@ -2245,7 +2221,7 @@ } }, "AI can make mistakes. Check important info.": { - "comment": "Research informational footnote shown below the docked composer.", + "comment": "Research informational footnote shown below the docked composer; the empty new-chat composer shows the privacy reassurance instead.", "extractionState": "manual", "localizations": { "en": { diff --git a/apps/maple-ios/Sources/MapleAppUI/Settings/Settings.xcstrings b/apps/maple-ios/Sources/MapleAppUI/Settings/Settings.xcstrings index d4b1ff4ec..84041a02c 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Settings/Settings.xcstrings +++ b/apps/maple-ios/Sources/MapleAppUI/Settings/Settings.xcstrings @@ -649,18 +649,6 @@ } } }, - "Plan details are unavailable. Paid features remain unavailable until your plan is known.": { - "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Plan details are unavailable. Paid features remain unavailable until your plan is known." - } - } - } - }, "Playback speed": { "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", "extractionState": "manual", @@ -1081,18 +1069,6 @@ } } }, - "Why is web search unavailable?": { - "comment": "Explanation action under the disabled web-search switch at the top of the chat model selector. Opens the existing account, plan, or model compatibility explanation without changing the saved preference.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Why is web search unavailable?" - } - } - } - }, "Your account has reached its current chat allowance.": { "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", "extractionState": "manual", @@ -1129,18 +1105,6 @@ } } }, - "Your web search preference is saved. Web search requires an eligible paid plan.": { - "comment": "Saved web-search preference explanation; access is determined by plan.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Your web search preference is saved. Web search requires an eligible paid plan." - } - } - } - }, "settings.model-access-title": { "comment": "Model availability notice title. Placeholder is the verbatim model name.", "extractionState": "manual", diff --git a/apps/maple-ios/Sources/MapleAppUI/Settings/SettingsView.swift b/apps/maple-ios/Sources/MapleAppUI/Settings/SettingsView.swift index 71d1533b6..0999b0a3c 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Settings/SettingsView.swift +++ b/apps/maple-ios/Sources/MapleAppUI/Settings/SettingsView.swift @@ -178,10 +178,7 @@ ? "Expires on" : "Renews on") } } - if status.plan == .unknown { - settingsText( - "Plan details are unavailable. Paid features remain unavailable until your plan is known.") - } else if !status.canChat || status.chatsRemaining == 0 { + if !status.canChat || status.chatsRemaining == 0 { settingsText("Your account has reached its current chat allowance.") } if entitlements.team.value?.seatMismatch != nil { @@ -244,12 +241,6 @@ ) { settingsText("Web search") } .disabled(entitlements.webSearchAccess != .allowed) .accessibilityIdentifier("settings.web-search") - if entitlements.webSearchAccess != .allowed { - settingsText( - "Your web search preference is saved. Web search requires an eligible paid plan." - ) - .font(.footnote).foregroundStyle(.secondary) - } Picker(selection: Binding(get: { preferences.value.voice }, set: { preferences.setVoice($0) })) { ForEach(["Default voices", "Reference accents"], id: \.self) { group in Section { @@ -324,7 +315,6 @@ private let topContentMargin: CGFloat private let canInteract: @MainActor () -> Bool private let onSelection: (String) -> Void - @State private var webSearchNoticePresented = false @State private var modelNotice: ModelNotice? private struct ModelNotice: Identifiable { @@ -385,18 +375,6 @@ case .ready: EmptyView() } } - if entitlements.webSearchAccess != .allowed { - Button { - webSearchNoticePresented = true - } label: { - Image(systemName: "info.circle") - } - .frame(minWidth: 44, minHeight: 44) - .accessibilityLabel(settingsText("Why is web search unavailable?")) - .accessibilityValue(MapleStrings.text("Unavailable")) - .accessibilityHint(MapleStrings.text("Learn why this feature is unavailable.")) - .accessibilityIdentifier("model.web-search-unavailable") - } } } .listRowSeparator(.hidden) @@ -445,9 +423,6 @@ .frame(maxWidth: .infinity, maxHeight: .infinity) .disabled(!canInteract()) .accessibilityIdentifier("settings.models") - .sheet(isPresented: $webSearchNoticePresented) { - MediaFeatureNotice(feature: .webSearch, account: entitlements) - } .sheet(item: $modelNotice) { notice in MediaFeatureNotice(feature: .model, account: entitlements, modelName: notice.name) } diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Account/AccountEntitlementsTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Account/AccountEntitlementsTests.swift index e88d0ff67..3f59e1550 100644 --- a/apps/maple-ios/Tests/MapleAppCoreTests/Account/AccountEntitlementsTests.swift +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Account/AccountEntitlementsTests.swift @@ -24,7 +24,7 @@ import Testing selectedModelID: "auto:powerful") #expect(account.billing == .unknown) #expect(account.plan == .unknown) - #expect(account.chatAccess == .modelUnavailable) + #expect(account.chatAccess == .allowed) await account.refreshCatalog() #expect(account.chatAccess == .allowed) let load = Task { await account.refresh() } @@ -32,12 +32,13 @@ import Testing #expect(account.billing == .loading) #expect(account.plan == .unknown) #expect(account.webSearchPreferred) - #expect(!account.effectiveWebSearch) + #expect(account.effectiveWebSearch && account.speechAvailable) #expect(account.chatAccess == .allowed) await gate.release(); await load.value #expect(account.billing == .failed(.unavailable)) #expect(account.plan == .unknown) #expect(account.chatAccess == .allowed) + #expect(account.effectiveWebSearch && account.speechAvailable) } @Test func explicitFreeEmailAndGuestDifferWithoutPurchaseActions() async throws { @@ -45,12 +46,13 @@ import Testing let guest = try fixture(plan: "Free", guest: true) await email.refresh(); await guest.refresh() #expect(email.chatAccess == .allowed) - #expect(email.webSearchAccess == .paidPlanRequired) - #expect(email.webSearchPreferred && !email.effectiveWebSearch) + #expect(email.webSearchAccess == .allowed) + #expect(email.webSearchPreferred && email.effectiveWebSearch) #expect(!email.transcriptionAvailable && !email.speechAvailable) #expect(email.selectModel("auto:powerful") == .paidPlanRequired) #expect(email.selectedModelID == "auto:quick") #expect(guest.chatAccess == .freeGuest) + #expect(guest.webSearchAccess == .allowed && guest.effectiveWebSearch) } @Test(arguments: ["Pro", "Max monthly", "Team Annual"]) @@ -71,7 +73,7 @@ import Testing await account.refresh() #expect(account.plan == .unknown) #expect(account.chatAccess == .allowed) - #expect(!account.effectiveWebSearch) + #expect(account.effectiveWebSearch) } for account in [try fixture(canChat: false), try fixture(remaining: 0)] { await account.refresh() @@ -94,6 +96,25 @@ import Testing #expect(guest.chatAccess == .allowed) } + @Test func webSearchKeepsPreferenceAcrossUnavailableAccountDetailsAndRetirement() async throws { + let service = AccountServingStub( + statuses: [.failure(.unavailable)], teams: [.failure(.unavailable)], + catalogs: [.failure(.unavailable)]) + let account = AccountEntitlements(scope: accountScope(), isGuest: true, billing: service, catalog: service) + #expect(account.webSearchAccess == .allowed && account.effectiveWebSearch) + await account.refresh() + #expect(account.billing == .failed(.unavailable) && account.catalog == .failed(.unavailable)) + #expect(account.webSearchAccess == .allowed && account.effectiveWebSearch) + #expect(account.speechAccess == .allowed) + account.webSearchPreferred = false + #expect(!account.effectiveWebSearch) + account.webSearchPreferred = true + #expect(account.effectiveWebSearch) + await account.retire() + #expect(account.webSearchPreferred && !account.effectiveWebSearch) + #expect(account.webSearchAccess == .retired && account.speechAccess == .retired) + } + @Test func selectedPaidModelMayChatWithUnknownPlanWhilePickerRemainsPaidGated() async throws { for plan: String? in [nil, "", "Future Premium"] { let account = try fixture(plan: plan, selected: "auto:powerful") @@ -102,7 +123,7 @@ import Testing #expect(account.chatAccess == .allowed) #expect(account.selectModel("auto:powerful") == .paidPlanRequired) #expect(account.selectedModelID == "auto:powerful") - #expect(!account.effectiveWebSearch) + #expect(account.effectiveWebSearch) } } @@ -122,7 +143,7 @@ import Testing #expect(account.plan == .free) #expect(account.chatAccess == (guest ? .freeGuest : .allowed)) #expect(account.selectModel("auto:powerful") == .paidPlanRequired) - #expect(account.webSearchAccess == (guest ? .freeGuest : .paidPlanRequired)) + #expect(account.webSearchAccess == .allowed && account.effectiveWebSearch) await account.refreshBilling() #expect(account.billing == .failed(.unavailable)) #expect(account.chatAccess == .allowed) @@ -160,7 +181,7 @@ import Testing #expect(account.billing.value?.plan == .paid(.team)) } - @Test func catalogFiltersDisabledDeprecatedNonChatAndUnknownTier() async throws { + @Test func catalogFiltersDisabledDeprecatedNonChatAndPaidGatesEveryNonFreeTier() async throws { let catalog = accountCatalog(items: [ catalogItem(), catalogItem("power-target", access: "pro"), catalogItem("off", enabled: false), catalogItem("old", deprecated: true), catalogItem("audio", chat: false), @@ -172,10 +193,18 @@ import Testing #expect(Set(account.advancedModels.map(\.id)) == ["quick-target", "power-target", "future"]) #expect(account.advancedModels.count == 3) #expect(account.primaryModels.map(\.id) == ["auto:quick", "auto:powerful"]) - #expect(account.selectModel("future") == .modelUnavailable) + #expect(account.selectModel("future") == .allowed) + #expect(account.selectedModelID == "future") + let free = try fixture(plan: "Free", catalog: catalog) + await free.refresh() + #expect(free.selectModel("future") == .paidPlanRequired) + #expect(free.selectedModelID == "auto:quick") + for id in ["off", "old", "audio"] { + #expect(account.selectModel(id) == .modelUnavailable) + } } - @Test func catalogFailurePreservesSelectionAndDoesNotAdoptFallback() async throws { + @Test func catalogFailurePreservesSelectionAndStillAdmitsSending() async throws { let service = AccountServingStub( statuses: [.success(try billingStatus())], teams: [.success(try basicTeam())], catalogs: [.success(accountCatalog()), .failure(.unavailable)]) @@ -187,11 +216,43 @@ import Testing await account.refreshCatalog() #expect(account.selectedModelID == "power-target") #expect(account.catalog == .failed(.unavailable)) - #expect(account.chatAccess == .modelUnavailable) + #expect(account.chatAccess == .allowed) #expect(account.plan == .paid(.pro)) #expect(account.primaryModels.isEmpty) } + @Test(arguments: ["auto:quick", "auto:powerful", "saved-model"]) + func missingLoadingAndFailedCatalogAllowValidSavedSelection(_ selected: String) async throws { + let gate = AccountGate() + let service = AccountServingStub( + statuses: [.success(try billingStatus())], teams: [.success(try basicTeam())], + catalogs: [.failure(.unavailable)], catalogGates: [1: gate]) + let account = AccountEntitlements( + scope: accountScope(), isGuest: false, billing: service, catalog: service, + selectedModelID: selected) + #expect(account.catalog == .unknown && account.selectedModel == nil) + #expect(account.chatAccess == .allowed && account.selectedModelID == selected) + let refresh = Task { await account.refreshCatalog() } + await gate.waitUntilEntered() + #expect(account.catalog == .loading && account.chatAccess == .allowed) + #expect(account.selectedModelID == selected) + await gate.release(); await refresh.value + #expect(account.catalog == .failed(.unavailable) && account.selectedModel == nil) + #expect(account.chatAccess == .allowed && account.selectedModelID == selected) + await account.retire() + #expect(account.chatAccess == .retired) + } + + @Test func missingCatalogDoesNotBypassKnownFreeGuestOrInvalidSelection() async throws { + let guest = try fixture(plan: "Free", guest: true) + await guest.refreshBilling() + #expect(guest.catalog == .unknown && guest.chatAccess == .freeGuest) + for invalid in ["", "invalid model", "model\n", String(repeating: "a", count: 257)] { + let account = try fixture(selected: invalid) + #expect(account.catalog == .unknown && account.chatAccess == .modelUnavailable) + } + } + @Test func successfulCatalogReconcilesRemovedConcreteModelAndKeepsAliases() async throws { let first = accountCatalog() let second = accountCatalog( @@ -262,7 +323,7 @@ import Testing #expect(await service.statusCalls == calls) } - @Test func billingRefreshRetainsVerifiedAccessUntilDenialAndNeverRevivesFailedValue() async throws { + @Test func billingRefreshRetainsPaidImageAccessUntilDenialAndNeverRevivesFailedValue() async throws { let denied = AccountGate(), failed = AccountGate(), retried = AccountGate() let service = AccountServingStub( statuses: [ @@ -273,27 +334,30 @@ import Testing await account.refresh() let refresh = Task { await account.refreshBilling() } await denied.waitUntilEntered() - #expect(account.speechAccess == .allowed && account.speechAvailable && account.plan.isPaid) + #expect(account.imageAccess == .allowed && account.plan.isPaid) await denied.release(); await refresh.value - #expect(account.speechAccess == .paidPlanRequired && account.plan == .free) + #expect(account.imageAccess == .paidPlanRequired && account.plan == .free) let failure = Task { await account.refreshBilling() } await failed.waitUntilEntered() - #expect(account.speechAccess == .paidPlanRequired) + #expect(account.imageAccess == .paidPlanRequired && account.speechAccess == .paidPlanRequired) await failed.release(); await failure.value - #expect(account.speechAccess == .unavailable && account.billing == .failed(.unavailable)) + #expect(account.imageAccess == .unavailable && account.billing == .failed(.unavailable)) + #expect(account.speechAccess == .allowed && account.effectiveWebSearch) let retry = Task { await account.refreshBilling() } await retried.waitUntilEntered() - #expect(account.billing == .loading && account.plan == .unknown && account.speechAccess == .unavailable) + #expect(account.billing == .loading && account.plan == .unknown && account.imageAccess == .unavailable) + #expect(account.speechAccess == .allowed && account.effectiveWebSearch) await retried.release(); await retry.value - #expect(account.speechAccess == .allowed) + #expect(account.imageAccess == .allowed && account.speechAccess == .allowed) await account.retire() #expect(account.speechAccess == .retired && account.billing == .unknown && account.catalog == .unknown) + #expect(account.webSearchAccess == .retired && !account.effectiveWebSearch) } @Test func catalogRefreshRetainsKnownCapabilityUntilFailureThenRetryStartsUnknown() async throws { let failed = AccountGate(), retried = AccountGate() var unavailable = accountCatalog() - unavailable.audio?.speech = AudioModelAvailability(available: false, model: "voxtral-tts") + unavailable.audio?.transcription = AudioModelAvailability(available: false, model: "whisper-large-v3") let service = AccountServingStub( statuses: [.success(try billingStatus())], teams: [.success(try basicTeam())], catalogs: [.success(accountCatalog()), .failure(.unavailable), .success(unavailable)], @@ -302,26 +366,35 @@ import Testing await account.refresh() let failure = Task { await account.refreshCatalog() } await failed.waitUntilEntered() - #expect(account.speechAccess == .allowed && !account.primaryModels.isEmpty) + #expect(account.transcriptionAccess == .allowed && !account.primaryModels.isEmpty) await failed.release(); await failure.value - #expect(account.speechAccess == .unavailable && account.catalog == .failed(.unavailable)) + #expect(account.transcriptionAccess == .unavailable && account.catalog == .failed(.unavailable)) + #expect(account.speechAccess == .allowed) let retry = Task { await account.refreshCatalog() } await retried.waitUntilEntered() - #expect(account.catalog == .loading && account.speechAccess == .unavailable) + #expect(account.catalog == .loading && account.transcriptionAccess == .unavailable) await retried.release(); await retry.value - #expect(account.speechAccess == .modelIncompatible && !account.speechAvailable) + #expect(account.transcriptionAccess == .modelIncompatible && !account.transcriptionAvailable) + #expect(account.speechAvailable) } - @Test func speechAdmissionDistinguishesUnknownFreeAndMissingCapabilityWithoutQuotaGates() async throws { + @Test func speechAdmissionBlocksOnlyKnownFreePlanAndRetiredSession() async throws { let unknown = try fixture(plan: "Future Premium"), free = try fixture(plan: "Free", guest: true) let exhausted = try fixture(remaining: 0) var missing = accountCatalog(); missing.audio = nil let absent = try fixture(catalog: missing) - #expect(unknown.speechAccess == .unavailable) + #expect(unknown.speechAccess == .allowed) await unknown.refresh(); await free.refresh(); await exhausted.refresh(); await absent.refresh() - #expect(unknown.speechAccess == .unavailable) + #expect(unknown.speechAccess == .allowed) #expect(free.speechAccess == .paidPlanRequired) #expect(exhausted.speechAccess == .allowed) - #expect(absent.speechAccess == .unavailable) + #expect(absent.speechAccess == .allowed) + var disabled = accountCatalog() + disabled.audio?.speech = AudioModelAvailability(available: false, model: "voxtral-tts") + let disabledSpeech = try fixture(catalog: disabled) + await disabledSpeech.refresh() + #expect(disabledSpeech.speechAccess == .allowed) + await disabledSpeech.retire() + #expect(disabledSpeech.speechAccess == .retired && !disabledSpeech.speechAvailable) } } diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Account/BillingServiceTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Account/BillingServiceTests.swift index cd3dbc3d7..25bb5ae7c 100644 --- a/apps/maple-ios/Tests/MapleAppCoreTests/Account/BillingServiceTests.swift +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Account/BillingServiceTests.swift @@ -189,6 +189,47 @@ struct BillingServiceTests { } } + @Test(arguments: ["other_provider", "action", "id", "type", "target_plan_name", "status", "expires_at"]) + func missingOrNullBillingHintSubfieldsDoNotInvalidateStatus(_ field: String) throws { + for nulls in [false, true] { + var payload = try #require(JSONSerialization.jsonObject(with: billingBody()) as? [String: Any]) + var conflict: [String: Any] = ["other_provider": "apple", "action": "future_action"] + var pending: [String: Any] = [ + "id": "fixture-change", "type": "downgrade", "target_plan_name": "Max", "status": "pending", + "expires_at": "2025-01-01T00:00:00Z", + ] + if conflict[field] != nil { + if nulls { conflict[field] = NSNull() } else { conflict.removeValue(forKey: field) } + } else { + if nulls { pending[field] = NSNull() } else { pending.removeValue(forKey: field) } + } + payload["conflict"] = conflict; payload["pending_plan_change"] = pending + let status: BillingStatus = try accountDecode(payload) + try status.validate() + #expect(status.plan == .paid(.pro) && status.canChat) + let decodedConflict = try #require(status.conflict), decodedPending = try #require(status.pendingPlanChange) + #expect(decodedConflict.otherProvider == (field == "other_provider" ? nil : "apple")) + #expect(decodedConflict.action == (field == "action" ? nil : "future_action")) + #expect(decodedPending.id == (field == "id" ? nil : "fixture-change")) + #expect(decodedPending.type == (field == "type" ? nil : "downgrade")) + #expect(decodedPending.targetPlanName == (field == "target_plan_name" ? nil : "Max")) + #expect(decodedPending.status == (field == "status" ? nil : "pending")) + #expect(decodedPending.expiresAt == (field == "expires_at" ? nil : "2025-01-01T00:00:00Z")) + } + } + + @Test func emptyBillingHintObjectsDoNotInvalidateStatus() throws { + var payload = try #require(JSONSerialization.jsonObject(with: billingBody()) as? [String: Any]) + payload["conflict"] = [String: String](); payload["pending_plan_change"] = [String: String]() + let status: BillingStatus = try accountDecode(payload) + try status.validate() + #expect(status.plan == .paid(.pro) && status.canChat) + let conflict = try #require(status.conflict), pending = try #require(status.pendingPlanChange) + #expect(conflict.otherProvider == nil && conflict.action == nil) + #expect(pending.id == nil && pending.type == nil && pending.targetPlanName == nil) + #expect(pending.status == nil && pending.expiresAt == nil) + } + @Test func mixedDatesAndFutureManagementValuesStayInert() throws { let seconds: BillingDate = try JSONDecoder().decode(BillingDate.self, from: Data("1735689600".utf8)) let iso: BillingDate = try JSONDecoder().decode(BillingDate.self, from: Data(#""2025-01-01T00:00:00Z""#.utf8)) diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Account/LocalAccountIntegrationTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Account/LocalAccountIntegrationTests.swift index 2df6e54c7..e7ea50dee 100644 --- a/apps/maple-ios/Tests/MapleAppCoreTests/Account/LocalAccountIntegrationTests.swift +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Account/LocalAccountIntegrationTests.swift @@ -46,13 +46,22 @@ private final class AccountLiveMemoryStore: SecureStore, Sendable { #expect(account.team.value != nil) #expect(account.catalog.value != nil) #expect(!account.primaryModels.isEmpty) + let originalSelection = account.selectedModelID + let gemma = try #require(account.advancedModels.first { $0.id == "gemma4-31b" }) + #expect(gemma.accessTier != "free") + let expectedGemmaAccess: AccountAccess = role == "PRO" ? .allowed : .paidPlanRequired + #expect(account.modelAccess(gemma) == expectedGemmaAccess) + #expect(account.selectModel(gemma.id) == expectedGemmaAccess) + #expect(account.selectedModelID == (role == "PRO" ? gemma.id : originalSelection)) + #expect(account.selectModel(originalSelection) == .allowed) + #expect(account.selectedModelID == originalSelection) if role == "PRO" { #expect(account.plan.isPaid) #expect(account.chatAccess == .allowed) #expect(account.effectiveWebSearch) } else { #expect(account.plan == .free) - #expect(!account.effectiveWebSearch) + #expect(account.effectiveWebSearch) } // SDK credential refresh preserves the captured account handle and // is independent of the third-party token cache. diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Account/MediaAccessTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Account/MediaAccessTests.swift index b78441119..3bcd00bfd 100644 --- a/apps/maple-ios/Tests/MapleAppCoreTests/Account/MediaAccessTests.swift +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Account/MediaAccessTests.swift @@ -71,12 +71,12 @@ import Testing var unknownTier = catalogItem(access: "future"); unknownTier.capabilities.vision = true let unsupported = try account(catalog: accountCatalog(items: [unknownTier])) await unsupported.refresh() - #expect(unsupported.chatAccess == .modelUnavailable) + #expect(unsupported.chatAccess == .allowed) #expect(unsupported.imageAccess == .allowed && unsupported.webSearchAccess == .allowed) #expect(unsupported.transcriptionAccess == .allowed) let missing = try account(catalog: accountCatalog(items: [])); await missing.refresh() - #expect(missing.chatAccess == .modelUnavailable) + #expect(missing.chatAccess == .allowed) #expect(missing.imageAccess == .allowed && missing.webSearchAccess == .allowed) } diff --git a/apps/maple-ios/UITests/EntitlementUITests.swift b/apps/maple-ios/UITests/EntitlementUITests.swift index 57861f867..26d03a6d7 100644 --- a/apps/maple-ios/UITests/EntitlementUITests.swift +++ b/apps/maple-ios/UITests/EntitlementUITests.swift @@ -103,13 +103,23 @@ import XCTest func testBillingLoadingAndFailurePreserveAvailableChatAndDraft() { launch() - XCTAssertFalse(app.buttons["entitlement.send"].isEnabled) + XCTAssertTrue(app.buttons["entitlement.send"].isEnabled) notice("images", containing: "unavailable") + for feature in ["speech", "webSearch"] { + XCTAssertEqual(app.buttons["entitlement.media." + feature].value as? String, "available") + } + waitLabel("entitlement.web-effective", "Effective web on") scenario("loading", billing: "loading") waitSendEnabled(true) notice("dictation", containing: "still loading") + for feature in ["speech", "webSearch"] { + XCTAssertEqual(app.buttons["entitlement.media." + feature].value as? String, "available") + } scenario("failed", billing: "failed") waitSendEnabled(true) + for feature in ["speech", "webSearch"] { + XCTAssertEqual(app.buttons["entitlement.media." + feature].value as? String, "available") + } XCTAssertFalse(app.staticTexts["Account access is unavailable. Your draft is preserved."].exists) tap("entitlement.settings") XCTAssertTrue( @@ -150,9 +160,10 @@ import XCTest notice("images", containing: "paid plan") notice("dictation", containing: "paid plan") notice("speech", containing: "paid plan") - notice("webSearch", containing: "paid plan") + XCTAssertEqual(app.buttons["entitlement.media.webSearch"].value as? String, "available") + notice("webSearch", containing: "available for your account") waitLabel("entitlement.web-preference", "Web preference on") - waitLabel("entitlement.web-effective", "Effective web off") + waitLabel("entitlement.web-effective", "Effective web on") scenario("pro", billing: "paid") for feature in ["images", "dictation", "speech", "webSearch"] { XCTAssertEqual(app.buttons["entitlement.media." + feature].value as? String, "available") @@ -169,7 +180,7 @@ import XCTest XCTAssertEqual(editor.value as? String, "Keep this unsent draft") } - func testOnlyKnownFreeGuestsAreBlockedAndUnknownPlansKeepPaidFeaturesUnavailable() { + func testOnlyKnownFreeGuestsAreBlockedAndUnknownPlansKeepImagesAndDictationUnavailable() { launch(guest: true); scenario("free", billing: "Free") waitSendEnabled(false) XCTAssertTrue( @@ -185,11 +196,16 @@ import XCTest XCTAssertEqual(app.buttons["entitlement.media.dictation"].value as? String, "available") scenario("unknownPlan", billing: "unknownPlan") waitSendEnabled(true) - notice("webSearch", containing: "plan details are unavailable") + for feature in ["speech", "webSearch"] { + XCTAssertEqual(app.buttons["entitlement.media." + feature].value as? String, "available") + } + waitLabel("entitlement.web-effective", "Effective web on") + notice("images", containing: "plan details are unavailable") + notice("dictation", containing: "plan details are unavailable") tap("entitlement.settings") - XCTAssertTrue( + XCTAssertFalse( app.staticTexts["Plan details are unavailable. Paid features remain unavailable until your plan is known."] - .waitForExistence(timeout: 5)) + .exists) XCTAssertEqual(app.staticTexts["settings.plan-name"].label, "Unrecognized fixture plan Plan") for label in ["Upgrade", "Manage subscription", "Restore purchases", "Developer API", "Manage team"] { XCTAssertFalse(app.buttons[label].exists) From 6425bbb7f3a7e246c47704b41d760aa51c37787f Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:33:55 +0000 Subject: [PATCH 15/17] fix(ios): preserve stopped output and recover options navigation --- .../MapleAppCore/Chat/ChatRuntime.swift | 78 ++++-- .../Chat/ChatRecoveryControls.swift | 63 ++--- .../Sources/MapleAppUI/Chat/ChatScreen.swift | 41 ++-- .../Navigation/CompactMenuNavigation.swift | 34 ++- .../ShippingViewportFixtureView.swift | 27 ++- .../MapleAppCoreTests/Chat/ChatFixtures.swift | 3 + .../Chat/ChatRuntimeTests.swift | 226 +++++++++++++++++- .../Composer/ComposerFlowTests.swift | 29 ++- .../UITests/ReferenceComparisonUITests.swift | 119 ++++++--- .../UITests/ShippingViewportUITests.swift | 85 +++++++ 10 files changed, 581 insertions(+), 124 deletions(-) diff --git a/apps/maple-ios/Sources/MapleAppCore/Chat/ChatRuntime.swift b/apps/maple-ios/Sources/MapleAppCore/Chat/ChatRuntime.swift index 556620d6d..7a8c00771 100644 --- a/apps/maple-ios/Sources/MapleAppCore/Chat/ChatRuntime.swift +++ b/apps/maple-ios/Sources/MapleAppCore/Chat/ChatRuntime.swift @@ -9,7 +9,13 @@ public final class ChatRuntime { public let scope: AccountScope public private(set) var snapshot: ChatSnapshot public private(set) var messages: [ConversationItem] = [] - public private(set) var lastIssue: ChatIssue? + public private(set) var lastIssue: ChatIssue? { + didSet { + lastIssueID = lastIssue == nil ? nil : hooks.uuid() + acknowledgedIssueID = nil + } + } + public private(set) var lastIssueID: UUID? public private(set) var lastOutcome: ChatOutcome? public private(set) var isLoading = false public private(set) var isLoadingOlder = false @@ -57,6 +63,7 @@ public final class ChatRuntime { @ObservationIgnored private var deletionRecoveryTask: Task? @ObservationIgnored private var deletedStorageKey: String? @ObservationIgnored private var pendingAcceptances: [ChatSendAcceptance] = [] + @ObservationIgnored private var acknowledgedIssueID: UUID? public init( scope: AccountScope, service: any ChatServing, persistence: any ChatPersistence, @@ -89,6 +96,20 @@ public final class ChatRuntime { } } + /// Presentation acknowledgment belongs to this account-owned runtime, so + /// re-mounting its screen cannot present the same issue occurrence again. + public func pendingIssuePresentationID(for issue: ChatIssue) -> UUID? { + guard valid, lastIssue == issue, let id = lastIssueID, acknowledgedIssueID != id else { return nil } + return id + } + + @discardableResult + public func acknowledgeIssuePresentation(id: UUID) -> Bool { + guard valid, lastIssueID == id, lastIssue != nil, acknowledgedIssueID != id else { return false } + acknowledgedIssueID = id + return true + } + public func updateDraft(_ draft: ChatDraft) { guard valid, !isLoading, draft.id == snapshot.draft.id else { return } snapshot.draft = draft; changed(); scheduleAutosave() @@ -763,15 +784,20 @@ public final class ChatRuntime { case let .reasoningTextDelta(value): try Self.text(value, reasoning: true, final: false, into: &next) case let .reasoningTextDone(value): try Self.text(value, reasoning: true, final: true, into: &next) case let .toolCallCreated(value): + let status = next.first { $0.id == value.toolCallId }?.status ?? "in_progress" Self.merge( ConversationItem( - id: value.toolCallId, type: "function_call", callId: value.toolCallId, + id: value.toolCallId, type: "function_call", status: status, callId: value.toolCallId, name: value.name, arguments: value.arguments), into: &next, authoritative: false) case let .toolOutputCreated(value): Self.merge( ConversationItem( - id: value.toolOutputId, type: "function_call_output", callId: value.toolCallId, + id: value.toolOutputId, type: "function_call_output", status: "completed", + callId: value.toolCallId, output: .string(value.output)), into: &next, authoritative: false) + if let index = next.firstIndex(where: { $0.id == value.toolCallId && $0.type == "function_call" }) { + next[index].status = "completed" + } case let .webSearchInProgress(value), let .webSearchSearching(value): Self.merge( ConversationItem(id: value.itemId, type: "web_search_call", status: "in_progress"), @@ -851,15 +877,21 @@ public final class ChatRuntime { private func applyRecovery(_ response: ResearchResponse, runID: UUID, operation: UUID, recovered: Bool = true) async { guard owns(runID, operation), let chatID = conversationID else { return } - let outcome = Self.outcome(response.status) + let outcome: ChatOutcome? = + response.status == .failed && (recovered || snapshot.pendingRun?.stopRequested == true) + ? .cancelled : Self.outcome(response.status) for item in response.output ?? [] { Self.merge(item, into: &messages, authoritative: outcome != nil, responseID: response.id) } + let priorHistoryIssueID = lastIssue == .historyUnavailable ? lastIssueID : nil do { let page = try await service.listConversationItems( conversationId: chatID, parameters: ListParameters(limit: 100, order: .desc)) guard owns(runID, operation) else { return } mergeHistory(page.data.reversed(), authoritative: outcome != nil) + if let priorHistoryIssueID, lastIssue == .historyUnavailable, lastIssueID == priorHistoryIssueID { + lastIssue = nil + } } catch { if owns(runID, operation) { lastIssue = .historyUnavailable } } guard owns(runID, operation) else { return } if let outcome { @@ -883,7 +915,9 @@ public final class ChatRuntime { .union(messages.filter { responseID != nil && $0.responseId == responseID && $0.role != "user" }.map(\.id)) for index in messages.indices where outputIDs.contains(messages[index].id) { if messages[index].responseId == nil { messages[index].responseId = responseID } - if outcome == .cancelled { messages[index].status = "incomplete" } + if outcome == .cancelled, ["in_progress", "streaming", "searching"].contains(messages[index].status ?? "") { + messages[index].status = "incomplete" + } } let completion = chatID.map { id in ChatCompletion( @@ -893,16 +927,26 @@ public final class ChatRuntime { recovered: recovered, wasStopped: wasStopped, wasVisible: isVisible, wasForeground: isForeground) } snapshot.pendingRun = nil; changed(); lastOutcome = outcome - if outcome == .failed, !wasStopped { - lastIssue = .generationFailed - } else if outcome == .completed, wasStopped { - lastIssue = .responseFinishedBeforeStop - } else if recovered, outcome == .completed { - lastIssue = .recoveredCompletion - } else if recovered, outcome == .cancelled || outcome == .failed { - lastIssue = .recoveredStop + let settlementIssue: ChatIssue? + if outcome == .completed, wasStopped { + settlementIssue = .responseFinishedBeforeStop + } else if recovered { + settlementIssue = outcome == .completed ? .recoveredCompletion : .recoveredStop + } else if outcome == .failed, !wasStopped { + settlementIssue = .generationFailed } else { - lastIssue = nil + settlementIssue = nil + } + // Settlement owns transient run feedback. History, storage and current + // composer/queue failures remain visible until their own recovery. + if lastIssue == nil + || [ + ChatIssue.uncertainResponse, .cancellationUnavailable, .generationFailed, + .stoppedBeforeSending, .stoppingWhenResponseReady, .responseFinishedBeforeStop, + .recoveredCompletion, .recoveredStop, + ].contains(where: { $0 == lastIssue }) + { + lastIssue = settlementIssue } runTask?.cancel(); cancelTask?.cancel() runTask = nil; cancelTask = nil @@ -1136,8 +1180,8 @@ public final class ChatRuntime { static func outcome(_ status: ResponseStatus) -> ChatOutcome? { switch status { case .completed: .completed - case .cancelled: .cancelled - case .failed, .incomplete: .failed + case .cancelled, .incomplete: .cancelled + case .failed: .failed case .queued, .inProgress: nil } } @@ -1294,7 +1338,7 @@ public final class ChatRuntime { parts[value.contentIndex].text = (parts[value.contentIndex].text ?? "") + (value.delta ?? "") } items[index].content = parts - if reasoning, items[index].status != "incomplete" { + if items[index].status != "incomplete" { items[index].status = final ? "completed" : "in_progress" } } diff --git a/apps/maple-ios/Sources/MapleAppUI/Chat/ChatRecoveryControls.swift b/apps/maple-ios/Sources/MapleAppUI/Chat/ChatRecoveryControls.swift index f125f2f9c..395365e15 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Chat/ChatRecoveryControls.swift +++ b/apps/maple-ios/Sources/MapleAppUI/Chat/ChatRecoveryControls.swift @@ -2,48 +2,51 @@ import MapleAppCore /// Read-only chat feedback copied from the Research client. enum ChatRecoveryPresentation { - static func message(_ issue: ChatIssue, plan: BillingPlan = .unknown) -> String { + static func message(_ issue: ChatIssue, productName: String? = nil) -> String { switch issue { - case .offline: MapleStrings.text("You’re offline. Your draft is saved when protected storage is available.") + case .offline: + return MapleStrings.text("You’re offline. Your draft is saved when protected storage is available.") case .storageUnavailable: - MapleStrings.text("Your draft hasn’t been saved. Unlock your device and try again.") + return MapleStrings.text("Your draft hasn’t been saved. Unlock your device and try again.") case .uncertainResponse: - MapleStrings.text("The response is still being checked. Your message will not be sent again.") + return MapleStrings.text("The response is still being checked. Your message will not be sent again.") case .cancellationUnavailable: - MapleStrings.text("Stop hasn’t been confirmed. Check the response to try again.") + return MapleStrings.text("Stop hasn’t been confirmed. Check the response to try again.") case .usageLimit: - switch plan { - case .free: - MapleStrings.text("You've reached your daily usage limit. Upgrade to Pro for more chats.") - case .paid(.pro): - MapleStrings.text("You've reached your monthly Pro limit. Upgrade to Max for 10x more usage.") - default: - MapleStrings.text("You've reached your monthly usage limit. Please wait for the next billing cycle.") + let plan = productName?.lowercased() + if plan == nil || plan?.isEmpty == true || plan == "free" { + return MapleStrings.text("You've reached your daily usage limit. Upgrade to Pro for more chats.") + } else if plan?.contains("pro") == true, plan?.contains("max") == false { + return MapleStrings.text("You've reached your monthly Pro limit. Upgrade to Max for 10x more usage.") + } else { + return MapleStrings.text( + "You've reached your monthly usage limit. Please wait for the next billing cycle.") } case .freeTierTokenLimit: - MapleStrings.text( + return MapleStrings.text( "This conversation is too long for the free tier. Upgrade to Pro for longer conversations.") - case .contextLimit: MapleStrings.text("Your message exceeds the context limit for this model.") - case .modelUnavailable: MapleStrings.text("This model or feature is not available on your current plan.") - case .generationFailed: MapleStrings.text("Failed to generate response. Please try again.") - case .stoppedBeforeSending: MapleStrings.text("Stopped before sending. Your message was restored.") - case .stoppingWhenResponseReady: MapleStrings.text("Stopping as soon as the response is ready…") - case .responseFinishedBeforeStop: MapleStrings.text("The response finished before it could be stopped.") + case .contextLimit: return MapleStrings.text("Your message exceeds the context limit for this model.") + case .modelUnavailable: return MapleStrings.text("This model or feature is not available on your current plan.") + case .generationFailed: return MapleStrings.text("Failed to generate response. Please try again.") + case .stoppedBeforeSending: return MapleStrings.text("Stopped before sending. Your message was restored.") + case .stoppingWhenResponseReady: return MapleStrings.text("Stopping as soon as the response is ready…") + case .responseFinishedBeforeStop: return MapleStrings.text("The response finished before it could be stopped.") case .recoveredCompletion: - MapleStrings.text("The response completed after the connection was restored. Queued messages were kept.") - case .recoveredStop: MapleStrings.text("The interrupted response stopped. Queued messages were kept.") + return MapleStrings.text( + "The response completed after the connection was restored. Queued messages were kept.") + case .recoveredStop: return MapleStrings.text("The interrupted response stopped. Queued messages were kept.") case .imageDescriptionUnavailable: - MapleStrings.text( + return MapleStrings.text( "Image description is temporarily unavailable. Your message and images were restored; please try again." ) case .inferenceCapacityUnavailable: - MapleStrings.text( + return MapleStrings.text( "Inference capacity is temporarily unavailable. Your message was restored. Please try again shortly.") - case .historyUnavailable: MapleStrings.text("Couldn’t load the conversation. Try again when connected.") - case .invalidImages: MapleStrings.text("Check the image attachments and try again.") - case .draftTooLarge: MapleStrings.text("This message is too large. Shorten it and try again.") - case .signedOut: MapleStrings.text("Your session has ended. Sign in again.") - default: MapleStrings.text("The request couldn’t finish. Your unsent work is preserved.") + case .historyUnavailable: return MapleStrings.text("Couldn’t load the conversation. Try again when connected.") + case .invalidImages: return MapleStrings.text("Check the image attachments and try again.") + case .draftTooLarge: return MapleStrings.text("This message is too large. Shorten it and try again.") + case .signedOut: return MapleStrings.text("Your session has ended. Sign in again.") + default: return MapleStrings.text("The request couldn’t finish. Your unsent work is preserved.") } } @@ -60,10 +63,10 @@ enum ChatRecoveryPresentation { /// Shared shipping presentation for recoverable account-owned chat work. struct ChatRecoveryControls: View { @Bindable var runtime: ChatRuntime - var plan: BillingPlan = .unknown + var productName: String? = nil var body: some View { if let issue = runtime.lastIssue { - Text(ChatRecoveryPresentation.message(issue, plan: plan)) + Text(ChatRecoveryPresentation.message(issue, productName: productName)) .font(.footnote).foregroundStyle(MapleRedText()) .accessibilityIdentifier("chat.error") } diff --git a/apps/maple-ios/Sources/MapleAppUI/Chat/ChatScreen.swift b/apps/maple-ios/Sources/MapleAppUI/Chat/ChatScreen.swift index 6f0930ac5..8990c7e9f 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Chat/ChatScreen.swift +++ b/apps/maple-ios/Sources/MapleAppUI/Chat/ChatScreen.swift @@ -74,6 +74,10 @@ enum ChatTranscriptPresentation { } enum ChatComposerPresentation { + static func isEmptyState(hasMessages: Bool, conversationID: String?) -> Bool { + !hasMessages && conversationID == nil + } + static func expanded(newConversation: Bool, editingQueue: Bool, focused: Bool, keyboardVisible: Bool?) -> Bool { newConversation || editingQueue || (focused && keyboardVisible != false) } @@ -408,6 +412,7 @@ enum ChatComposerPresentation { @Environment(\.layoutDirection) private var layoutDirection @Environment(\.horizontalSizeClass) private var sizeClass @Environment(\.compactMenuChatState) private var compactMenuState + @Environment(\.compactMenuPresentationDismissed) private var compactMenuPresentationDismissed @Bindable var runtime: ChatRuntime @Bindable var account: AccountEntitlements @Bindable var preferences: PreferencesController @@ -422,7 +427,6 @@ enum ChatComposerPresentation { @State private var queuePresented = false @State private var modelPresented = false @State private var contextLimitPresented = false - @State private var contextLimitPending = false @State private var externalLink: URL? @State private var browserPresentationID = UUID() @State private var mediaNotice: MediaFeature? @@ -933,9 +937,8 @@ enum ChatComposerPresentation { .environment(\.chatPresentationChanged) { id, presented in environment.setPresentationBlocked(presented, id: id, scope: runtime.scope) } - .onChange(of: runtime.lastIssue, initial: true) { _, issue in + .onChange(of: runtime.lastIssueID, initial: true) { _, _ in // Rejected drafts have already been restored by the runtime. - contextLimitPending = issue == .contextLimit presentContextLimitIfNeeded() } .alert(MapleStrings.text("Message Too Large"), isPresented: $contextLimitPresented) { @@ -973,7 +976,7 @@ enum ChatComposerPresentation { .sheet(item: $mediaNotice) { feature in MediaFeatureNotice(feature: feature, account: account) } - .sheet(isPresented: $modelPresented) { + .sheet(isPresented: $modelPresented, onDismiss: compactMenuPresentationDismissed) { let scope = runtime.scope, owner = runtime.snapshot.storageOwner if let context = environment.libraryContext, context.scope == scope { ChatOptionsSheet( @@ -1030,8 +1033,11 @@ enum ChatComposerPresentation { } private func presentContextLimitIfNeeded() { - guard contextLimitPending, currentProjection, !menuState.occluded else { return } - contextLimitPending = false; contextLimitPresented = true + guard currentProjection, !menuState.occluded, + let issueID = runtime.pendingIssuePresentationID(for: .contextLimit), + runtime.acknowledgeIssuePresentation(id: issueID) + else { return } + contextLimitPresented = true } private func composer(maximumHeight: CGFloat) -> some View { @@ -1079,7 +1085,7 @@ enum ChatComposerPresentation { if account.chatAccess != .allowed { AccountAccessNotice(account: account) } - ChatRecoveryControls(runtime: runtime, plan: account.plan) + ChatRecoveryControls(runtime: runtime, productName: account.billing.value?.productName) if let importer = environment.imageImporters[ObjectIdentifier(runtime)] { DraftImageStrip(importer: importer) if let issue = importer.issue { ImageImportIssueView(issue: issue) } @@ -1111,15 +1117,18 @@ enum ChatComposerPresentation { .allowsHitTesting(false).accessibilityHidden(true) } .layoutValue(key: ComposerEditorLayoutKey.self, value: true) - HStack(spacing: 4) { - MaplePrivacyLock().environment(\.mapleLaunchTransition, nil).accessibilityHidden(true) - Text(MapleStrings.text("Encrypted and private at every step")) - } - .font(.footnote).foregroundStyle(.secondary) - .frame(maxWidth: .infinity) - .accessibilityElement(children: .combine) - .accessibilityIdentifier("composer.privacy") - if runtime.conversationID != nil { + if ChatComposerPresentation.isEmptyState( + hasMessages: !runtime.messages.isEmpty, conversationID: runtime.conversationID) + { + HStack(spacing: 4) { + MaplePrivacyLock().environment(\.mapleLaunchTransition, nil).accessibilityHidden(true) + Text(MapleStrings.text("Encrypted and private at every step")) + } + .font(.footnote).foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("composer.privacy") + } else { Text(MapleStrings.text("AI can make mistakes. Check important info.")) .font(.footnote).foregroundStyle(.secondary) .frame(maxWidth: .infinity) diff --git a/apps/maple-ios/Sources/MapleAppUI/Navigation/CompactMenuNavigation.swift b/apps/maple-ios/Sources/MapleAppUI/Navigation/CompactMenuNavigation.swift index df90cc33a..bc7796722 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Navigation/CompactMenuNavigation.swift +++ b/apps/maple-ios/Sources/MapleAppUI/Navigation/CompactMenuNavigation.swift @@ -96,11 +96,20 @@ } } + private struct CompactMenuPresentationDismissedKey: EnvironmentKey { + static let defaultValue: @MainActor @Sendable () -> Void = {} + } + extension EnvironmentValues { var compactMenuChatState: @MainActor @Sendable (AccountScope, UUID) -> CompactMenuChatState { get { self[CompactMenuChatStateKey.self] } set { self[CompactMenuChatStateKey.self] = newValue } } + + var compactMenuPresentationDismissed: @MainActor @Sendable () -> Void { + get { self[CompactMenuPresentationDismissedKey.self] } + set { self[CompactMenuPresentationDismissedKey.self] = newValue } + } } /// Compact-only outer navigation. Each child owns its existing SwiftUI @@ -134,10 +143,12 @@ withTransaction(context.transaction) { controller.libraryHost.rootView = CompactMenuHostedContent( content: library, environment: context.environment, - publishBar: controller.barPublisher(role: .library, storageOwner: storageOwner)) + publishBar: controller.barPublisher(role: .library, storageOwner: storageOwner), + presentationDismissed: controller.presentationDismissalObserver()) controller.chatHost.rootView = CompactMenuHostedContent( content: chat, environment: context.environment, - publishBar: controller.barPublisher(role: .chat, storageOwner: storageOwner)) + publishBar: controller.barPublisher(role: .chat, storageOwner: storageOwner), + presentationDismissed: controller.presentationDismissalObserver()) } controller.update(requestedMenu: requestedMenu, reduceMotion: reduceMotion) } @@ -151,9 +162,11 @@ let content: Content let environment: EnvironmentValues var publishBar: CompactMenuBarPublicationHandler = { _, _, _ in } + var presentationDismissed: @MainActor @Sendable () -> Void = {} var body: some View { var merged = environment merged.compactMenuBarPublication = publishBar + merged.compactMenuPresentationDismissed = presentationDismissed return content.environment(\.self, merged) } } @@ -481,6 +494,8 @@ delegate = self libraryHost.rootView.publishBar = barPublisher(role: .library, storageOwner: storageOwner) chatHost.rootView.publishBar = barPublisher(role: .chat, storageOwner: storageOwner) + libraryHost.rootView.presentationDismissed = presentationDismissalObserver() + chatHost.rootView.presentationDismissed = presentationDismissalObserver() setViewControllers([libraryRoute, chatRoute], animated: false) } @@ -584,6 +599,21 @@ } } + func presentationDismissalObserver() -> @MainActor @Sendable () -> Void { + { [weak self] in self?.presentationDismissed() } + } + + private func presentationDismissed() { + guard !retired, current else { return } + // SwiftUI's onDismiss can precede the end of an interactive sheet + // transition. UIKit still owns the presented controller until then. + if let coordinator = transitionCoordinator { + coordinator.animate(alongsideTransition: nil) { [weak self] _ in self?.scheduleReconcile() } + } else { + scheduleReconcile() + } + } + private func publishRootBar(role: CompactMenuBarRole, owner: UUID, publication: CompactMenuBarPublication?) { let state = role == .library ? libraryBar : chatBar let route = role == .library ? libraryRoute : chatRoute diff --git a/apps/maple-ios/Sources/MapleAppUI/Rendering/ShippingViewportFixtureView.swift b/apps/maple-ios/Sources/MapleAppUI/Rendering/ShippingViewportFixtureView.swift index a8a838985..2eab06a8c 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Rendering/ShippingViewportFixtureView.swift +++ b/apps/maple-ios/Sources/MapleAppUI/Rendering/ShippingViewportFixtureView.swift @@ -54,9 +54,10 @@ Group { if let model { Group { - if model.draftLoadProof && !showingChat { + if (model.draftLoadProof || model.contextLimitProof) && !showingChat { Text("Retained draft remains in its runtime") - .accessibilityIdentifier("shipping-draft.away") + .accessibilityIdentifier( + model.contextLimitProof ? "shipping-context.away" : "shipping-draft.away") } else { ChatDetail( environment: model.environment, runtime: model.runtime, @@ -320,6 +321,19 @@ .accessibilityValue(model.renderLifecyclesJSON) } .frame(maxWidth: .infinity).frame(height: 60) + } else if model.contextLimitProof { + VStack(spacing: 4) { + HStack { + Button("Reject context") { model.rejectContext() } + .accessibilityIdentifier("shipping-context.reject") + .disabled(!model.ready || model.runtime.hasUnresolvedRun) + Button(showingChat ? "Visit another screen" : "Return to chat") { showingChat.toggle() } + .accessibilityIdentifier("shipping-context.navigate") + .disabled(!model.ready) + }.font(.caption) + Text(model.summary).font(.caption2) + .accessibilityIdentifier("shipping-viewport.summary") + }.frame(maxWidth: .infinity).frame(height: 60) } else if model.draftLoadProof { VStack(spacing: 4) { HStack { @@ -415,6 +429,7 @@ nativeNestedScroll: ProcessInfo.processInfo.arguments.contains( "--maple-shipping-native-nested-scroll-proof")) private let store: ShippingViewportStore + let contextLimitProof = ProcessInfo.processInfo.arguments.contains("--maple-shipping-context-limit-proof") let draftLoadProof: Bool let compactProjectionProof: Bool let coldHistoryProof: Bool @@ -705,6 +720,11 @@ return (try? encoder.encode(value)).flatMap { String(data: $0, encoding: .utf8) } ?? "{}" } func releaseOlder() async { await service.releaseOlder() } + func rejectContext() { + guard contextLimitProof, ready, !retired, !runtime.hasUnresolvedRun else { return } + runtime.draftText = "Synthetic rejected context message" + failed = !runtime.send() + } func startRun() { guard ready, !started else { return } runtime.draftText = "Synthetic live viewport turn" @@ -980,6 +1000,9 @@ guard !retired, stream == nil, request.conversation == Self.chatID else { throw ChatIssue.uncertainResponse } + if ProcessInfo.processInfo.arguments.contains("--maple-shipping-context-limit-proof") { + throw ResearchAPIError.http(status: 413, reason: .contextLimit) + } let pair = AsyncThrowingStream.makeStream() stream = pair.continuation stream?.yield(.created(try lifecycle(status: .inProgress, output: []))) diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatFixtures.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatFixtures.swift index 064b38fcf..45d3df3d8 100644 --- a/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatFixtures.swift +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatFixtures.swift @@ -89,6 +89,7 @@ actor ChatFixtureService: ChatServing { var retrieved = chatResponse("response", .completed) var cancelError: (any Error)? var page = APIPage(data: []) + var pageError: (any Error)? var pageRequests: [ListParameters] = [] var pageGate: ChatTestGate? func setPageGate(_ gate: ChatTestGate?) { pageGate = gate } @@ -105,6 +106,7 @@ actor ChatFixtureService: ChatServing { func setRetrieved(_ value: ResearchResponse) { retrieved = value } func setCancelError(_ value: (any Error)?) { cancelError = value } func setPage(_ value: APIPage) { page = value } + func setPageError(_ value: (any Error)?) { pageError = value } func createConversation(metadata: [String: JSONValue]?, projectId: String?, pinned: Bool?) async throws -> Conversation @@ -155,6 +157,7 @@ actor ChatFixtureService: ChatServing { pageRequests.append(parameters) let captured = page if let pageGate { await pageGate.hold() } + if let pageError { throw pageError } return captured } func emit(_ event: ResponseStreamEvent) { continuation?.yield(event) } diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatRuntimeTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatRuntimeTests.swift index a7328059b..92b1ad7a0 100644 --- a/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatRuntimeTests.swift +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatRuntimeTests.swift @@ -174,7 +174,7 @@ import Testing await service.setRetrieved(chatResponse("response", status)) await runtime.reconcile() #expect(runtime.hasUnresolvedRun == (status == .queued || status == .inProgress)) - #expect(runtime.lastOutcome == ChatRuntime.outcome(status)) + #expect(runtime.lastOutcome == (status == .failed ? .cancelled : ChatRuntime.outcome(status))) #expect(await service.posts.count == 1) runtime.invalidate() } @@ -420,13 +420,12 @@ import Testing runtime.invalidate() } - @Test(arguments: [ResponseStatus.failed, .incomplete]) - func failedTerminalShowsFailureAndKeepsPartialText(_ status: ResponseStatus) async throws { + @Test func failedTerminalShowsFailureAndKeepsPartialText() async throws { let (runtime, service, _) = fixture() await service.setScript([ .created(try chatLifecycle(chatResponse("response", .inProgress))), .outputTextDelta(try chatDelta("Partial response")), - .failed(try chatLifecycle(chatResponse("response", status))), + .failed(try chatLifecycle(chatResponse("response", .failed))), ]) runtime.draftText = "A turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() #expect(runtime.lastOutcome == .failed && runtime.lastIssue == .generationFailed) @@ -493,7 +492,127 @@ import Testing runtime.invalidate() } - @Test(arguments: [ResponseStatus.completed, .cancelled, .failed]) + @Test func stopPreservesCompletedReasoningAndToolsWhileMarkingActiveOutputsIncomplete() async throws { + let (runtime, service, _) = fixture() + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + let done = try decoder.decode( + ResponseTextEvent.self, + from: Data(#"{"item_id":"reasoning","output_index":0,"content_index":0,"text":"Finished thought"}"#.utf8)) + let search = try decoder.decode( + ResponseWebSearchEvent.self, from: Data(#"{"item_id":"search","output_index":1}"#.utf8)) + let activeTool = try decoder.decode( + ResponseItemEvent.self, + from: Data( + #"{"output_index":2,"item":{"id":"active-tool","type":"function_call","status":"searching"}}"#.utf8)) + await service.setScript([ + .created(try chatLifecycle(chatResponse("response", .inProgress))), + .reasoningTextDelta(try chatDelta("A thought", item: "reasoning")), .reasoningTextDone(done), + .webSearchInProgress(search), .webSearchCompleted(search), .outputItemAdded(activeTool), + .outputTextDelta(try chatDelta("Partial answer")), + ]) + runtime.draftText = "Stop this turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + runtime.stop(); await runtime.waitForCurrentWork() + #expect(runtime.lastOutcome == .cancelled && !runtime.hasUnresolvedRun) + #expect(runtime.messages.first { $0.id == "reasoning" }?.status == "completed") + #expect(runtime.messages.first { $0.id == "search" }?.status == "completed") + #expect(runtime.messages.first { $0.id == "active-tool" }?.status == "incomplete") + #expect(runtime.messages.first { $0.id == "assistant" }?.status == "incomplete") + #expect(runtime.messages.first { $0.id == "assistant" }?.content?.first?.text == "Partial answer") + runtime.invalidate() + } + + @Test func incompleteTerminalKeepsPartialTextAndSettlesAsStopped() async throws { + let (runtime, service, _) = fixture() + await service.setScript([ + .created(try chatLifecycle(chatResponse("response", .inProgress))), + .outputTextDelta(try chatDelta("Partial response")), + .failed(try chatLifecycle(chatResponse("response", .incomplete))), + ]) + runtime.draftText = "A turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + #expect(runtime.lastOutcome == .cancelled && runtime.lastIssue == nil && !runtime.hasUnresolvedRun) + #expect(runtime.messages.first { $0.id == "assistant" }?.status == "incomplete") + #expect(runtime.messages.first { $0.id == "assistant" }?.content?.first?.text == "Partial response") + runtime.invalidate() + } + + @Test func authoritativeIncompleteAssistantRetainsCancelledChipAfterTextDone() async throws { + let (runtime, service, _) = fixture() + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + let done = try decoder.decode( + ResponseTextEvent.self, + from: Data(#"{"item_id":"assistant","output_index":0,"content_index":0,"text":"Partial response"}"#.utf8)) + let item = ConversationItem( + id: "assistant", type: "message", status: "incomplete", role: "assistant", + content: [.init(type: "output_text", text: "Partial response")]) + await service.setScript([ + .created(try chatLifecycle(chatResponse("response", .inProgress))), + .outputTextDelta(try chatDelta("Partial response")), .outputTextDone(done), + .failed(try chatLifecycle(chatResponse("response", .incomplete, output: [item]))), + ]) + runtime.draftText = "A turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + #expect(runtime.lastOutcome == .cancelled && runtime.lastIssue == nil && !runtime.hasUnresolvedRun) + #expect(runtime.messages.first { $0.id == "assistant" }?.status == "incomplete") + #expect(runtime.messages.first { $0.id == "assistant" }?.content?.first?.text == "Partial response") + runtime.invalidate() + } + + @Test(arguments: [false, true]) + func failedResponseLookupStopsActiveOutputAndPreservesCompletedReasoning(stopRequested: Bool) async throws { + let (runtime, service, _) = fixture() + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + let done = try decoder.decode( + ResponseTextEvent.self, + from: Data(#"{"item_id":"reasoning","output_index":0,"content_index":0,"text":"Finished thought"}"#.utf8)) + await service.setScript([ + .created(try chatLifecycle(chatResponse("response", .inProgress))), + .reasoningTextDelta(try chatDelta("A thought", item: "reasoning")), .reasoningTextDone(done), + .outputTextDelta(try chatDelta("Partial answer")), + ]) + runtime.draftText = "Interrupted turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + await service.setRetrieved(chatResponse("response", .failed)) + if stopRequested { + await service.setCancelError(ResearchAPIError.http(status: 400, reason: .unspecified)) + runtime.stop(); await runtime.waitForCurrentWork() + } else { + await runtime.reconcile() + } + #expect(runtime.lastOutcome == .cancelled && !runtime.hasUnresolvedRun) + #expect(runtime.lastIssue == (stopRequested ? nil : .recoveredStop)) + #expect(runtime.messages.first { $0.id == "reasoning" }?.status == "completed") + #expect(runtime.messages.first { $0.id == "assistant" }?.status == "incomplete") + #expect(runtime.messages.first { $0.id == "assistant" }?.content?.first?.text == "Partial answer") + runtime.invalidate() + } + + @Test(arguments: [false, true]) + func stopUsesToolFrameStatusAndPreservesFinishedToolOutput(finished: Bool) async throws { + let (runtime, service, _) = fixture() + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + let added = try decoder.decode( + ResponseItemEvent.self, + from: Data(#"{"output_index":0,"item":{"id":"tool","type":"function_call","status":"in_progress"}}"#.utf8)) + let tool = try decoder.decode( + ToolCallEvent.self, + from: Data(#"{"tool_call_id":"tool","output_index":0,"name":"search","arguments":{}}"#.utf8)) + let output = try decoder.decode( + ToolOutputEvent.self, + from: Data(#"{"tool_output_id":"result","tool_call_id":"tool","output_index":1,"output":"Done"}"#.utf8)) + var events: [ResponseStreamEvent] = [ + .created(try chatLifecycle(chatResponse("response", .inProgress))), + .outputItemAdded(added), .toolCallCreated(tool), + ] + if finished { events.append(.toolOutputCreated(output)) } + await service.setScript(events) + runtime.draftText = "Stop this turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + #expect(runtime.messages.first { $0.id == "tool" }?.status == (finished ? "completed" : "in_progress")) + runtime.stop(); await runtime.waitForCurrentWork() + #expect(runtime.lastOutcome == .cancelled && !runtime.hasUnresolvedRun) + #expect(runtime.messages.first { $0.id == "tool" }?.status == (finished ? "completed" : "incomplete")) + if finished { #expect(runtime.messages.first { $0.id == "result" }?.status == "completed") } + runtime.invalidate() + } + + @Test(arguments: [ResponseStatus.completed, .cancelled, .failed, .incomplete]) func recoveredTerminalPublishesItsResolutionAndKeepsQueue(_ status: ResponseStatus) async throws { let (runtime, service, _) = fixture() await service.setScript([.created(try chatLifecycle(chatResponse("response", .inProgress)))]) @@ -501,13 +620,102 @@ import Testing runtime.draftText = "Queued turn"; #expect(runtime.enqueueDraft()) await service.setRetrieved(chatResponse("response", status)) await runtime.reconcile() - #expect( - runtime.lastIssue - == (status == .completed - ? .recoveredCompletion : status == .cancelled ? .recoveredStop : .generationFailed)) + #expect(runtime.lastIssue == (status == .completed ? .recoveredCompletion : .recoveredStop)) + #expect(runtime.lastOutcome == (status == .completed ? .completed : .cancelled)) #expect(runtime.queuedTurns.count == 1 && runtime.queuedTurns.first?.draft.text == "Queued turn") #expect(runtime.isQueuePaused && !runtime.hasUnresolvedRun) #expect(await service.posts.count == 1) runtime.invalidate() } + + @Test(arguments: [ResponseStatus.completed, .cancelled, .failed, .incomplete]) + func recoveredTerminalPreservesHistoryReloadFailure(_ status: ResponseStatus) async throws { + let (runtime, service, _) = fixture() + await service.setScript([ + .created(try chatLifecycle(chatResponse("response", .inProgress))), + .outputTextDelta(try chatDelta("Visible partial response")), + ]) + runtime.draftText = "Interrupted turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + await service.setRetrieved(chatResponse("response", status)) + await service.setPageError(URLError(.networkConnectionLost)) + await runtime.reconcile() + #expect(runtime.lastIssue == .historyUnavailable && !runtime.hasUnresolvedRun) + #expect(runtime.lastOutcome == (status == .completed ? .completed : .cancelled)) + #expect(runtime.messages.first { $0.id == "assistant" }?.content?.first?.text == "Visible partial response") + runtime.invalidate() + } + + @Test(arguments: [false, true]) + func successfulHistoryRecoveryClearsItsOldFailureAndPreservesANewerComposerIssue(newerIssue: Bool) async throws { + let (runtime, service, _) = fixture() + await service.setScript([.created(try chatLifecycle(chatResponse("response", .inProgress)))]) + runtime.draftText = "Interrupted turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + await service.setRetrieved(chatResponse("response", .inProgress)) + await service.setPageError(URLError(.networkConnectionLost)); await runtime.reconcile() + let oldIssueID = try #require(runtime.lastIssueID) + #expect(runtime.lastIssue == .historyUnavailable && runtime.hasUnresolvedRun) + await service.setPageError(nil); await service.setRetrieved(chatResponse("response", .completed)) + let gate = ChatTestGate(); await service.setPageGate(gate) + let recovery = Task { await runtime.reconcile() } + await gate.waitForArrival() + if newerIssue { + runtime.draftText = String(repeating: "x", count: 32 * 1_024 + 1) + #expect(!runtime.enqueueDraft()) + #expect(runtime.lastIssue == .draftTooLarge && runtime.lastIssueID != oldIssueID) + } + await gate.release(); await recovery.value + #expect(runtime.lastOutcome == .completed && !runtime.hasUnresolvedRun) + #expect(runtime.lastIssue == (newerIssue ? .draftTooLarge : .recoveredCompletion)) + runtime.invalidate() + } + + enum ComposerIssue: CaseIterable, Sendable { case draftTooLarge, queueLimit } + + @Test(arguments: ComposerIssue.allCases, [ResponseStatus.completed, .failed, .cancelled]) + func settlementPreservesCurrentComposerAndQueueIssue(_ failure: ComposerIssue, _ status: ResponseStatus) + async throws + { + let (runtime, service, _) = fixture() + runtime.draftText = "In-flight turn"; #expect(runtime.send()); await service.waitForPosts() + let issue: ChatIssue + switch failure { + case .draftTooLarge: + runtime.draftText = String(repeating: "x", count: 32 * 1_024 + 1) + #expect(!runtime.enqueueDraft()); issue = .draftTooLarge + case .queueLimit: + for index in 0..<16 { + runtime.draftText = "Queued \(index)"; #expect(runtime.enqueueDraft()) + } + runtime.draftText = "Retain this unsent turn"; #expect(!runtime.enqueueDraft()); issue = .queueLimit + runtime.pauseQueue() + } + let issueID = runtime.lastIssueID + #expect(runtime.lastIssue == issue) + await service.emit(.completed(try chatLifecycle(chatResponse("response", status)))) + await runtime.waitForCurrentWork() + #expect(runtime.lastIssue == issue && runtime.lastIssueID == issueID) + #expect(runtime.lastOutcome == ChatRuntime.outcome(status) && !runtime.hasUnresolvedRun) + runtime.invalidate() + } + + @Test func issueAcknowledgmentSurvivesRemountAndStaleAcknowledgmentCannotConsumeNewRejection() async throws { + let (runtime, service, _) = fixture() + #expect(runtime.lastIssueID == nil && runtime.pendingIssuePresentationID(for: .contextLimit) == nil) + await service.setResponseErrors([ResearchAPIError.http(status: 413, reason: .contextLimit)]) + runtime.draftText = "Rejected turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + let first = try #require(runtime.pendingIssuePresentationID(for: .contextLimit)) + #expect(runtime.acknowledgeIssuePresentation(id: first)) + #expect(!runtime.acknowledgeIssuePresentation(id: first)) + #expect(runtime.pendingIssuePresentationID(for: .contextLimit) == nil) + #expect(runtime.lastIssue == .contextLimit && runtime.lastIssueID == first) + await service.setResponseErrors([ResearchAPIError.http(status: 413, reason: .contextLimit)]) + #expect(runtime.send()); await runtime.waitForCurrentWork() + let second = try #require(runtime.pendingIssuePresentationID(for: .contextLimit)) + #expect(second != first && !runtime.acknowledgeIssuePresentation(id: first)) + #expect(runtime.pendingIssuePresentationID(for: .contextLimit) == second) + #expect(runtime.acknowledgeIssuePresentation(id: second)) + #expect(runtime.pendingIssuePresentationID(for: .contextLimit) == nil) + runtime.invalidate() + #expect(!runtime.acknowledgeIssuePresentation(id: second)) + } } diff --git a/apps/maple-ios/Tests/MapleAppUITests/Composer/ComposerFlowTests.swift b/apps/maple-ios/Tests/MapleAppUITests/Composer/ComposerFlowTests.swift index 5621b15ed..406e3d984 100644 --- a/apps/maple-ios/Tests/MapleAppUITests/Composer/ComposerFlowTests.swift +++ b/apps/maple-ios/Tests/MapleAppUITests/Composer/ComposerFlowTests.swift @@ -242,16 +242,20 @@ import Testing == "Thought for 2 seconds") } - @Test func serverLimitFeedbackDistinguishesKnownPlansAndMessageLimit() { - #expect( - ChatRecoveryPresentation.message(.usageLimit, plan: .free) - == "You've reached your daily usage limit. Upgrade to Pro for more chats.") - #expect( - ChatRecoveryPresentation.message(.usageLimit, plan: .paid(.pro)) - == "You've reached your monthly Pro limit. Upgrade to Max for 10x more usage.") - for plan: BillingPlan in [.unknown, .paid(.max), .paid(.team)] { + @Test func usageLimitCopyMatchesMissingAndRawResearchProductNames() { + for productName: String? in [nil, "", "Free", "FREE"] { + #expect( + ChatRecoveryPresentation.message(.usageLimit, productName: productName) + == "You've reached your daily usage limit. Upgrade to Pro for more chats.") + } + for productName in ["Pro", "Pro Annual", "Team Pro"] { #expect( - ChatRecoveryPresentation.message(.usageLimit, plan: plan) + ChatRecoveryPresentation.message(.usageLimit, productName: productName) + == "You've reached your monthly Pro limit. Upgrade to Max for 10x more usage.") + } + for productName in ["Unrecognized plan", "Max", "Team", "Max Pro", " Free "] { + #expect( + ChatRecoveryPresentation.message(.usageLimit, productName: productName) == "You've reached your monthly usage limit. Please wait for the next billing cycle.") } #expect( @@ -268,4 +272,11 @@ import Testing ) } + @Test func composerPrivacyOnlyAppearsForEmptyUnassignedChat() { + #expect(ChatComposerPresentation.isEmptyState(hasMessages: false, conversationID: nil)) + #expect(!ChatComposerPresentation.isEmptyState(hasMessages: true, conversationID: nil)) + #expect(!ChatComposerPresentation.isEmptyState(hasMessages: false, conversationID: "existing")) + #expect(!ChatComposerPresentation.isEmptyState(hasMessages: true, conversationID: "existing")) + } + } diff --git a/apps/maple-ios/UITests/ReferenceComparisonUITests.swift b/apps/maple-ios/UITests/ReferenceComparisonUITests.swift index 47c9a0c84..5f1b7332c 100644 --- a/apps/maple-ios/UITests/ReferenceComparisonUITests.swift +++ b/apps/maple-ios/UITests/ReferenceComparisonUITests.swift @@ -478,6 +478,27 @@ import XCTest tap("library.settings", attempts: 24, in: app) XCTAssertTrue(app.navigationBars["Settings"].waitForExistence(timeout: 20)) } + private func dismissChatOptions() throws { + let pickers = app.collectionViews.matching(identifier: "settings.models") + let picker = pickers.firstMatch + XCTAssertTrue(picker.waitForExistence(timeout: 10)) + XCTAssertEqual(pickers.count, 1) + let window = app.windows.firstMatch.frame + let sheet = picker.frame + let outsideHeight = sheet.minY - window.minY + let point = CGPoint(x: window.midX, y: window.minY + outsideHeight / 2) + guard pickers.count == 1, picker.exists, + [window, sheet].allSatisfy({ + [$0.minX, $0.minY, $0.width, $0.height].allSatisfy { $0.isFinite } + && $0.width > 0 && $0.height > 0 + }), outsideHeight > 44, window.contains(point), !sheet.contains(point) + else { throw ReferenceCaptureIssue.missingControl } + app.coordinate(withNormalizedOffset: .zero).withOffset( + CGVector(dx: point.x - app.frame.minX, dy: point.y - app.frame.minY) + ).tap() + XCTAssertTrue(picker.waitForNonExistence(timeout: 10)) + } + private func returnFromModelPicker() { XCTAssertTrue(app.collectionViews["settings.models"].exists) let back = app.navigationBars.buttons.matching( @@ -497,8 +518,10 @@ import XCTest private var cellKey: String { "\(input.orientation).\(input.appearance)" } private func verifyPlan() { - reveal(app.staticTexts["Current plan"], down: true) - XCTAssertTrue(app.staticTexts[input.account == "pro" ? "Pro" : "Free"].exists) + let plan = app.staticTexts["settings.plan-name"] + XCTAssertTrue(plan.waitForExistence(timeout: 20)) + reveal(plan, down: true) + XCTAssertEqual(plan.label, input.account == "pro" ? "Pro Plan" : "Free Plan") } private func selectAndVerifyAppearance() { @@ -518,13 +541,7 @@ import XCTest reveal(web) if web.isEnabled && web.value as? String == "1" { turnWebPreferenceOff(web) } let value = web.value as? String - if input.account == "pro" { - XCTAssertTrue(web.isEnabled); XCTAssertEqual(value, "0") - } else { - // A confirmed Free account cannot change this disabled preference. - // Record its real saved state and authority without fabricating off. - XCTAssertFalse(web.isEnabled); XCTAssertTrue(value == "0" || value == "1") - } + XCTAssertTrue(web.isEnabled); XCTAssertEqual(value, "0") observedWebPreference = value } @@ -706,8 +723,7 @@ import XCTest "observed_appearance_setting": observedAppearance ?? "signed-out-system-appearance", "appearance_evidence": "Native Settings picker value; rendered screenshot requires visual review", "observed_web_preference": observedWebPreference ?? "not-applicable", - "web_authority": !accountVerified - ? "not-applicable" : (input.account == "free" ? "disabled-confirmed-free" : "eligible-pro-aligned-off"), + "web_authority": accountVerified ? "saved-preference-aligned-off" : "not-applicable", ] if let data = try? JSONSerialization.data(withJSONObject: metadata, options: .sortedKeys), let text = String(data: data, encoding: .utf8) @@ -1494,38 +1510,63 @@ import XCTest let web = app.switches["model.web-search"] XCTAssertTrue(web.waitForExistence(timeout: 20)) XCTAssertEqual(web.value as? String, observedWebPreference) - XCTAssertEqual(web.isEnabled, input.account == "pro") + XCTAssertTrue(web.isEnabled) XCTAssertFalse(app.buttons["chat.web-search"].exists) capture("model-selection") - if input.account == "free" { - XCTAssertEqual(app.buttons["model.web-search-unavailable"].value as? String, "Unavailable") - tap("model.web-search-unavailable") - XCTAssertTrue(app.staticTexts["media.access.message"].waitForExistence(timeout: 15)) - capture("web-access"); tap("media.access.close") - XCTAssertTrue(web.exists); XCTAssertFalse(web.isEnabled) - XCTAssertEqual(web.value as? String, observedWebPreference) + let original = try XCTUnwrap(observedWebPreference) + XCTAssertTrue(original == "0" || original == "1") + let changed = original == "1" ? "0" : "1" + tapWebPreference(web) + let flipped = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "value == %@", changed), object: web) + XCTAssertEqual(XCTWaiter.wait(for: [flipped], timeout: 10), .completed) + try dismissChatOptions() + XCTAssertEqual(composer.value as? String, "Unsent comparison draft") + composer.tap() + tap("chat.model") + let reopened = app.switches["model.web-search"] + XCTAssertTrue(reopened.waitForExistence(timeout: 20)); XCTAssertTrue(reopened.isEnabled) + XCTAssertEqual(reopened.value as? String, changed, "The picker must read back the saved preference") + tapWebPreference(reopened) + let restored = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "value == %@", original), object: reopened) + XCTAssertEqual(XCTWaiter.wait(for: [restored], timeout: 10), .completed) + try dismissChatOptions() + XCTAssertEqual(composer.value as? String, "Unsent comparison draft") + + // Test ordinary toolbar touches immediately after dismissal. No edge + // gesture, focus mutation or projection reset may repair admission first. + let libraryWasVisible = visibleLibrary() != nil + let menu = app.mapleMenuButton + XCTAssertTrue(menu.waitForExistence(timeout: 10)) + XCTAssertTrue(menu.isEnabled && menu.isHittable) + menu.tap() + let menuChanged = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in (self.visibleLibrary() != nil) != libraryWasVisible }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [menuChanged], timeout: 15), .completed) + let close = app.buttons["library.close"].firstMatch + if close.exists { + XCTAssertTrue(close.isEnabled && close.isHittable); close.tap() } else { - let original = try XCTUnwrap(observedWebPreference) - XCTAssertTrue(original == "0" || original == "1") - let changed = original == "1" ? "0" : "1" - tapWebPreference(web) - let flipped = XCTNSPredicateExpectation( - predicate: NSPredicate(format: "value == %@", changed), object: web) - XCTAssertEqual(XCTWaiter.wait(for: [flipped], timeout: 10), .completed) - tap("Done") - XCTAssertEqual(composer.value as? String, "Unsent comparison draft") - composer.tap() - tap("chat.model") - let reopened = app.switches["model.web-search"] - XCTAssertTrue(reopened.waitForExistence(timeout: 20)); XCTAssertTrue(reopened.isEnabled) - XCTAssertEqual(reopened.value as? String, changed, "The picker must read back the saved preference") - tapWebPreference(reopened) - let restored = XCTNSPredicateExpectation( - predicate: NSPredicate(format: "value == %@", original), object: reopened) - XCTAssertEqual(XCTWaiter.wait(for: [restored], timeout: 10), .completed) - } - tap("Done") + // A regular-width sidebar uses the same Menu toggle to close. + let sidebarToggle = app.mapleMenuButton + XCTAssertTrue(sidebarToggle.exists && sidebarToggle.isEnabled && sidebarToggle.isHittable) + sidebarToggle.tap() + } + let menuRestored = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in (self.visibleLibrary() != nil) == libraryWasVisible }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [menuRestored], timeout: 15), .completed) + let newChat = app.buttons["chat.new"].firstMatch + XCTAssertTrue(newChat.waitForExistence(timeout: 10)) + XCTAssertTrue(newChat.isEnabled && newChat.isHittable); newChat.tap() + XCTAssertTrue(composer.waitForExistence(timeout: 10)) XCTAssertEqual(composer.value as? String, "Unsent comparison draft") + XCTAssertTrue(newChat.exists && newChat.isEnabled && newChat.isHittable) + XCTAssertFalse(app.buttons["chat.stop"].exists) + // This test is already on New Chat: its saved draft is intentionally + // reused. These checks establish touch admission and draft retention, + // while changing from an existing conversation is verified separately. + capture("options-dismissal-toolbar-touch-and-draft-retention") if input.account == "free" { for (control, surface) in [("dictation.start", "dictation-access")] { XCTAssertEqual(app.buttons[control].value as? String, "Unavailable") diff --git a/apps/maple-ios/UITests/ShippingViewportUITests.swift b/apps/maple-ios/UITests/ShippingViewportUITests.swift index 2fdcb155a..9720d0afd 100644 --- a/apps/maple-ios/UITests/ShippingViewportUITests.swift +++ b/apps/maple-ios/UITests/ShippingViewportUITests.swift @@ -41,6 +41,38 @@ final class ShippingViewportUITests: XCTestCase { XCTAssertTrue(app.buttons["message.speak.viewport-assistant-20"].exists) } + func testContextAlertIsAcknowledgedAcrossRemountAndNewRejectionPresentsAgain() { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = ["--maple-shipping-viewport-fixture", "--maple-shipping-context-limit-proof"] + app.launch() + defer { app.terminate() } + waitSummary("Ready 1; older 0; messages 8") + XCTAssertTrue(app.staticTexts["composer.disclaimer"].exists) + XCTAssertFalse(app.otherElements["composer.privacy"].exists) + + let reject = app.buttons["shipping-context.reject"] + XCTAssertTrue(reject.waitForExistence(timeout: 5)) + reject.tap() + let alert = app.alerts["Message Too Large"] + XCTAssertTrue(alert.waitForExistence(timeout: 10)) + alert.buttons["Got it"].tap() + XCTAssertTrue(alert.waitForNonExistence(timeout: 5)) + XCTAssertEqual(app.textViews["chat.composer"].value as? String, "Synthetic rejected context message") + + app.buttons["shipping-context.navigate"].tap() + XCTAssertTrue(app.staticTexts["shipping-context.away"].waitForExistence(timeout: 5)) + app.buttons["shipping-context.navigate"].tap() + XCTAssertTrue(app.textViews["chat.composer"].waitForExistence(timeout: 5)) + XCTAssertFalse(alert.waitForExistence(timeout: 1), "An acknowledged issue occurrence must survive view remount") + XCTAssertTrue(app.staticTexts["chat.error"].exists, "Acknowledgment keeps the context-limit footer") + + reject.tap() + XCTAssertTrue(alert.waitForExistence(timeout: 10), "A newly rejected request owns a new alert occurrence") + alert.buttons["Got it"].tap() + XCTAssertTrue(alert.waitForNonExistence(timeout: 5)) + } + func testUserMessagesStayOnRightAndWebToolRowsRemainVisibleWithDetails() throws { continueAfterFailure = false app = XCUIApplication() @@ -316,6 +348,59 @@ final class ShippingViewportUITests: XCTestCase { } } + func testMenuButtonRecoversAfterOptionsScrimAndSwipeDismissal() { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = [ + "--maple-shipping-viewport-fixture", "--maple-shipping-pagination-menu-proof", + "--maple-shipping-picker-proof", + ] + app.launch() + defer { app.terminate() } + waitSummary("Ready 1; older 0; messages 8") + let menu = app.buttons["Menu"] + XCTAssertTrue(menu.waitForExistence(timeout: 10)) + XCTAssertTrue(menu.isEnabled && menu.isHittable) + // Calibrate the actual native control before either presentation. + menu.tap() + let close = app.buttons["shipping-pagination.close"] + XCTAssertTrue(close.waitForExistence(timeout: 10)); close.tap() + XCTAssertTrue(menu.waitForExistence(timeout: 10)) + + for swipeDismissal in [false, true] { + let model = app.buttons["chat.model"] + XCTAssertTrue(model.exists && model.isHittable); model.tap() + let picker = app.collectionViews["settings.models"] + XCTAssertTrue(picker.waitForExistence(timeout: 10)) + let window = app.windows.firstMatch.frame + let origin = app.coordinate(withNormalizedOffset: .zero) + if swipeDismissal { + let handle = origin.withOffset( + CGVector(dx: window.midX - app.frame.minX, dy: picker.frame.minY + 8 - app.frame.minY)) + handle.press( + forDuration: 0.1, + thenDragTo: origin.withOffset( + CGVector(dx: window.midX - app.frame.minX, dy: window.maxY - 15 - app.frame.minY)), + withVelocity: .fast, thenHoldForDuration: 0) + } else { + origin.withOffset( + CGVector( + dx: window.midX - app.frame.minX, + dy: window.minY + (picker.frame.minY - window.minY) / 2 - app.frame.minY) + ).tap() + } + waitForModelPickerDismissal(picker) + XCTAssertTrue(menu.waitForExistence(timeout: 10), "Options dismissal restores the native bar") + XCTAssertTrue(menu.isEnabled && menu.isHittable) + // No edge swipe, draft mutation or remount may repair the bar first. + menu.tap() + XCTAssertTrue(close.waitForExistence(timeout: 10), "Menu receives a fresh native touch after dismissal") + XCTAssertTrue(close.isEnabled && close.isHittable); close.tap() + XCTAssertTrue(menu.waitForExistence(timeout: 10)) + capture("Native Menu remains available after options \(swipeDismissal ? "swipe" : "scrim") dismissal") + } + } + private func waitForExpandedSoftwareKeyboard(composer: XCUIElement, model: XCUIElement) { let expanded = XCTNSPredicateExpectation( predicate: NSPredicate { _, _ in From b84ba2dd203520d434a908c8295c0beffb162b77 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:38:34 +0000 Subject: [PATCH 16/17] fix(ios): default missing web search preferences on --- apps/maple-ios/README.md | 4 +-- .../Preferences/LocalPreferences.swift | 2 +- .../Preferences/PreferencesTests.swift | 32 +++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/apps/maple-ios/README.md b/apps/maple-ios/README.md index 1c04821d1..fe6788d39 100644 --- a/apps/maple-ios/README.md +++ b/apps/maple-ios/README.md @@ -149,8 +149,8 @@ messages do not provide purchasing or management actions. Quick/Powerful 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 follows its saved preference, and read-aloud blocks only a known -Free plan; the server authorizes every request. +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 diff --git a/apps/maple-ios/Sources/MapleAppCore/Preferences/LocalPreferences.swift b/apps/maple-ios/Sources/MapleAppCore/Preferences/LocalPreferences.swift index 869477198..4dc2c07a7 100644 --- a/apps/maple-ios/Sources/MapleAppCore/Preferences/LocalPreferences.swift +++ b/apps/maple-ios/Sources/MapleAppCore/Preferences/LocalPreferences.swift @@ -49,7 +49,7 @@ public struct LocalPreferences: Codable, Equatable, Sendable { theme = try record.decode(MapleTheme.self, forKey: .theme) chatFont = try record.decode(MapleChatFont.self, forKey: .chatFont) selectedModelID = try record.decode(String.self, forKey: .selectedModelID) - webSearchPreferred = try record.decode(Bool.self, forKey: .webSearchPreferred) + webSearchPreferred = try record.decodeIfPresent(Bool.self, forKey: .webSearchPreferred) ?? true voice = try record.decode(MapleVoice.self, forKey: .voice) playbackSpeed = try record.decode(MaplePlaybackSpeed.self, forKey: .playbackSpeed) // Existing records predate the sidebar choice; their selected speech rate remains authoritative. diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Preferences/PreferencesTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Preferences/PreferencesTests.swift index 935e22f2e..04d4dcbd1 100644 --- a/apps/maple-ios/Tests/MapleAppCoreTests/Preferences/PreferencesTests.swift +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Preferences/PreferencesTests.swift @@ -11,6 +11,7 @@ import Testing let value = LocalPreferences() #expect(value.theme == .system && value.chatFont == .manrope) #expect(value.selectedModelID == "auto:quick" && value.webSearchPreferred) + #expect(try JSONDecoder().decode(LocalPreferences.self, from: JSONEncoder().encode(value)).webSearchPreferred) #expect(value.voice == .casualFemale && value.playbackSpeed.rawValue == 1.2) #expect(value.sidebarIsOpen) #expect(MaplePlaybackSpeed.allCases.map(\.rawValue) == [1, 1.2, 1.25, 1.5, 2]) @@ -46,6 +47,7 @@ import Testing var migrated = try await store.activate(owner) #expect(migrated.sidebarIsOpen && migrated.playbackSpeed.rawValue == rate) #expect(migrated.theme == .dark && migrated.voice == .frenchMale) + #expect(!migrated.webSearchPreferred) #expect(defaults.data(forKey: key) == data) migrated.sidebarIsOpen = false try await store.save(migrated, scope: owner, revision: 1) @@ -54,6 +56,31 @@ import Testing #expect(try await restarted.activate(scope()) == migrated) } + @Test(arguments: [false, true]) + func legacyMissingOrNullWebSearchDefaultsOnAndPersistsWithoutChangingOtherPreferences(null: Bool) async throws { + let suite = "preferences-tests.\(UUID().uuidString)" + defer { UserDefaults.standard.removePersistentDomain(forName: suite) } + let defaults = try #require(UserDefaults(suiteName: suite)), owner = scope() + var legacy: [String: Any] = [ + "theme": "dark", "chatFont": "serif", "selectedModelID": "auto:powerful", + "voice": "fr_male", "playbackSpeed": 1.5, "sidebarIsOpen": false, + ] + if null { legacy["webSearchPreferred"] = NSNull() } + let data = try JSONSerialization.data(withJSONObject: ["version": 1, "preferences": legacy]) + let key = try PreferencesStore.key(owner) + defaults.set(data, forKey: key) + let store = PreferencesStore(suiteName: suite) + let loaded = try await store.activate(owner) + #expect(loaded.webSearchPreferred) + #expect(loaded.theme == .dark && loaded.chatFont == .serif && loaded.selectedModelID == "auto:powerful") + #expect(loaded.voice == .frenchMale && loaded.playbackSpeed == .fast && !loaded.sidebarIsOpen) + #expect(defaults.data(forKey: key) == data) + try await store.save(loaded, scope: owner, revision: 1) + try await store.retire(owner) + let restarted = PreferencesStore(suiteName: suite) + #expect(try await restarted.activate(scope()) == loaded) + } + @Test func sidebarChoiceAndNewPlaybackRateRoundTrip() throws { let choice = LocalPreferences(sidebarIsOpen: false) let decoded = try JSONDecoder().decode(LocalPreferences.self, from: JSONEncoder().encode(choice)) @@ -149,6 +176,11 @@ import Testing try JSONSerialization.data(withJSONObject: ["version": 1, "preferences": record]), forKey: key) await #expect(throws: PreferencesIssue.invalidRecord) { try await store.activate(owner) } } + record["sidebarIsOpen"] = true; record["webSearchPreferred"] = "false" + let invalidWebSearch = try JSONSerialization.data(withJSONObject: ["version": 1, "preferences": record]) + defaults.set(invalidWebSearch, forKey: key) + await #expect(throws: PreferencesIssue.invalidRecord) { try await store.activate(owner) } + #expect(defaults.data(forKey: key) == invalidWebSearch) } @Test func confirmedOldAccountDeletionDoesNotDisturbAnotherSignedInAccount() async throws { From 09fcb3359894db3ad1fa1dd60b6fa142240bb9bc Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:38:34 +0000 Subject: [PATCH 17/17] fix(ios): reconcile native navigation after chat presentations dismiss --- .../Sources/MapleAppUI/Chat/ChatScreen.swift | 4 +- .../Images/ImageAttachmentViews.swift | 3 +- .../Navigation/ChatPresentationContext.swift | 11 ++- .../PresentationCoverFixtureView.swift | 97 ++++++++++++++++++- .../UITests/PresentationCoverUITests.swift | 71 ++++++++++++++ .../UITests/ShippingViewportUITests.swift | 67 +++++++++++++ 6 files changed, 247 insertions(+), 6 deletions(-) diff --git a/apps/maple-ios/Sources/MapleAppUI/Chat/ChatScreen.swift b/apps/maple-ios/Sources/MapleAppUI/Chat/ChatScreen.swift index 8990c7e9f..65d23380b 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Chat/ChatScreen.swift +++ b/apps/maple-ios/Sources/MapleAppUI/Chat/ChatScreen.swift @@ -973,7 +973,7 @@ enum ChatComposerPresentation { .externalLinks($externalLink) { presented in environment.setPresentationBlocked(presented, id: browserPresentationID, scope: runtime.scope) } - .sheet(item: $mediaNotice) { feature in + .sheet(item: $mediaNotice, onDismiss: compactMenuPresentationDismissed) { feature in MediaFeatureNotice(feature: feature, account: account) } .sheet(isPresented: $modelPresented, onDismiss: compactMenuPresentationDismissed) { @@ -1026,7 +1026,7 @@ enum ChatComposerPresentation { environment.setPresentationBlocked(false, id: browserPresentationID, scope: runtime.scope) runtime.isVisible = false; Task { await runtime.saveDraft() } } - .sheet(isPresented: $queuePresented) { + .sheet(isPresented: $queuePresented, onDismiss: compactMenuPresentationDismissed) { QueueReview(runtime: runtime) { composerFocused = true } .presentationDetents([.medium, .large]).presentationDragIndicator(.visible) } diff --git a/apps/maple-ios/Sources/MapleAppUI/Images/ImageAttachmentViews.swift b/apps/maple-ios/Sources/MapleAppUI/Images/ImageAttachmentViews.swift index 26a59da35..b44a285ee 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Images/ImageAttachmentViews.swift +++ b/apps/maple-ios/Sources/MapleAppUI/Images/ImageAttachmentViews.swift @@ -49,6 +49,7 @@ @State private var selectionOwner: UUID? @State private var cameraOwner: ImportOwner? @State private var pasteOwner: ImportOwner? + @Environment(\.compactMenuPresentationDismissed) private var compactMenuPresentationDismissed var body: some View { Menu { @@ -111,7 +112,7 @@ case .failure: importer.report(.unavailable) } } - .sheet(item: $pasteOwner) { owner in + .sheet(item: $pasteOwner, onDismiss: compactMenuPresentationDismissed) { owner in NavigationStack { VStack(spacing: 16) { Text(MapleStrings.text("Paste an image from the clipboard.")) diff --git a/apps/maple-ios/Sources/MapleAppUI/Navigation/ChatPresentationContext.swift b/apps/maple-ios/Sources/MapleAppUI/Navigation/ChatPresentationContext.swift index 21dfe92a2..56be51921 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Navigation/ChatPresentationContext.swift +++ b/apps/maple-ios/Sources/MapleAppUI/Navigation/ChatPresentationContext.swift @@ -31,9 +31,18 @@ let makeCover: (Item) -> Cover @State private var lease = ChatPresentationLeaseOwner() @Environment(\.chatPresentationChanged) private var changed + @Environment(\.compactMenuPresentationDismissed) private var compactMenuPresentationDismissed func body(content: Content) -> some View { content - .fullScreenCover(item: $item, onDismiss: { lease.update(item != nil, changed: changed) }) { value in + .fullScreenCover( + item: $item, + onDismiss: { + lease.update(item != nil, changed: changed) + // Binding changes can release the lease before native + // dismissal completes; notify even if it is already inactive. + compactMenuPresentationDismissed() + } + ) { value in // Retain the lease with the presented content too: lazy // transcript eviction must not release a still-visible cover. RetainedPresentationContent(lease: lease, content: makeCover(value)) diff --git a/apps/maple-ios/Sources/MapleAppUI/Rendering/PresentationCoverFixtureView.swift b/apps/maple-ios/Sources/MapleAppUI/Rendering/PresentationCoverFixtureView.swift index 4991a301b..fe45d4264 100644 --- a/apps/maple-ios/Sources/MapleAppUI/Rendering/PresentationCoverFixtureView.swift +++ b/apps/maple-ios/Sources/MapleAppUI/Rendering/PresentationCoverFixtureView.swift @@ -12,7 +12,25 @@ struct PresentationCoverFixtureView: View { @State private var model = PresentationCoverFixtureModel() @State private var preview: ChatImageAttachment? + @State private var compactLease = CompactMenuLease() + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.layoutDirection) private var layoutDirection + + private var navigationProof: Bool { + ProcessInfo.processInfo.arguments.contains("--maple-presentation-cover-navigation-proof") + } + var body: some View { + Group { + if navigationProof { + nativeNavigation + } else { + coverContent + } + } + } + + private var coverContent: some View { VStack(spacing: 20) { Text("Image cover completion eligibility").font(.headline) Text(model.summary).accessibilityIdentifier("cover.summary") @@ -36,6 +54,65 @@ } .environment(\.chatPresentationChanged) { id, active in model.presentationChanged(id: id, active: active) } } + + /// The same viewer and lease run inside the shipping retained native + /// navigation. This flag adds no alternate dismissal or recovery path. + private var nativeNavigation: some View { + let scope = model.runtime.scope, owner = model.runtime.snapshot.storageOwner + return CompactMenuNavigation( + scope: scope, storageOwner: owner, + requestedMenu: model.menuPresented, reduceMotion: reduceMotion, lease: compactLease, + library: NavigationStack { + Text("Synthetic retained Library") + .accessibilityIdentifier("cover.library") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(MapleSurface.sidebar) + .toolbarVisibility(.hidden, for: .navigationBar) + .compactMenuRootBar( + role: .library, title: "Synthetic retained Library", + trailing: AnyView(closeMenu(scope: scope, owner: owner))) + } + .environment(\.layoutDirection, layoutDirection), + chat: + coverContent + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(MapleSurface.canvas) + .compactMenuRootBar(role: .chat, title: "Synthetic cover chat") + .environment(\.layoutDirection, layoutDirection) + .modifier( + CompactMenuChatAccessibility( + lease: compactLease, scope: scope, storageOwner: owner, + isCurrent: { [weak model] in + model?.isCurrent(scope: scope, storageOwner: owner) ?? false + })), + isCurrent: { [weak model] in model?.isCurrent(scope: scope) ?? false }, + presentationBlocked: { [weak model] in !(model?.blocks.isEmpty ?? false) }, + requestMenu: { [weak model] in model?.setMenu($0, scope: scope) }, + blockPresentation: { [weak model] id, active in + guard let model, model.isCurrent(scope: scope) else { return } + model.presentationChanged(id: id, active: active) + }, + chatShown: { _ in } + ) + .id(scope) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea(.container) + } + + private func closeMenu(scope: AccountScope, owner: UUID) -> some View { + Button { + guard model.isCurrent(scope: scope, storageOwner: owner) else { return } + model.setMenu(false, scope: scope) + } label: { + Label("Close menu", systemImage: "xmark") + .labelStyle(.iconOnly) + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("cover.library-close") + .disabled(!compactLease.menuVisible) + } } @MainActor @Observable private final class PresentationCoverFixtureModel { @@ -48,6 +125,7 @@ var blockedCompletions = 0 var hapticEligible = 0 var failure = false + private(set) var menuPresented = false init() { let scope = AccountScope(apiOrigin: "https://synthetic.invalid", principalID: "cover-fixture") @@ -86,9 +164,24 @@ if active { blocks.insert(id) } else { blocks.remove(id) } // Same visibility inputs as AppEnvironment.updatePresentationVisibility. // The fixture deliberately does not claim to exercise account/root wiring. - runtime.isVisible = blocks.isEmpty - listening.setVisibility(chatID: blocks.isEmpty ? "cover-chat" : nil, foreground: true) + updateVisibility() + } + + func isCurrent(scope: AccountScope, storageOwner: UUID? = nil) -> Bool { + !runtime.isRetired && runtime.scope == scope + && (storageOwner == nil || runtime.snapshot.storageOwner == storageOwner) + } + + func setMenu(_ presented: Bool, scope: AccountScope) { + guard isCurrent(scope: scope) else { return } + menuPresented = presented + updateVisibility() + } + private func updateVisibility() { + let visible = !runtime.isRetired && !menuPresented && blocks.isEmpty + runtime.isVisible = visible + listening.setVisibility(chatID: visible ? "cover-chat" : nil, foreground: true) } func send() { diff --git a/apps/maple-ios/UITests/PresentationCoverUITests.swift b/apps/maple-ios/UITests/PresentationCoverUITests.swift index d6a5fd9f0..e0cd15f21 100644 --- a/apps/maple-ios/UITests/PresentationCoverUITests.swift +++ b/apps/maple-ios/UITests/PresentationCoverUITests.swift @@ -47,4 +47,75 @@ import XCTest add(attachment) app.terminate() } + + func testImageCoverDismissalRestoresDirectNativeMenu() { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = [ + "--maple-renderer-fixture", "--maple-presentation-cover-fixture", + "--maple-presentation-cover-navigation-proof", + ] + app.launch() + defer { app.terminate() } + let summary = app.staticTexts["cover.summary"] + XCTAssertTrue(summary.waitForExistence(timeout: 15)) + XCTAssertEqual(summary.label, "Leases 0; completions 0; covered 0; plays 0; haptics 0; pending 0; failed 0") + let initialMenu = app.mapleMenuButton + XCTAssertTrue(initialMenu.waitForExistence(timeout: 10)) + XCTAssertTrue(initialMenu.isHittable) + + app.buttons["cover.open"].tap() + let closeImage = app.buttons["images.close"] + XCTAssertTrue(closeImage.waitForExistence(timeout: 10)) + let inside = app.staticTexts["cover.inside-summary"] + XCTAssertTrue(inside.waitForExistence(timeout: 10)) + XCTAssertEqual(inside.label, "Leases 1; completions 0; covered 0; plays 0; haptics 0; pending 0; failed 0") + app.buttons["cover.covered-completion"].tap() + let covered = XCTNSPredicateExpectation( + predicate: NSPredicate( + format: "label == %@", "Leases 1; completions 1; covered 1; plays 0; haptics 0; pending 0; failed 0"), + object: inside) + XCTAssertEqual(XCTWaiter.wait(for: [covered], timeout: 10), .completed) + closeImage.tap() + let blocked = "Leases 0; completions 1; covered 1; plays 0; haptics 0; pending 0; failed 0" + let dismissed = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "label == %@", blocked), object: summary) + XCTAssertEqual(XCTWaiter.wait(for: [dismissed], timeout: 10), .completed) + + // Tap the real native bar immediately after dismissal. No edge gesture, + // response, remount or fixture control can repair it before this check. + let menu = app.mapleMenuButton + XCTAssertTrue(menu.waitForExistence(timeout: 10)) + XCTAssertTrue(menu.isHittable) + menu.tap() + let library = app.staticTexts["cover.library"] + let closeMenu = app.buttons["cover.library-close"] + XCTAssertTrue(library.waitForExistence(timeout: 10)) + let menuReady = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "exists == true AND enabled == true AND hittable == true"), + object: closeMenu) + XCTAssertEqual(XCTWaiter.wait(for: [menuReady], timeout: 10), .completed) + XCTAssertTrue(library.isHittable) + closeMenu.tap() + let returned = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "exists == false"), object: closeMenu) + XCTAssertEqual(XCTWaiter.wait(for: [returned], timeout: 10), .completed) + XCTAssertTrue(summary.waitForExistence(timeout: 10)) + XCTAssertTrue(summary.isHittable) + XCTAssertEqual(summary.label, blocked) + + let catchup = expectation(description: "Observe no completion catch-up after native navigation") + XCTAssertEqual(XCTWaiter.wait(for: [catchup], timeout: 1), .timedOut) + XCTAssertEqual(summary.label, blocked) + app.buttons["cover.visible-completion"].tap() + let eligible = XCTNSPredicateExpectation( + predicate: NSPredicate( + format: "label == %@", "Leases 0; completions 2; covered 1; plays 1; haptics 1; pending 0; failed 0"), + object: summary) + XCTAssertEqual(XCTWaiter.wait(for: [eligible], timeout: 10), .completed) + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Image cover dismissal restores direct native Menu and Close" + attachment.lifetime = .keepAlways + add(attachment) + } } diff --git a/apps/maple-ios/UITests/ShippingViewportUITests.swift b/apps/maple-ios/UITests/ShippingViewportUITests.swift index 9720d0afd..886c8d71d 100644 --- a/apps/maple-ios/UITests/ShippingViewportUITests.swift +++ b/apps/maple-ios/UITests/ShippingViewportUITests.swift @@ -401,6 +401,73 @@ final class ShippingViewportUITests: XCTestCase { } } + func testQueueDismissalRestoresDirectNativeMenuAndRetainsOwners() throws { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = [ + "--maple-shipping-viewport-fixture", "--maple-shipping-pagination-menu-proof", + "--maple-shipping-window-proof", + ] + app.launch() + defer { app.terminate() } + waitSummary("Ready 1; older 0; messages 8") + let queue = app.buttons["queue.open"] + XCTAssertTrue(queue.waitForExistence(timeout: 10)) + let composer = app.textViews["chat.composer"] + XCTAssertEqual(composer.value as? String, "Window held draft B") + let owners = try windowOwnership() + XCTAssertEqual(owners["queueCount"], "1") + XCTAssertEqual(owners["queuePaused"], "true") + XCTAssertEqual(owners["queuedText"], "Window queued A remains paused") + XCTAssertEqual(owners["running"], "false") + + let menu = app.mapleMenuButton + XCTAssertTrue(menu.waitForExistence(timeout: 10)) + XCTAssertTrue(menu.isEnabled && menu.isHittable) + menu.tap() + let close = app.buttons["shipping-pagination.close"] + XCTAssertTrue(close.waitForExistence(timeout: 10)); close.tap() + XCTAssertTrue(menu.waitForExistence(timeout: 10)) + + for swipeDismissal in [false, true] { + XCTAssertTrue(queue.exists && queue.isHittable); queue.tap() + let resume = app.buttons["queue.resume"] + XCTAssertTrue(resume.waitForExistence(timeout: 10)) + XCTAssertTrue(app.staticTexts["Window queued A remains paused"].exists) + XCTAssertFalse(app.buttons["queue.pause"].exists) + if swipeDismissal { + let navigation = app.navigationBars["Queued messages"] + XCTAssertTrue(navigation.exists) + let window = app.windows.firstMatch.frame + let origin = app.coordinate(withNormalizedOffset: .zero) + let handle = origin.withOffset( + CGVector( + dx: window.midX - app.frame.minX, + dy: max(window.minY + 8, navigation.frame.minY - 8) - app.frame.minY)) + handle.press( + forDuration: 0.1, + thenDragTo: origin.withOffset( + CGVector(dx: window.midX - app.frame.minX, dy: window.maxY - 15 - app.frame.minY)), + withVelocity: .fast, thenHoldForDuration: 0) + } else { + app.buttons["Done"].tap() + } + XCTAssertTrue(resume.waitForNonExistence(timeout: 10)) + + // A native touch must work before focus, state mutation or any edge + // navigation could repair the retained bar after this sheet. + XCTAssertTrue(menu.waitForExistence(timeout: 10)) + XCTAssertTrue(menu.isEnabled && menu.isHittable) + menu.tap() + XCTAssertTrue(close.waitForExistence(timeout: 10)) + XCTAssertTrue(close.isEnabled && close.isHittable); close.tap() + XCTAssertTrue(menu.waitForExistence(timeout: 10)) + XCTAssertEqual(composer.value as? String, "Window held draft B") + XCTAssertEqual(try windowOwnership(), owners) + capture("Queue \(swipeDismissal ? "swipe" : "Done") dismissal restores native Menu and retains owners") + } + } + private func waitForExpandedSoftwareKeyboard(composer: XCUIElement, model: XCUIElement) { let expanded = XCTNSPredicateExpectation( predicate: NSPredicate { _, _ in