From 06fe327dfe80ad7c76abb37864a157885da0895a Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 5 Oct 2026 20:59:33 -0700 Subject: [PATCH] Use one first-use AI permission for creation and Whisper --- apple/whistlegraph/README.md | 23 +++++--- .../Sources/AIConsentRecord.swift | 6 +- .../Sources/WhistlegraphApp.swift | 23 +------- .../Sources/WhistlegraphPrivacy.swift | 58 +++++++------------ .../Tests/AIConsentRecordCheck.swift | 20 +++---- .../whistlegraph/UITests/AIConsentTests.swift | 6 ++ .../Whistlegraph.xcodeproj/project.pbxproj | 6 +- apple/whistlegraph/project.yml | 2 +- 8 files changed, 56 insertions(+), 88 deletions(-) diff --git a/apple/whistlegraph/README.md b/apple/whistlegraph/README.md index c2c04a44cf..4c27d1e857 100644 --- a/apple/whistlegraph/README.md +++ b/apple/whistlegraph/README.md @@ -196,15 +196,20 @@ AC's IPFS gateway; its sandbox cannot access the mint page or wallet storage. ## App Store monetization -Build 108 asks for AI permission at the first Send, retry, or Talk action. Launch, -sign-in, typing and drawing do not open the prompt. The compact sheet identifies -the shared content and providers; Allow remembers the account's choice and -continues a pending typed/drawing request after the WebView gate is updated. -Not now or dismissing the sheet preserves the draft. Talk requires a new hold -after permission, so dismissing a sheet never starts the microphone. Account or -piece changes discard a pending continuation. Existing permissions are retained. -Brain → AI & privacy keeps the detailed controls, including the separate, -optional cloud speech and narration permissions, which remain off by default. +Build 111 asks once for AI permission at the first Send, retry, or Talk action. +Launch, sign-in, typing and drawing do not open the prompt. The sheet names +shared content, providers, and Whisper's 40-braincell/second price. Allow AI +remembers one account-scoped grant for creation and Whisper; optional Jeffrey +narration remains a voice preference under that grant. Turning Allow AI off +cancels creation, speech uploads and cloud narration. + +Allow continues a pending typed/drawing request after the WebView gate updates. +Not now or dismissing preserves the draft. Talk needs a new hold after permission, +so accepting a sheet never starts the microphone. Account or piece changes +discard a pending continuation. Consent version 2 asks older installations once +for the expanded audio disclosure; it never silently upgrades creation-only +permission to microphone uploads. Brain → AI & privacy has the same Allow AI +switch. There is no separate Whisper permission screen. `WhistlegraphUITests/AIConsentTests` exercises first use, cancellation, continuation, relaunch and Talk against the `consent` fixture on a simulator or paired phone. diff --git a/apple/whistlegraph/Sources/AIConsentRecord.swift b/apple/whistlegraph/Sources/AIConsentRecord.swift index 5a3eefa166..0cca77de78 100644 --- a/apple/whistlegraph/Sources/AIConsentRecord.swift +++ b/apple/whistlegraph/Sources/AIConsentRecord.swift @@ -2,11 +2,11 @@ import Foundation import CryptoKit struct AIConsentRecord: Codable, Equatable { - static let version = 1 + static let version = 2 var version = Self.version var creation = false - var cloudSpeech = false - var cloudSpeechChoice: Bool? = nil + // One grant covers creation and OpenAI transcription. + var cloudSpeech: Bool { creation } var cloudNarration = false var updatedAt = Date() static func key(subject: String) -> String { diff --git a/apple/whistlegraph/Sources/WhistlegraphApp.swift b/apple/whistlegraph/Sources/WhistlegraphApp.swift index 32ee97f28e..ceea0e14ba 100644 --- a/apple/whistlegraph/Sources/WhistlegraphApp.swift +++ b/apple/whistlegraph/Sources/WhistlegraphApp.swift @@ -27,6 +27,8 @@ struct WhistlegraphApp: App { if voice.isConsentFixture { Text("Requests: \(voice.consentFixtureRequests)") .accessibilityIdentifier("consent-fixture-requests") + Text(voice.aiConsent.cloudSpeech ? "Whisper allowed" : "Whisper off") + .accessibilityIdentifier("consent-fixture-speech") } #endif if !voice.workspaceReady { @@ -49,9 +51,6 @@ struct WhistlegraphApp: App { WhistlegraphAIConsentSheet(canAllow: voice.canAllowAIConsent, allow: voice.allowAIConsent, decline: voice.declineAIConsent) } - .sheet(isPresented: $voice.showingSpeechConsent) { - WhistlegraphSpeechConsentSheet(choose: voice.chooseCloudSpeech) - } .preferredColorScheme(appearance == "light" ? .light : appearance == "dark" ? .dark : nil) .onChange(of: phase) { _, value in DeviceActionLog.shared.record(.lifecycle, value == .active ? .active : value == .background ? .background : .inactive) @@ -219,9 +218,7 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand @Published var layout = NativeLayout() @Published var snapshot = PieceSnapshot() @Published var showingAIConsent = false - @Published var showingSpeechConsent = false @Published var speechNotice: String? - private var speechConsentAccount: (subject: String, handle: String, generation: Int)? @Published private(set) var verifyingAIAccount = false @Published var actionError: String? @Published var typedDraft = "" @@ -379,12 +376,6 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand // Permission does not start a microphone after the finger has lifted. guard aiConsent.creation else { requestAIConsent(); return } if isConsentFixture { return } - if !aiConsent.cloudSpeech && aiConsent.record.cloudSpeechChoice == nil, let subject = aiConsent.subject { - speechConsentAccount = (subject, snapshot.handle, account.generation) - showingSpeechConsent = true - DeviceActionLog.shared.record(.consent, .presented, control: .cloudSpeech) - return - } speechNotice = nil performanceTurn = false captureError = nil; transcript = ""; speechStartedAt = nil; capturePhase = .opening @@ -395,13 +386,6 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand self?.reportActionFailure("Recording could not start. Try holding Talk again.", reason: .failed) } } - func chooseCloudSpeech(_ enabled: Bool) { - defer { showingSpeechConsent = false; speechConsentAccount = nil } - guard let pending = speechConsentAccount, pending.subject == aiConsent.subject, - pending.handle == snapshot.handle, pending.generation == account.generation else { return } - aiConsent.set(\.cloudSpeech, enabled) - // A choice never starts recording after the finger has lifted. - } func latchPerformance() { DeviceActionLog.shared.record(.talkLatch, .requested) guard capturePhase == .opening || capturePhase == .recording else { return } @@ -497,11 +481,8 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand } } private func applyAIConsent() { - if showingSpeechConsent, let pending = speechConsentAccount, - pending.subject != aiConsent.subject || pending.handle != snapshot.handle || pending.generation != account.generation { showingSpeechConsent = false; speechConsentAccount = nil } if showingAIConsent && !canAllowAIConsent { declineAIConsent() } if !aiConsent.creation { command("stop"); cancelHold() } - if !aiConsent.cloudSpeech { cancelHold() } if !aiConsent.cloudNarration { StoryVoice.cancelCloudRequests() } let value = aiConsent.bridge Task { _ = try? await webView?.callAsyncJavaScript("window.__whistlegraphAIConsent = value; window.walkiewareSetAIConsent?.(value);", arguments: ["value": value], in: nil, contentWorld: .page) } diff --git a/apple/whistlegraph/Sources/WhistlegraphPrivacy.swift b/apple/whistlegraph/Sources/WhistlegraphPrivacy.swift index 95e55c6db1..566b2d41b0 100644 --- a/apple/whistlegraph/Sources/WhistlegraphPrivacy.swift +++ b/apple/whistlegraph/Sources/WhistlegraphPrivacy.swift @@ -9,7 +9,7 @@ import SwiftUI var signedIn: Bool { subject != nil && !handle.isEmpty } var creation: Bool { signedIn && record.creation } var cloudSpeech: Bool { signedIn && record.cloudSpeech } - var cloudNarration: Bool { signedIn && record.cloudNarration } + var cloudNarration: Bool { creation && record.cloudNarration } var bridge: [String: Any] { ["handle": handle, "creation": creation] } func bind(subject: String?, handle: String) { if self.subject != subject { StoryVoice.cancelCloudRequests() } @@ -20,8 +20,7 @@ import SwiftUI func set(_ key: WritableKeyPath, _ value: Bool) { guard let subject, signedIn else { return } DeviceActionLog.shared.record(.setting, value ? .enabled : .disabled, - control: key == \.creation ? .creation : key == \.cloudSpeech ? .cloudSpeech : .cloudNarration) - if key == \.cloudSpeech { record.cloudSpeechChoice = value } + control: key == \.creation ? .creation : .cloudNarration) record[keyPath: key] = value; record.updatedAt = Date() record.save(subject: subject); changed() } @@ -43,19 +42,24 @@ struct WhistlegraphAIConsentSheet: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 14) { - ComicTitle(text: "Create with AI?", size: 30) + ComicTitle(text: "Allow AI?", size: 30) .accessibilityAddTraits(.isHeader) - Text("To make and check your piece, AC shares your typed and spoken words, drawings, code and version history, sound measurements and artwork previews with OpenRouter and your chosen AI provider.") + Text("AC shares your words, drawings, code, history, sound measurements and artwork previews with OpenRouter and your chosen AI provider to create and check pieces. Personal models use Anthropic or OpenAI directly.") .font(.custom("ComicRelief-Regular", size: 18, relativeTo: .body)) - Text("Providers: Anthropic, OpenAI, DeepSeek, Moonshot AI, Alibaba (Qwen), MiniMax and Z.ai. Personal models use Anthropic or OpenAI directly.") - .font(.custom("ComicRelief-Regular", size: 15, relativeTo: .subheadline)) - Text("Change your choice in Brain → AI & privacy.") + Text("Talk sends your recording to OpenAI’s Whisper: 40 braincells/second, using your daily allowance first, then purchased braincells. Failed transcriptions are refunded.") + .font(.custom("ComicRelief-Regular", size: 18, relativeTo: .body)) + .accessibilityIdentifier("ai-consent-speech-disclosure") + Text("Providers: Anthropic, OpenAI, DeepSeek, Moonshot, Qwen, MiniMax and Z.ai. Optional Jeffrey voice sends captions to ElevenLabs.") .font(.custom("ComicRelief-Regular", size: 15, relativeTo: .subheadline)) + }.padding(24).padding(.top, 12) + } + .safeAreaInset(edge: .bottom, spacing: 0) { + VStack(spacing: 12) { HStack(spacing: 12) { Button("Not now", action: decline) .accessibilityIdentifier("ai-consent-not-now") .buttonStyle(ConsentButtonStyle(theme: theme, fill: theme.surface)) - Button("Allow", action: allow) + Button("Allow AI", action: allow) .accessibilityIdentifier("ai-consent-allow") .buttonStyle(ConsentButtonStyle(theme: theme, fill: Color(red: 0.40, green: 0.83, blue: 0.95), prominent: true)) .disabled(!canAllow) @@ -63,12 +67,12 @@ struct WhistlegraphAIConsentSheet: View { Link("Privacy policy", destination: URL(string: "https://aesthetic.computer/privacy-policy.html")!) .font(.custom("ComicRelief-Regular", size: 15, relativeTo: .subheadline)) .frame(maxWidth: .infinity).padding(.top, 2) - }.padding(24).padding(.top, 12) + }.padding(.horizontal, 24).padding(.vertical, 16).background(theme.background) } .foregroundStyle(theme.foreground).tint(theme.foreground) .background(theme.background.ignoresSafeArea()) .presentationBackground(theme.background) - .presentationDetents(typeSize.isAccessibilitySize ? [.large] : [.height(510), .large]) + .presentationDetents(typeSize.isAccessibilitySize ? [.large] : [.height(560), .large]) .presentationDragIndicator(.visible) .presentationCornerRadius(30) } @@ -96,21 +100,17 @@ struct WhistlegraphPrivacySheet: View { private let privacy = URL(string: "https://aesthetic.computer/privacy-policy.html")! var body: some View { List { - Section("AI creation") { + Section("AI") { Text("AC sends your prompts, speech transcripts, selected source and version context, drawings, requested sound measurements, and cropped artwork preview images to AI services to generate and check edits.") Text("Hosted models use OpenRouter and the model provider you select: Anthropic, OpenAI, DeepSeek, Moonshot AI, Alibaba/Qwen, MiniMax, or Z.ai. Personal models use Anthropic or OpenAI. The Brain panel identifies the current model and service.") - Toggle("Allow AI creation", isOn: Binding(get: { consent.creation }, set: { consent.set(\.creation, $0) })) + Toggle("Allow AI", isOn: Binding(get: { consent.creation }, set: { consent.set(\.creation, $0) })) .disabled(!consent.signedIn).accessibilityIdentifier("privacy-ai-creation") - } - Section("Cloud speech") { - Text("Whisper sends each finished recording through AC to OpenAI for transcription and word timing. AC pays OpenAI and charges 40 braincells per recorded second, using your daily allowance first, then purchased braincells. Failed transcriptions are refunded. AC keeps billing receipts, not audio or transcripts; a retry result may remain in memory for one minute. With this off, speech recognition stays on the device.") - Toggle("Allow audio to OpenAI", isOn: Binding(get: { consent.cloudSpeech }, set: { consent.set(\.cloudSpeech, $0) })) - .disabled(!consent.signedIn).accessibilityIdentifier("privacy-cloud-speech") + Text("Whisper sends each finished recording through AC to OpenAI for transcription and word timing. AC pays OpenAI and charges 40 braincells per recorded second, using your daily allowance first, then purchased braincells. Failed transcriptions are refunded. AC keeps billing receipts, not audio or transcripts; a retry result may remain in memory for one minute. This is included in Allow AI. Turning it off stops AI creation and cloud speech.") } Section("Cloud narration") { Text("Optional Jeffrey narration sends story captions to ElevenLabs through AC. With this off, stories use your saved recording or device speech.") - Toggle("Allow captions to ElevenLabs", isOn: Binding(get: { consent.cloudNarration }, set: { consent.set(\.cloudNarration, $0) })) - .disabled(!consent.signedIn).accessibilityIdentifier("privacy-cloud-narration") + Toggle("Use Jeffrey’s voice", isOn: Binding(get: { consent.cloudNarration }, set: { consent.set(\.cloudNarration, $0) })) + .disabled(!consent.creation).accessibilityIdentifier("privacy-cloud-narration") } Section { Text("Turning permission off stops new requests and cancels active sending. Data already sent may remain with those services under their policies. Viewing, editing source and exporting your saved work remain available.") @@ -126,21 +126,3 @@ struct WhistlegraphPrivacySheet: View { .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } } } - -struct WhistlegraphSpeechConsentSheet: View { - let choose: (Bool) -> Void - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - Text("Use Whisper for speech?").font(.title.bold()).accessibilityAddTraits(.isHeader) - Text("After you release Talk, AC sends the recording to OpenAI for transcription and word timing. Live captions stay on your device.") - Text("40 braincells per recorded second — 320 for eight seconds. Your daily allowance is used first, then purchased braincells. Failed transcriptions are refunded.") - Text("AC keeps billing receipts, not recordings or transcripts. Retry results stay in memory for up to one minute. Change this in Brain → AI & privacy.") - Button("Allow Whisper") { choose(true) }.buttonStyle(.borderedProminent).accessibilityIdentifier("speech-consent-allow") - Button("Use device speech") { choose(false) }.buttonStyle(.bordered).accessibilityIdentifier("speech-consent-device") - Link("Privacy policy", destination: URL(string: "https://aesthetic.computer/privacy-policy.html")!) - }.font(.body).padding(24) - } - .presentationDetents([.large]).presentationDragIndicator(.visible) - } -} diff --git a/apple/whistlegraph/Tests/AIConsentRecordCheck.swift b/apple/whistlegraph/Tests/AIConsentRecordCheck.swift index 71ebb94e98..4a2450ae6b 100644 --- a/apple/whistlegraph/Tests/AIConsentRecordCheck.swift +++ b/apple/whistlegraph/Tests/AIConsentRecordCheck.swift @@ -9,24 +9,18 @@ import Foundation precondition(allOff(AIConsentRecord.read(subject: nil, defaults: defaults))) precondition(allOff(AIConsentRecord.read(subject: "alice", defaults: defaults))) var record = AIConsentRecord(); record.creation = true + precondition(record.cloudSpeech, "One AI grant includes Whisper") record.save(subject: "alice", defaults: defaults) let restored = AIConsentRecord.read(subject: "alice", defaults: defaults) - precondition(restored.creation && !restored.cloudSpeech && !restored.cloudNarration, "Optional providers stay off") + precondition(restored.creation && restored.cloudSpeech && !restored.cloudNarration, "Permission persists; optional narration stays off") precondition(allOff(AIConsentRecord.read(subject: "bob", defaults: defaults)), "Consent never transfers to another account") record.creation = false; record.save(subject: "alice", defaults: defaults) - precondition(allOff(AIConsentRecord.read(subject: "alice", defaults: defaults)), "Revocation persists") - record.creation = true; record.version = AIConsentRecord.version - 1 - record.save(subject: "alice", defaults: defaults) - precondition(allOff(AIConsentRecord.read(subject: "alice", defaults: defaults)), "Changed disclosure needs fresh permission") - defaults.set(Data("invalid".utf8), forKey: AIConsentRecord.key(subject: "alice")) - precondition(allOff(AIConsentRecord.read(subject: "alice", defaults: defaults))) - record = AIConsentRecord(); record.creation = true; record.cloudSpeechChoice = false - record.save(subject: "alice", defaults: defaults) - precondition(AIConsentRecord.read(subject: "alice", defaults: defaults).cloudSpeechChoice == false, "Device speech choice persists") - precondition(AIConsentRecord.read(subject: "bob", defaults: defaults).cloudSpeechChoice == nil, "Speech choice stays with the account") + precondition(allOff(AIConsentRecord.read(subject: "alice", defaults: defaults)), "Revocation stops both creation and speech") let old = #"{"version":1,"creation":true,"cloudSpeech":false,"cloudNarration":false,"updatedAt":0}"# defaults.set(Data(old.utf8), forKey: AIConsentRecord.key(subject: "legacy")) - precondition(AIConsentRecord.read(subject: "legacy", defaults: defaults).creation, "Existing creation consent survives the optional speech choice") - print("AIConsentRecordCheck passed (10 cases)") + precondition(allOff(AIConsentRecord.read(subject: "legacy", defaults: defaults)), "Creation-only permission cannot silently authorize microphone uploads") + defaults.set(Data("invalid".utf8), forKey: AIConsentRecord.key(subject: "alice")) + precondition(allOff(AIConsentRecord.read(subject: "alice", defaults: defaults))) + print("AIConsentRecordCheck passed (8 cases)") } } diff --git a/apple/whistlegraph/UITests/AIConsentTests.swift b/apple/whistlegraph/UITests/AIConsentTests.swift index 484d80787d..84cf3790d1 100644 --- a/apple/whistlegraph/UITests/AIConsentTests.swift +++ b/apple/whistlegraph/UITests/AIConsentTests.swift @@ -35,6 +35,7 @@ final class AIConsentTests: XCTestCase { type("Make a dancing tree", in: app) let allow = app.buttons["ai-consent-allow"] XCTAssertTrue(allow.waitForExistence(timeout: 10)) + XCTAssertTrue(app.staticTexts["ai-consent-speech-disclosure"].exists, "One prompt names audio sharing and cost") XCTAssertTrue(allow.isHittable, "Primary action fits without scrolling") XCTAssertTrue(app.buttons["ai-consent-not-now"].isHittable) let image = XCTAttachment(screenshot: app.screenshot()) @@ -47,6 +48,7 @@ final class AIConsentTests: XCTestCase { app.buttons["request-send"].tap() XCTAssertTrue(allow.waitForExistence(timeout: 10)); allow.tap() count(1, in: app) + XCTAssertEqual(app.staticTexts["consent-fixture-speech"].label, "Whisper allowed") XCTAssertFalse(field.exists, "Allow continues the original typed request") type("Make it purple", in: app) count(2, in: app) @@ -67,6 +69,10 @@ final class AIConsentTests: XCTestCase { XCTAssertTrue(app.buttons["type-control"].waitForExistence(timeout: 10)) XCTAssertFalse(app.staticTexts["Listening…"].exists) count(0, in: app) + XCTAssertEqual(app.staticTexts["consent-fixture-speech"].label, "Whisper allowed") + app.buttons["talk-control"].press(forDuration: 0.2) + XCTAssertFalse(app.buttons["ai-consent-allow"].exists, "Talk reuses the same grant") + XCTAssertFalse(app.buttons["speech-consent-allow"].exists, "No separate Whisper permission") } func testIdentityFailureExplainsBlockAndKeepsDraft() { let app = launch(identityFailure: true) diff --git a/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj b/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj index 24bda40db0..fc8d4064c6 100644 --- a/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj +++ b/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj @@ -598,7 +598,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 110; + CURRENT_PROJECT_VERSION = 111; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -683,7 +683,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 110; + CURRENT_PROJECT_VERSION = 111; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_NS_ASSERTIONS = NO; @@ -743,7 +743,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 110; + CURRENT_PROJECT_VERSION = 111; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_STRICT_OBJC_MSGSEND = YES; diff --git a/apple/whistlegraph/project.yml b/apple/whistlegraph/project.yml index 3665f497be..45136caf9e 100644 --- a/apple/whistlegraph/project.yml +++ b/apple/whistlegraph/project.yml @@ -16,7 +16,7 @@ settings: CODE_SIGN_STYLE: Automatic SWIFT_VERSION: "5.0" MARKETING_VERSION: "0.1.0" - CURRENT_PROJECT_VERSION: "110" + CURRENT_PROJECT_VERSION: "111" targets: Whistlegraph: type: application -- 2.51.2