From 5f12e175b1719698a7a75ff3a54dab0af4bcfa4b Mon Sep 17 00:00:00 2001 From: he-james Date: Fri, 2 Oct 2026 16:55:03 -0500 Subject: [PATCH 1/3] iOS: AssemblyAI-owned identifiers, and API keys in every build Gets the iPhone app ready for TestFlight. - Bundle ids move from the dev.alex placeholders to com.assemblyai.blurt.ios (app, .keyboard, .core, .tests, .probe), the App Group to group.com.assemblyai.blurt.ios, and the term-pack type to com.assemblyai.blurt.terms. The Darwin notification names, log subsystem, queue labels and scripts follow. The Mac app keeps its dev.alex ids: changing them would orphan Sparkle updates and its granted Accessibility permission. - API-key entry is no longer debug-only. Sign in with AssemblyAI doesn't exist yet, so a TestFlight build had no way to add a key. The setup card and Settings now offer "Paste your key" and "Get a free key" in every build, as the Mac app does. Co-Authored-By: Claude Opus 5.5 --- .../BlurtiOS/Sources/AudioSource.swift | 4 +-- .../BlurtiOS/Sources/BlurtiOSApp.swift | 2 +- .../BlurtiOS/Sources/KeyEntryView.swift | 6 ++-- .../BlurtiOS/Sources/SettingsView.swift | 6 ++-- App/BlurtiOS/BlurtiOS/Sources/SetupCard.swift | 18 ++++++----- App/BlurtiOS/BlurtiOSCore/SharedState.swift | 10 +++--- App/BlurtiOS/BlurtiOSCore/TermPack.swift | 2 +- App/BlurtiOS/DESIGN.md | 2 +- App/BlurtiOS/README.md | 20 ++++++------ App/BlurtiOS/project.yml | 31 ++++++++++--------- scripts/ios-keyboard-flows.sh | 6 ++-- scripts/ios-record.sh | 2 +- scripts/ios-sim.sh | 2 +- 13 files changed, 56 insertions(+), 55 deletions(-) diff --git a/App/BlurtiOS/BlurtiOS/Sources/AudioSource.swift b/App/BlurtiOS/BlurtiOS/Sources/AudioSource.swift index cd697647..d2f01c07 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/AudioSource.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/AudioSource.swift @@ -31,10 +31,10 @@ nonisolated final class WindowedAudioSource: NSObject, ListeningSource, @uncheck private let session = AVCaptureSession() private let output = AVCaptureAudioDataOutput() /// Serial, so the converter below needs no lock. - private let deliveryQueue = DispatchQueue(label: "dev.alex.blurt.ios.capture") + private let deliveryQueue = DispatchQueue(label: "com.assemblyai.blurt.ios.capture") /// Where the session is started and stopped — both block for a while on a /// phone — so the main actor never waits on the capture stack. - private let controlQueue = DispatchQueue(label: "dev.alex.blurt.ios.capture.control") + private let controlQueue = DispatchQueue(label: "com.assemblyai.blurt.ios.capture.control") private nonisolated(unsafe) var converter: PCMConverter? var levels: AsyncStream { feed.levels } diff --git a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift index e8dd7066..dc33e93d 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift @@ -18,7 +18,7 @@ struct BlurtiOSApp: App { HostIdentity.configure( HostIdentity( productName: "Blurt", - subsystem: "dev.alex.blurt.ios", + subsystem: "com.assemblyai.blurt.ios", keychainService: "blurt-ios", defaultsPrefix: "Blurt", logDirectoryName: "Blurt")) diff --git a/App/BlurtiOS/BlurtiOS/Sources/KeyEntryView.swift b/App/BlurtiOS/BlurtiOS/Sources/KeyEntryView.swift index 823c6afd..0781be08 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/KeyEntryView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/KeyEntryView.swift @@ -3,9 +3,9 @@ import BlurtiOSCore import SwiftUI /// Paste an AssemblyAI API key, verified against the API before it is saved -/// (`APIKeySubmission` owns that rule). Debug builds only: the shipping app -/// signs in with AssemblyAI and never shows a key. It exists so the pipeline can -/// be tested on a phone before that service is in place. +/// (`APIKeySubmission` owns that rule). Every build shows it, the way the Mac app +/// works, until Sign in with AssemblyAI exists; then it becomes the advanced +/// path for contributors and self-built copies. struct KeyEntryView: View { var apiKey: APIKeyModel @Environment(\.dismiss) private var dismiss diff --git a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift index d37bc0d7..8a7e95f5 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift @@ -126,10 +126,8 @@ struct SettingsView: View { private var accountSection: some View { Section { - LabeledContent("Sign in with AssemblyAI", value: "Coming soon") - #if DEBUG - Button(coordinator.apiKey.hasAPIKey ? "Replace the API key" : "Use an API key") { showsKeyEntry = true } - #endif + Button(coordinator.apiKey.hasAPIKey ? "Replace the API key" : "Add an API key") { showsKeyEntry = true } + Link("Get a free key", destination: APIKeyStore.dashboardURL) } header: { Eyebrow("Account") } diff --git a/App/BlurtiOS/BlurtiOS/Sources/SetupCard.swift b/App/BlurtiOS/BlurtiOS/Sources/SetupCard.swift index 4f0f5977..9ca3adc9 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SetupCard.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SetupCard.swift @@ -1,15 +1,17 @@ import AVFoundation +import BlurtEngine import SwiftUI import UIKit /// The setup checklist, on top of the home screen while anything is missing: -/// three numbered steps — sign in (a key, in debug builds), the microphone, -/// the keyboard — each with its control until it is done. +/// three numbered steps — an AssemblyAI API key, the microphone, the keyboard — +/// each with its control until it is done. struct SetupCard: View { let hasKey: Bool let microphoneGranted: Bool let keyboardSeen: Bool - /// Debug builds take an API key in place of the sign-in that doesn't exist yet. + /// Every build takes an API key, as the Mac app does, until Sign in with + /// AssemblyAI exists. let enterKey: () -> Void /// Re-read the permissions after one was asked for. let refresh: () -> Void @@ -17,13 +19,13 @@ struct SetupCard: View { var body: some View { VStack(alignment: .leading, spacing: DesignTokens.Metrics.appStackGap) { Eyebrow("Set up Blurt") - SetupRow(number: "01", done: hasKey, title: "Sign in with AssemblyAI") { + SetupRow(number: "01", done: hasKey, title: "Add your AssemblyAI API key") { VStack(alignment: .leading, spacing: DesignTokens.Metrics.appLineGap) { - Text("Coming soon — sign-in needs the AssemblyAI login service.") + Text("Blurt uses your own AssemblyAI account. New accounts come with free credits.") .font(BlurtType.body(DesignTokens.Typography.sizeCaption)).foregroundStyle(BlurtBrand.muted) - #if DEBUG - Button("Use an API key instead") { enterKey() }.buttonStyle(BrandButtonStyle(role: .secondary)) - #endif + Button("Paste your key") { enterKey() }.buttonStyle(BrandButtonStyle(role: .primary)) + Link("Get a free key", destination: APIKeyStore.dashboardURL) + .font(BlurtType.body(DesignTokens.Typography.sizeCaption)) } } SetupRow(number: "02", done: microphoneGranted, title: "Allow the microphone") { diff --git a/App/BlurtiOS/BlurtiOSCore/SharedState.swift b/App/BlurtiOS/BlurtiOSCore/SharedState.swift index 18bff687..f6246837 100644 --- a/App/BlurtiOS/BlurtiOSCore/SharedState.swift +++ b/App/BlurtiOS/BlurtiOSCore/SharedState.swift @@ -12,7 +12,7 @@ package nonisolated enum BlurtShared { /// The App Group both targets declare. A placeholder namespace inherited from /// the Mac app; an org-owned id replaces it before the App Store, and the app /// and the keyboard must change together. - package static let appGroup = "group.dev.alex.blurt" + package static let appGroup = "group.com.assemblyai.blurt.ios" /// The URL the keyboard opens to bring the app forward (`blurt://start`) — /// the one moment iOS insists on before the microphone may open. package static let urlScheme = "blurt" @@ -46,10 +46,10 @@ package nonisolated enum BlurtShared { /// Darwin notification names. Reverse-DNS so they can't collide with another /// app's on the same device. package nonisolated enum Signal { - package static let command = "dev.alex.blurt.ios.command" - package static let phase = "dev.alex.blurt.ios.phase" - package static let result = "dev.alex.blurt.ios.result" - package static let lexicon = "dev.alex.blurt.ios.lexicon" + package static let command = "com.assemblyai.blurt.ios.command" + package static let phase = "com.assemblyai.blurt.ios.phase" + package static let result = "com.assemblyai.blurt.ios.result" + package static let lexicon = "com.assemblyai.blurt.ios.lexicon" } } diff --git a/App/BlurtiOS/BlurtiOSCore/TermPack.swift b/App/BlurtiOS/BlurtiOSCore/TermPack.swift index aa82b731..a5633e2a 100644 --- a/App/BlurtiOS/BlurtiOSCore/TermPack.swift +++ b/App/BlurtiOS/BlurtiOSCore/TermPack.swift @@ -5,7 +5,7 @@ import UniformTypeIdentifiers extension UTType { /// A shared list of Blurt key terms: JSON, `.blurtterms`, declared in /// `project.yml` so a tap on one in Messages opens Blurt. - package nonisolated static let blurtTerms = UTType(exportedAs: "dev.alex.blurt.terms", conformingTo: .json) + package nonisolated static let blurtTerms = UTType(exportedAs: "com.assemblyai.blurt.terms", conformingTo: .json) } /// A list of key terms on its way to a friend — a group chat's names and diff --git a/App/BlurtiOS/DESIGN.md b/App/BlurtiOS/DESIGN.md index d09a961a..c179f07d 100644 --- a/App/BlurtiOS/DESIGN.md +++ b/App/BlurtiOS/DESIGN.md @@ -372,7 +372,7 @@ against the request's cap of 100 terms. Settings → Transcription → **Share key terms…** puts the list in the share sheet as a `.blurtterms` file (JSON — `name`, `from`, `terms`; a declared -document type, `dev.alex.blurt.terms`) with the words as the message text. A +document type, `com.assemblyai.blurt.terms`) with the words as the message text. A tap on the file in Messages opens Blurt with "Add N key terms?": every term ticked, untick any, Add merges the rest without duplicates. A `blurt://terms?add=a,b,c&name=…&from=…` link does the same (`TermPack`, diff --git a/App/BlurtiOS/README.md b/App/BlurtiOS/README.md index ae271b0b..361af67a 100644 --- a/App/BlurtiOS/README.md +++ b/App/BlurtiOS/README.md @@ -33,9 +33,10 @@ CI builds it for the simulator on every PR (`check.yml`'s `ios-build` job). On a Mac with only the Command Line Tools — no iOS SDK — `scripts/ios-typecheck.sh` typechecks both targets against the Mac Catalyst frameworks with the same flags, which catches the Swift 6 isolation errors before CI does. Running -on a phone needs Xcode with a team that can provision the App Group; every id in -`project.yml` (`dev.alex.blurt.ios`, `group.dev.alex.blurt`) is a placeholder -inherited from the Mac app and must become org-owned before the App Store. +on a phone needs Xcode with a team that can provision the App Group. The ids in +`project.yml` (`com.assemblyai.blurt.ios`, `com.assemblyai.blurt.ios.keyboard`, +`group.com.assemblyai.blurt.ios`) are AssemblyAI's, so they register under the AssemblyAI team; +anyone building under another team changes them locally (see Testing on a phone). ## Design @@ -128,13 +129,12 @@ shows its console. characters after the team's name). The App Group needs a paid team — a free Personal Team cannot register one, so use the org's team, not your own. 3. `cd App/BlurtiOS && BLURT_TEAM= xcodegen generate && open BlurtiOS.xcodeproj`. - Automatic signing registers `dev.alex.blurt.ios`, `dev.alex.blurt.ios.keyboard` and - `group.dev.alex.blurt` under that team on the first build; it fails if another team already + Automatic signing registers `com.assemblyai.blurt.ios`, `com.assemblyai.blurt.ios.keyboard` and + `group.com.assemblyai.blurt.ios` under that team on the first build; it fails if another team already owns them. 4. Plug the phone in (Developer Mode on: Settings → Privacy & Security), pick it as the run destination, run the `BlurtiOS` scheme. On the phone, trust the developer app (Settings → - General → VPN & Device Management). In the app: **Use an API key instead** (debug builds - only), **Allow** the microphone, then **Open Settings** and go to **Apps › Blurt › Keyboards** + General → VPN & Device Management). In the app: **Paste your key**, **Allow** the microphone, then **Open Settings** and go to **Apps › Blurt › Keyboards** (a phone may open straight on Blurt's page): turn on Blurt and **Allow Full Access**. 5. Back in the app, **Start listening**, then go to Messages, hold the globe key, pick Blurt, and tap the mic. Tap again to stop, or hold it while you talk. The words land in the field. @@ -145,9 +145,9 @@ shows its console. - **Autocorrect and the suggestion bar** on the full keyboard. -- **Sign in with AssemblyAI.** The shipping app signs the user in and gets a key - behind the scenes; that service does not exist yet. Debug builds show "Use an API - key instead" (`KeyEntryView`) so the pipeline can be tested now. +- **Sign in with AssemblyAI.** The shipping app will sign the user in and get a key + behind the scenes; that service does not exist yet. Until it does, every build + (TestFlight included) takes a pasted API key (`KeyEntryView`), as the Mac app does. - **Returning to the host app.** After "Start Blurt" the user swipes back; iOS 26.4 ended automatic switch-back for everyone. - **The full keyboard** has no autocorrect or suggestions yet. diff --git a/App/BlurtiOS/project.yml b/App/BlurtiOS/project.yml index 8f45c3f7..df4d7bc8 100644 --- a/App/BlurtiOS/project.yml +++ b/App/BlurtiOS/project.yml @@ -9,13 +9,14 @@ # # Two processes, one App Group. iOS lets no keyboard use the microphone, so the # app records and talks to AssemblyAI while the keyboard is a remote control that -# inserts the result; they share `group.dev.alex.blurt` (see Shared/). Every id -# here is a placeholder namespace inherited from the Mac app — the App Store -# submission needs org-owned bundle ids, an org App Group and a team that can -# provision them. +# inserts the result; they share `group.com.assemblyai.blurt.ios` (see +# BlurtiOSCore/SharedState.swift). Every id is AssemblyAI-owned +# (`com.assemblyai.blurt.ios…`); the team comes from $BLURT_TEAM, never this +# file. The Mac app keeps its own `dev.alex.blurt` ids: changing them would +# orphan its Sparkle updates and its granted Accessibility permission. name: BlurtiOS options: - bundleIdPrefix: dev.alex + bundleIdPrefix: com.assemblyai deploymentTarget: # Matches Package.swift's `.iOS(.v18)`: an app floor below the package's fails # to resolve the dependency at all. @@ -57,7 +58,7 @@ targets: - path: BlurtiOSCore settings: base: - PRODUCT_BUNDLE_IDENTIFIER: dev.alex.blurt.ios.core + PRODUCT_BUNDLE_IDENTIFIER: com.assemblyai.blurt.ios.core GENERATE_INFOPLIST_FILE: YES SWIFT_TREAT_WARNINGS_AS_ERRORS: YES SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor @@ -97,7 +98,7 @@ targets: - KeyboardViewController.swift settings: base: - PRODUCT_BUNDLE_IDENTIFIER: dev.alex.blurt.ios + PRODUCT_BUNDLE_IDENTIFIER: com.assemblyai.blurt.ios ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon # The app-level accent, from the catalog, so system-drawn chrome and our # views agree (see the Mac's project.yml and BlurtBrand). @@ -132,7 +133,7 @@ targets: # `blurt://start` — what the keyboard opens to bring the app forward, the # one moment iOS insists on before the microphone may open. CFBundleURLTypes: - - CFBundleURLName: dev.alex.blurt.ios + - CFBundleURLName: com.assemblyai.blurt.ios CFBundleURLSchemes: [blurt] # A shared key-term list (`.blurtterms`, JSON): declared so a tap on # one in Messages offers Blurt, which opens it as an import. @@ -144,9 +145,9 @@ targets: - CFBundleTypeName: Blurt key terms CFBundleTypeRole: Viewer LSHandlerRank: Owner - LSItemContentTypes: [dev.alex.blurt.terms] + LSItemContentTypes: [com.assemblyai.blurt.terms] UTExportedTypeDeclarations: - - UTTypeIdentifier: dev.alex.blurt.terms + - UTTypeIdentifier: com.assemblyai.blurt.terms UTTypeDescription: Blurt key terms UTTypeConformsTo: [public.json] UTTypeTagSpecification: @@ -155,7 +156,7 @@ targets: entitlements: path: BlurtiOS/BlurtiOS.entitlements properties: - com.apple.security.application-groups: [group.dev.alex.blurt] + com.apple.security.application-groups: [group.com.assemblyai.blurt.ios] dependencies: - package: BlurtEngine product: BlurtEngine @@ -175,7 +176,7 @@ targets: buildPhase: resources settings: base: - PRODUCT_BUNDLE_IDENTIFIER: dev.alex.blurt.ios.keyboard + PRODUCT_BUNDLE_IDENTIFIER: com.assemblyai.blurt.ios.keyboard SWIFT_TREAT_WARNINGS_AS_ERRORS: YES SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor APPLICATION_EXTENSION_API_ONLY: YES @@ -196,7 +197,7 @@ targets: entitlements: path: BlurtKeyboard/BlurtKeyboard.entitlements properties: - com.apple.security.application-groups: [group.dev.alex.blurt] + com.apple.security.application-groups: [group.com.assemblyai.blurt.ios] dependencies: - package: BlurtEngine product: BlurtEngine @@ -224,7 +225,7 @@ targets: - target: BlurtiOS settings: base: - PRODUCT_BUNDLE_IDENTIFIER: dev.alex.blurt.ios.tests + PRODUCT_BUNDLE_IDENTIFIER: com.assemblyai.blurt.ios.tests GENERATE_INFOPLIST_FILE: YES SWIFT_TREAT_WARNINGS_AS_ERRORS: YES SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor @@ -242,7 +243,7 @@ targets: - target: BlurtiOS settings: base: - PRODUCT_BUNDLE_IDENTIFIER: dev.alex.blurt.ios.probe + PRODUCT_BUNDLE_IDENTIFIER: com.assemblyai.blurt.ios.probe TEST_TARGET_NAME: BlurtiOS GENERATE_INFOPLIST_FILE: YES SWIFT_TREAT_WARNINGS_AS_ERRORS: YES diff --git a/scripts/ios-keyboard-flows.sh b/scripts/ios-keyboard-flows.sh index 04415538..076e3410 100755 --- a/scripts/ios-keyboard-flows.sh +++ b/scripts/ios-keyboard-flows.sh @@ -74,7 +74,7 @@ ios_wait_booted "$UDID" xcodebuild build -project "$REPO_ROOT/App/BlurtiOS/BlurtiOS.xcodeproj" -scheme BlurtiOS \ -destination "platform=iOS Simulator,id=$UDID" -derivedDataPath "$DERIVED" "${IOS_SIM_SIGNING[@]}" -quiet xcrun simctl install "$UDID" "$DERIVED/Build/Products/Debug-iphonesimulator/BlurtiOS.app" -xcrun simctl privacy "$UDID" grant microphone dev.alex.blurt.ios +xcrun simctl privacy "$UDID" grant microphone com.assemblyai.blurt.ios for face in $FACES; do xcrun simctl ui "$UDID" appearance "$face" @@ -136,9 +136,9 @@ echo "ios-keyboard-flows: screenshots in $OUT" # back through the simulator's own defaults daemon (a plist edit alone is # not seen until cfprefsd reloads). if [ -n "$ALIGN" ]; then - GROUP="$(xcrun simctl get_app_container "$UDID" dev.alex.blurt.ios groups 2>/dev/null | awk -F'\t' '/group.dev.alex.blurt/ {print $2}')" + GROUP="$(xcrun simctl get_app_container "$UDID" com.assemblyai.blurt.ios groups 2>/dev/null | awk -F'\t' '/group.com.assemblyai.blurt.ios/ {print $2}')" if [ -n "$GROUP" ]; then - xcrun simctl spawn "$UDID" defaults write "$GROUP/Library/Preferences/group.dev.alex.blurt" micAlignment center + xcrun simctl spawn "$UDID" defaults write "$GROUP/Library/Preferences/group.com.assemblyai.blurt.ios" micAlignment center echo "ios-keyboard-flows: mic alignment back to the middle" fi fi diff --git a/scripts/ios-record.sh b/scripts/ios-record.sh index f9437c1c..2dc45c33 100755 --- a/scripts/ios-record.sh +++ b/scripts/ios-record.sh @@ -76,7 +76,7 @@ read -r CROP_W CROP_H CROP_X CROP_Y < <(swift scripts/design-diff.swift crop "$r PICKED="$(ios_pick_device "${BLURT_SIM_DEVICE:-iPhone 18 Pro}")" UDID="${PICKED%% *}" -BUNDLE_ID=dev.alex.blurt.ios +BUNDLE_ID=com.assemblyai.blurt.ios echo "==> recording $name (two cycles)" xcrun simctl terminate "$UDID" "$BUNDLE_ID" 2>/dev/null || true diff --git a/scripts/ios-sim.sh b/scripts/ios-sim.sh index 29c7359c..d5464146 100755 --- a/scripts/ios-sim.sh +++ b/scripts/ios-sim.sh @@ -23,7 +23,7 @@ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" source "$REPO_ROOT/scripts/ios-lib.sh" DERIVED="$IOS_DERIVED" # PRODUCT_BUNDLE_IDENTIFIER in App/BlurtiOS/project.yml. -BUNDLE_ID=dev.alex.blurt.ios +BUNDLE_ID=com.assemblyai.blurt.ios SHOT="" BUILD=1 while [ $# -gt 0 ]; do From ec4bc6bfcb6b82ed02bb3fd9fbc911e582dc9325 Mon Sep 17 00:00:00 2001 From: he-james Date: Fri, 2 Oct 2026 17:03:47 -0500 Subject: [PATCH 2/3] iOS: ask before sending audio to AssemblyAI App Review 5.1.2(i) requires clearly disclosing where personal data goes, third-party AI included, and getting explicit permission first. The listening window now won't open until the user agrees, in a sheet that names AssemblyAI, lists what is sent (the voice while dictating, the text before the cursor, recent dictations, key terms with contact names) and links the privacy policy. Every path to the microphone goes through startListening(), so one gate covers the button, the keyboard's blurt://start and in-app dictation. Consent is versioned (AIConsent): sending something new means a new version, and everyone is asked again. Settings' About section links the privacy policy too. The debug -BlurtStartListening flag grants consent, since scripted runs can't tap through the sheet. Co-Authored-By: Claude Opus 5.5 --- .../BlurtiOS/Sources/BlurtiOSApp.swift | 4 +- .../BlurtiOS/Sources/ConsentView.swift | 49 +++++++++++++++++++ .../Sources/DictationCoordinator.swift | 16 ++++++ App/BlurtiOS/BlurtiOS/Sources/HomeView.swift | 3 ++ .../BlurtiOS/Sources/SettingsView.swift | 1 + App/BlurtiOS/BlurtiOSCore/AIConsent.swift | 28 +++++++++++ .../BlurtiOSTests/AIConsentTests.swift | 35 +++++++++++++ App/BlurtiOS/DESIGN.md | 17 +++++-- App/BlurtiOS/README.md | 2 +- 9 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 App/BlurtiOS/BlurtiOS/Sources/ConsentView.swift create mode 100644 App/BlurtiOS/BlurtiOSCore/AIConsent.swift create mode 100644 App/BlurtiOS/BlurtiOSTests/AIConsentTests.swift diff --git a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift index dc33e93d..a24e3de0 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift @@ -71,7 +71,9 @@ struct BlurtiOSApp: App { #if DEBUG // `-BlurtStartListening` opens the mic at launch, so the listening // state can be screenshotted without a tap (see scripts/ios-sim.sh). - if CommandLine.arguments.contains("-BlurtStartListening") { await coordinator.startListening() } + // Asking for it stands in for consent, which a screenshot or probe run + // can't tap through; debug builds only. + if CommandLine.arguments.contains("-BlurtStartListening") { await coordinator.grantConsent() } #endif } } diff --git a/App/BlurtiOS/BlurtiOS/Sources/ConsentView.swift b/App/BlurtiOS/BlurtiOS/Sources/ConsentView.swift new file mode 100644 index 00000000..bc83ca04 --- /dev/null +++ b/App/BlurtiOS/BlurtiOS/Sources/ConsentView.swift @@ -0,0 +1,49 @@ +import BlurtiOSCore +import SwiftUI + +/// "Send your voice to AssemblyAI?" — asked once, before the listening window +/// first opens (App Review 5.1.2(i)). It names the third party, says plainly +/// what goes to it, and links the privacy policy. The list is the disclosure +/// `AIConsent.version` covers: change one, change the other. +struct ConsentView: View { + let allow: () -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Section { + Text( + "Blurt turns your voice into text with AssemblyAI, a third-party AI service. " + + "It records only while you dictate.") + } + Section { + Label("Your voice while you dictate", systemImage: "waveform") + Label("The text just before your cursor", systemImage: "text.cursor") + Label("Your recent dictations", systemImage: "clock.arrow.circlepath") + Label("Your key terms, including contact names", systemImage: "person.text.rectangle") + } header: { + Eyebrow("Sent to AssemblyAI") + } footer: { + Text("The context helps AssemblyAI spell names right and continue your sentence.") + } + Section { + Link("AssemblyAI's privacy policy", destination: AIConsent.privacyPolicyURL) + } + } + .brandForm() + .navigationTitle("Before you blurt") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Not now") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Allow") { + dismiss() + allow() + } + } + } + } + .tint(BlurtBrand.accent) + } +} diff --git a/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift index 738df78f..7565afad 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift @@ -33,6 +33,9 @@ final class DictationCoordinator { var termPackUnreadable = false /// The window can't open without a key: the last "Start listening" said so. var needsKey = false + /// The window can't open until the user agrees to AssemblyAI processing their + /// voice (`AIConsent`); `ConsentView` asks while this is set. + var needsConsent = false @ObservationIgnored private let session: DictationSession @ObservationIgnored private let recents: AsyncStream @@ -99,6 +102,11 @@ final class DictationCoordinator { apiKey.refreshStatus() needsKey = !apiKey.hasAPIKey guard !needsKey else { return } + // Every way to the microphone comes through here — the button, the + // keyboard's `blurt://start`, and in-app dictation, which needs the window + // open — so this one gate covers them all. + needsConsent = !AIConsent.isGranted() + guard !needsConsent else { return } guard await AVAudioApplication.requestRecordPermission() else { microphoneDenied = true return @@ -107,6 +115,14 @@ final class DictationCoordinator { await window.open() } + /// The user agreed in `ConsentView`: record it and carry on opening the + /// window they asked for. + func grantConsent() async { + AIConsent.grant() + needsConsent = false + await startListening() + } + /// Closes the window. A dictation in flight is cancelled first, upload and /// all — closing the feed alone would end the utterance and let the request /// complete, transcribing (and billing) audio nobody asked for. diff --git a/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift index 19b3a438..dba9d8db 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift @@ -52,6 +52,9 @@ struct HomeView: View { .toolbar(.hidden, for: .navigationBar) .sheet(isPresented: $showsKeyEntry) { KeyEntryView(apiKey: coordinator.apiKey) } .sheet(isPresented: $showsSettings) { SettingsView(coordinator: coordinator) } + .sheet(isPresented: Binding(get: { coordinator.needsConsent }, set: { coordinator.needsConsent = $0 })) { + ConsentView { Task { await coordinator.grantConsent() } } + } .sheet(item: Binding(get: { coordinator.pendingTermPack }, set: { coordinator.pendingTermPack = $0 })) { ImportTermsView(pack: $0) } diff --git a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift index 8a7e95f5..5f5a5e6d 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift @@ -137,6 +137,7 @@ struct SettingsView: View { Section { LabeledContent("Version", value: Self.version) Link("Blurt on GitHub", destination: URL(string: "https://github.com/AssemblyAI/blurt") ?? URL(filePath: "/")) + Link("AssemblyAI's privacy policy", destination: AIConsent.privacyPolicyURL) Text("Powered by AssemblyAI").foregroundStyle(BlurtBrand.muted) } header: { Eyebrow("About") diff --git a/App/BlurtiOS/BlurtiOSCore/AIConsent.swift b/App/BlurtiOS/BlurtiOSCore/AIConsent.swift new file mode 100644 index 00000000..6e7e347b --- /dev/null +++ b/App/BlurtiOS/BlurtiOSCore/AIConsent.swift @@ -0,0 +1,28 @@ +import Foundation + +/// The user's go-ahead to send their voice, and the text that gives it context, +/// to AssemblyAI. App Review 5.1.2(i) requires clearly disclosing where personal +/// data goes, third-party AI included, and getting explicit permission first, so +/// the listening window won't open until this is granted. +/// +/// Versioned: consent covers what is sent, so sending something new (another +/// kind of context) means raising `version` and updating the copy that lists it +/// (`ConsentView`), and everyone is asked again. +package nonisolated enum AIConsent { + /// The version of the disclosure in `ConsentView`: 1 is audio, the text before + /// the cursor, recent dictations, and key terms with contact names. + package static let version = 1 + package static let defaultsKey = "AIConsentVersion" + package static let privacyPolicyURL = + URL(string: "https://www.assemblyai.com/legal/privacy-policy") ?? URL(filePath: "/") + + /// Whether the user agreed to the current disclosure. Agreeing to an older one + /// isn't enough: it didn't list everything that is sent now. + package static func isGranted(in defaults: UserDefaults = .standard) -> Bool { + defaults.integer(forKey: defaultsKey) >= version + } + + package static func grant(in defaults: UserDefaults = .standard) { + defaults.set(version, forKey: defaultsKey) + } +} diff --git a/App/BlurtiOS/BlurtiOSTests/AIConsentTests.swift b/App/BlurtiOS/BlurtiOSTests/AIConsentTests.swift new file mode 100644 index 00000000..bb206d5f --- /dev/null +++ b/App/BlurtiOS/BlurtiOSTests/AIConsentTests.swift @@ -0,0 +1,35 @@ +import Foundation +import Testing + +@testable import BlurtiOSCore + +/// The consent gate over its own defaults suite, never the app's. +@Suite("AI consent") +struct AIConsentTests { + private func freshDefaults() throws -> UserDefaults { + let name = "AIConsentTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: name)) + defaults.removePersistentDomain(forName: name) + return defaults + } + + @Test("nothing is granted until the user agrees, and agreeing sticks") + func grant() throws { + let defaults = try freshDefaults() + #expect(!AIConsent.isGranted(in: defaults)) + AIConsent.grant(in: defaults) + #expect(AIConsent.isGranted(in: defaults)) + } + + @Test("agreeing to an older disclosure doesn't cover the current one") + func olderVersion() throws { + let defaults = try freshDefaults() + defaults.set(AIConsent.version - 1, forKey: AIConsent.defaultsKey) + #expect(!AIConsent.isGranted(in: defaults)) + } + + @Test("the privacy policy link is AssemblyAI's") + func privacyPolicy() { + #expect(AIConsent.privacyPolicyURL.host() == "www.assemblyai.com") + } +} diff --git a/App/BlurtiOS/DESIGN.md b/App/BlurtiOS/DESIGN.md index c179f07d..d89286a1 100644 --- a/App/BlurtiOS/DESIGN.md +++ b/App/BlurtiOS/DESIGN.md @@ -690,14 +690,21 @@ time, a copy button); `POWERED BY ASSEMBLYAI`. **Settings** (`SettingsView`): a form on the page, its sections under eyebrows — Keyboard (layout, theme, hands-free), Listening (the window), Transcription (enhanced transcripts, output styles, key terms with the -contact-name count, sharing), Account (sign-in stub; the API key in debug -builds), About. The theme picker shows each theme's two faces side by side. -Styles, the import sheet and the key entry share the form's chrome -(`brandForm()`). +contact-name count, sharing), Account (the API key, and where to get one), +About (with AssemblyAI's privacy policy). The theme picker shows each theme's +two faces side by side. Styles, the import sheet, the key entry and the consent +sheet share the form's chrome (`brandForm()`). + +**Consent** (`ConsentView`, `AIConsent`): before the listening window first +opens, a sheet names AssemblyAI, lists what is sent (the voice while +dictating, the text before the cursor, recent dictations, key terms with +contact names) and links the privacy policy; `ALLOW` records it and opens the +window. App Review 5.1.2(i). The list is what `AIConsent.version` covers: +sending anything new means a new version, and everyone is asked again. `-BlurtSettings` opens Settings as the root for a screenshot; `xcrun simctl ui booted appearance dark` before any capture for the dark appearance; -`-BlurtStartListening` opens the mic at launch. +`-BlurtStartListening` opens the mic at launch (and grants AI consent, which a scripted run can't tap through). ## Feedback and accessibility diff --git a/App/BlurtiOS/README.md b/App/BlurtiOS/README.md index 361af67a..b24bc878 100644 --- a/App/BlurtiOS/README.md +++ b/App/BlurtiOS/README.md @@ -134,7 +134,7 @@ shows its console. owns them. 4. Plug the phone in (Developer Mode on: Settings → Privacy & Security), pick it as the run destination, run the `BlurtiOS` scheme. On the phone, trust the developer app (Settings → - General → VPN & Device Management). In the app: **Paste your key**, **Allow** the microphone, then **Open Settings** and go to **Apps › Blurt › Keyboards** + General → VPN & Device Management). In the app: **Paste your key**, **Allow** sending your voice to AssemblyAI, **Allow** the microphone, then **Open Settings** and go to **Apps › Blurt › Keyboards** (a phone may open straight on Blurt's page): turn on Blurt and **Allow Full Access**. 5. Back in the app, **Start listening**, then go to Messages, hold the globe key, pick Blurt, and tap the mic. Tap again to stop, or hold it while you talk. The words land in the field. From f7d583451327a008b10e6a0a0c3a73d18c0eaf51 Mon Sep 17 00:00:00 2001 From: he-james Date: Fri, 2 Oct 2026 17:08:10 -0500 Subject: [PATCH 3/3] Move the shared brand assets to Design/brand The iPhone project pulled the app icon and the wordmark straight out of the Mac app's folder, so a Mac-side move or rename would have broken the iPhone build. Both now live in a top-level Design/brand that each project references directly; neither app's folder is a dependency of the other. The Mac project lists them explicitly (it used to get them by including its whole Blurt folder) and its project.pbxproj is regenerated. Both app bundles still carry the icon and logo. Co-Authored-By: Claude Opus 5.5 --- .cursorignore | 2 +- .markdownlintignore | 2 +- App/Blurt/Blurt.xcodeproj/project.pbxproj | 25 ++++++++++++------ App/Blurt/Blurt/Wizard/ReadyView.swift | 2 +- App/Blurt/project.yml | 5 ++++ App/BlurtiOS/BlurtiOS/Sources/AppChrome.swift | 2 +- App/BlurtiOS/project.yml | 8 +++--- Design/README.md | 11 ++++++++ .../AppIcon.icon/Assets/blurt-wordmark.png | Bin .../brand}/AppIcon.icon/icon.json | 0 .../brand}/blurt-ready-logo.png | Bin 11 files changed, 41 insertions(+), 16 deletions(-) create mode 100644 Design/README.md rename {App/Blurt/Blurt => Design/brand}/AppIcon.icon/Assets/blurt-wordmark.png (100%) rename {App/Blurt/Blurt => Design/brand}/AppIcon.icon/icon.json (100%) rename {App/Blurt/Blurt/Branding => Design/brand}/blurt-ready-logo.png (100%) diff --git a/.cursorignore b/.cursorignore index 7d07df11..bee46ded 100644 --- a/.cursorignore +++ b/.cursorignore @@ -5,4 +5,4 @@ App/Blurt/Blurt.xcodeproj/ .github/images/ # App icon asset bundle (not Swift). -App/Blurt/Blurt/AppIcon.icon/ +Design/brand/AppIcon.icon/ diff --git a/.markdownlintignore b/.markdownlintignore index 54eb2b42..fcc44ac7 100644 --- a/.markdownlintignore +++ b/.markdownlintignore @@ -7,7 +7,7 @@ App/Blurt/Blurt.xcodeproj/ .github/images/ # App icon asset bundle (not Swift). -App/Blurt/Blurt/AppIcon.icon/ +Design/brand/AppIcon.icon/ # --- lint-only (NOT mirrored to .cursorignore — Cursor still indexes these) --- diff --git a/App/Blurt/Blurt.xcodeproj/project.pbxproj b/App/Blurt/Blurt.xcodeproj/project.pbxproj index 7a304c15..6bbdfd00 100644 --- a/App/Blurt/Blurt.xcodeproj/project.pbxproj +++ b/App/Blurt/Blurt.xcodeproj/project.pbxproj @@ -24,10 +24,12 @@ 0933F6F9CD7294843A63E09C /* rom1b-15-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = B401FA111CA49F5A535366AC /* rom1b-15-stop.m4a */; }; 0946D58B6886B74ED2A5DA18 /* rom1b-13-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 429B975FA7FFA777106D0A49 /* rom1b-13-stop.m4a */; }; 0A259385C40D25FC87E377D6 /* APIKeyStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DA3A304528FD1C7FE6C2B94 /* APIKeyStepView.swift */; }; + 0A37755226EF8ABE02BA42AC /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 30117E33FF99C309A29FB903 /* AppIcon.icon */; }; 0A7FB8A90BB7E7A5110A4F4D /* BlurtEngine in Frameworks */ = {isa = PBXBuildFile; productRef = 371215E93A9C2CE60C6347BB /* BlurtEngine */; }; 0BAB613C778CDF7691FAE6E6 /* rom1b-11-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 4052A873788D31A33BE80FCB /* rom1b-11-stop.m4a */; }; 0CB2E6C02E57374806139E68 /* juno-60-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 24B8138546D9CD21F6D55F54 /* juno-60-start.m4a */; }; 0D4AE0F936EAA5E3F0CE96AD /* rom1b-5-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = F2B1262C9AB83ED251421D03 /* rom1b-5-start.m4a */; }; + 0DB94677FDF38489C7CE3E25 /* blurt-ready-logo.png in Resources */ = {isa = PBXBuildFile; fileRef = 49B8C7CF9FC84D9E3B96796B /* blurt-ready-logo.png */; }; 0F06FD2F6DB2831867C8B681 /* juno-52-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = ED1535C5BDC865063C5DA3A3 /* juno-52-stop.m4a */; }; 0F1317CCFA2A3245DAA96141 /* DictationComposition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A7417835D8538AC4A7E2FB /* DictationComposition.swift */; }; 0F6D746DF48BB048827A358F /* rom1b-29-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 786672855B44AB80CAF4D782 /* rom1b-29-stop.m4a */; }; @@ -296,7 +298,6 @@ A98B1591750D1E45F35356F5 /* juno-75-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 92CAA331F57FCD20583AF8CB /* juno-75-start.m4a */; }; AA418A6C485538A314B95258 /* rom1b-10-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 7A6872FE0B51D0EC9C4B397B /* rom1b-10-stop.m4a */; }; AA61D851350DECC5A7045594 /* rom1a-27-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = A8FCDA16949B9CE1323E24F8 /* rom1a-27-start.m4a */; }; - AAD6D2EADCA8C241332764AD /* blurt-ready-logo.png in Resources */ = {isa = PBXBuildFile; fileRef = 350F18C964DCA2DDCA296ED4 /* blurt-ready-logo.png */; }; AB1C0077FA26EEDEA3542A07 /* juno-89-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 00768AE8171902B439F0602F /* juno-89-start.m4a */; }; AC402D2E4BC71A0F8324DCFD /* juno-109-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = EB08EC40A267C71B185EC782 /* juno-109-stop.m4a */; }; AD32046355138298E7FC40D0 /* MenuBarScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBA9C9322D0AF3D9C529F06F /* MenuBarScene.swift */; }; @@ -428,7 +429,6 @@ F0ABE7C8E87C7B955DB89601 /* juno-34-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 3012F22300681F3DD71AD51B /* juno-34-stop.m4a */; }; F164F3AA161629499C07A5F2 /* rom1b-17-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = DE43545891BB9D4ED7B55D7B /* rom1b-17-stop.m4a */; }; F22CA13B8F0DB10BE6D84696 /* rom1a-21-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 5DB9D5866E9743B12589DFF4 /* rom1a-21-stop.m4a */; }; - F245B1D54675872118567839 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 1BE0AC6DFA2DC39D6D557BA3 /* AppIcon.icon */; }; F2C37DB124D3CD134315F6AD /* rom1b-20-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 662773E86D58C62C6A41801F /* rom1b-20-start.m4a */; }; F30578AA785CAE98E944F6B7 /* TextShortcutsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A50C85936C100F9E51CC2AD /* TextShortcutsSection.swift */; }; F3D87A72BED81C8532FCB503 /* juno-3-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = A2C5021E8F72182F331DA397 /* juno-3-start.m4a */; }; @@ -504,7 +504,6 @@ 19A708C2C2C7CDF52E47A8C4 /* juno-88-start.m4a */ = {isa = PBXFileReference; path = "juno-88-start.m4a"; sourceTree = ""; }; 1A1C4446E1D3FAB9F662E61D /* rom1a-12-stop.m4a */ = {isa = PBXFileReference; path = "rom1a-12-stop.m4a"; sourceTree = ""; }; 1A3D5DAAD31F8C7F67CAD5BB /* juno-32-start.m4a */ = {isa = PBXFileReference; path = "juno-32-start.m4a"; sourceTree = ""; }; - 1BE0AC6DFA2DC39D6D557BA3 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = ""; }; 1C72B7BEF6CFEC52928C6CED /* juno-100-stop.m4a */ = {isa = PBXFileReference; path = "juno-100-stop.m4a"; sourceTree = ""; }; 1CF1F098A8DC41DD8508480E /* juno-8-start.m4a */ = {isa = PBXFileReference; path = "juno-8-start.m4a"; sourceTree = ""; }; 1D5C52A673686FB041595227 /* rom1b-31-stop.m4a */ = {isa = PBXFileReference; path = "rom1b-31-stop.m4a"; sourceTree = ""; }; @@ -548,6 +547,7 @@ 2E64BCDB280FD0A27D965AA9 /* rom1a-5-start.m4a */ = {isa = PBXFileReference; path = "rom1a-5-start.m4a"; sourceTree = ""; }; 2E79CB809D285C0C27E8D43D /* OverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayView.swift; sourceTree = ""; }; 2FFA326076660C91492A6CE1 /* juno-8-stop.m4a */ = {isa = PBXFileReference; path = "juno-8-stop.m4a"; sourceTree = ""; }; + 30117E33FF99C309A29FB903 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = ""; }; 3012F22300681F3DD71AD51B /* juno-34-stop.m4a */ = {isa = PBXFileReference; path = "juno-34-stop.m4a"; sourceTree = ""; }; 30703F589CF213720C7E3B15 /* StyleRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StyleRow.swift; sourceTree = ""; }; 30E57A9C9B934A2120381005 /* WizardController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WizardController.swift; sourceTree = ""; }; @@ -561,7 +561,6 @@ 33AE2EBBF7FA36E1E0AEBC3A /* rom1a-16-start.m4a */ = {isa = PBXFileReference; path = "rom1a-16-start.m4a"; sourceTree = ""; }; 35048A9866E106410CDCED81 /* juno-115-stop.m4a */ = {isa = PBXFileReference; path = "juno-115-stop.m4a"; sourceTree = ""; }; 35059AC7D847666F9D7FFF07 /* juno-107-start.m4a */ = {isa = PBXFileReference; path = "juno-107-start.m4a"; sourceTree = ""; }; - 350F18C964DCA2DDCA296ED4 /* blurt-ready-logo.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "blurt-ready-logo.png"; sourceTree = ""; }; 35EA12CB630AC702CBAC175A /* juno-106-stop.m4a */ = {isa = PBXFileReference; path = "juno-106-stop.m4a"; sourceTree = ""; }; 3611A8FA0547B780D0804F52 /* AppCoordinator+UITesting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppCoordinator+UITesting.swift"; sourceTree = ""; }; 36CF30F1384D606BE74362C8 /* juno-51-start.m4a */ = {isa = PBXFileReference; path = "juno-51-start.m4a"; sourceTree = ""; }; @@ -608,6 +607,7 @@ 4873421ACF290B88FA257D6A /* rom1a-7-start.m4a */ = {isa = PBXFileReference; path = "rom1a-7-start.m4a"; sourceTree = ""; }; 48C4CFC6271703797187A269 /* KeyCap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCap.swift; sourceTree = ""; }; 49A71146BDFEA07D74A6A67C /* juno-32-stop.m4a */ = {isa = PBXFileReference; path = "juno-32-stop.m4a"; sourceTree = ""; }; + 49B8C7CF9FC84D9E3B96796B /* blurt-ready-logo.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "blurt-ready-logo.png"; sourceTree = ""; }; 49CD6A6815EDDB16BBBCCCB2 /* rom1b-3-start.m4a */ = {isa = PBXFileReference; path = "rom1b-3-start.m4a"; sourceTree = ""; }; 49F7AD1193F18CCDF17B9EBA /* juno-6-stop.m4a */ = {isa = PBXFileReference; path = "juno-6-stop.m4a"; sourceTree = ""; }; 4A89CB4834FF9ABE02C969F7 /* juno-111-stop.m4a */ = {isa = PBXFileReference; path = "juno-111-stop.m4a"; sourceTree = ""; }; @@ -936,7 +936,6 @@ 240939D503F3CE03A0697A5F /* AppCoordinator.swift */, 3611A8FA0547B780D0804F52 /* AppCoordinator+UITesting.swift */, 9542F903D789DDAA55639374 /* AppDelegate.swift */, - 1BE0AC6DFA2DC39D6D557BA3 /* AppIcon.icon */, E3DD66C653EAB2EFF2CF8DAA /* Assets.xcassets */, 77E9E7F17972483F6FBF1CCD /* Blurt.entitlements */, 7D222ED050CC93DEB330E773 /* CueSoundPlayer.swift */, @@ -961,6 +960,7 @@ children = ( 1560402FBA5911463036085F /* Blurt */, 73649A7C02EB13B17D81A412 /* BlurtUITests */, + E0FCB536C7F1A0AD06EE625D /* brand */, 4B1375E36CD42DBEFF08BC7E /* Packages */, D8F69C40E4A93A6B3433BAC0 /* Shared */, 10E2E11380C974390308C1D2 /* Products */, @@ -1406,7 +1406,6 @@ 7D6DE85EA50E7CD4CC977211 /* Branding */ = { isa = PBXGroup; children = ( - 350F18C964DCA2DDCA296ED4 /* blurt-ready-logo.png */, C19DBE4661B752927D16B261 /* BlurtBrand.swift */, ); path = Branding; @@ -1466,6 +1465,16 @@ path = Hotkey; sourceTree = ""; }; + E0FCB536C7F1A0AD06EE625D /* brand */ = { + isa = PBXGroup; + children = ( + 30117E33FF99C309A29FB903 /* AppIcon.icon */, + 49B8C7CF9FC84D9E3B96796B /* blurt-ready-logo.png */, + ); + name = brand; + path = ../../Design/brand; + sourceTree = ""; + }; E7D37943B6B2CE3C0692AA48 /* Update */ = { isa = PBXGroup; children = ( @@ -1581,9 +1590,9 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - F245B1D54675872118567839 /* AppIcon.icon in Resources */, + 0A37755226EF8ABE02BA42AC /* AppIcon.icon in Resources */, 68AE6018C91E06C05EBFB2D0 /* Assets.xcassets in Resources */, - AAD6D2EADCA8C241332764AD /* blurt-ready-logo.png in Resources */, + 0DB94677FDF38489C7CE3E25 /* blurt-ready-logo.png in Resources */, 351E74AD4AF61AC397D0E29F /* juno-0-start.m4a in Resources */, 8D81DE1C806A0EE6E219D343 /* juno-0-stop.m4a in Resources */, FACF8BEBF744A47219B402F3 /* juno-1-start.m4a in Resources */, diff --git a/App/Blurt/Blurt/Wizard/ReadyView.swift b/App/Blurt/Blurt/Wizard/ReadyView.swift index 085c60e8..444b915a 100644 --- a/App/Blurt/Blurt/Wizard/ReadyView.swift +++ b/App/Blurt/Blurt/Wizard/ReadyView.swift @@ -216,7 +216,7 @@ enum BlurtLinks { } /// The `blurt` wordmark over the form: the brand-green mark -/// (`Branding/blurt-ready-logo.png`, a 720×180 rasterization of the design's +/// (`Design/brand/blurt-ready-logo.png`, a 720×180 rasterization of the design's /// vector wordmark, so its 104×26 pt slot is fed nearly 7× the pixels it needs and /// stays crisp at any display scale). Smoothly interpolated — it's curved /// letterforms now, not the pixel-art mark it replaced, which needed diff --git a/App/Blurt/project.yml b/App/Blurt/project.yml index e6da6a58..a0045157 100644 --- a/App/Blurt/project.yml +++ b/App/Blurt/project.yml @@ -141,6 +141,11 @@ targets: # Shared with BlurtUITests: the single source of truth for UI-test # identifiers/sentinels (see Shared/UITestIdentifiers.swift). - path: Shared + # The brand assets both apps ship (Design/brand), so neither app's folder + # is a dependency of the other. + - path: ../../Design/brand/AppIcon.icon + - path: ../../Design/brand/blurt-ready-logo.png + buildPhase: resources info: path: Blurt/Info.plist properties: diff --git a/App/BlurtiOS/BlurtiOS/Sources/AppChrome.swift b/App/BlurtiOS/BlurtiOS/Sources/AppChrome.swift index 2dabcfda..bb2e5f15 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/AppChrome.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/AppChrome.swift @@ -112,7 +112,7 @@ struct BrandButtonStyle: ButtonStyle { } /// The lowercase wordmark from the Mac's ready screen, tinted with the accent -/// (`blurt-ready-logo.png`, shared from `App/Blurt/Blurt/Branding`). +/// (`blurt-ready-logo.png`, shared from `Design/brand`). struct Wordmark: View { private static let height = DesignTokens.Metrics.wordmarkHeight diff --git a/App/BlurtiOS/project.yml b/App/BlurtiOS/project.yml index df4d7bc8..8579e2fb 100644 --- a/App/BlurtiOS/project.yml +++ b/App/BlurtiOS/project.yml @@ -79,10 +79,10 @@ targets: # and the bundle's own (CA92.1), the only such API either target uses. - path: BlurtiOS/PrivacyInfo.xcprivacy buildPhase: resources - # The Mac's app icon and ready-screen wordmark, shared rather than copied: - # the Icon Composer file targets iOS too (`squares: shared`). - - path: ../Blurt/Blurt/AppIcon.icon - - path: ../Blurt/Blurt/Branding/blurt-ready-logo.png + # The app icon and wordmark both apps ship (Design/brand), shared rather + # than copied: the Icon Composer file targets iOS too (`squares: shared`). + - path: ../../Design/brand/AppIcon.icon + - path: ../../Design/brand/blurt-ready-logo.png buildPhase: resources # The UI both the app and the keyboard draw with (design tokens, type, # brand colours), compiled into each — an extension can't import the diff --git a/Design/README.md b/Design/README.md new file mode 100644 index 00000000..dca2a1b3 --- /dev/null +++ b/Design/README.md @@ -0,0 +1,11 @@ +# Design + +Brand assets shared by both apps, so neither app's folder depends on the other's. + +| Path | What it is | +| ---------------------------- | ----------------------------------------------------------------- | +| `brand/AppIcon.icon` | The app icon (Icon Composer), built for macOS and iOS | +| `brand/blurt-ready-logo.png` | The `blurt` wordmark (720×180), tinted with the accent at runtime | + +Both `App/Blurt/project.yml` and `App/BlurtiOS/project.yml` reference these paths directly. +Assets one app alone uses stay with that app (for example `App/BlurtiOS/Design/tokens.json`). diff --git a/App/Blurt/Blurt/AppIcon.icon/Assets/blurt-wordmark.png b/Design/brand/AppIcon.icon/Assets/blurt-wordmark.png similarity index 100% rename from App/Blurt/Blurt/AppIcon.icon/Assets/blurt-wordmark.png rename to Design/brand/AppIcon.icon/Assets/blurt-wordmark.png diff --git a/App/Blurt/Blurt/AppIcon.icon/icon.json b/Design/brand/AppIcon.icon/icon.json similarity index 100% rename from App/Blurt/Blurt/AppIcon.icon/icon.json rename to Design/brand/AppIcon.icon/icon.json diff --git a/App/Blurt/Blurt/Branding/blurt-ready-logo.png b/Design/brand/blurt-ready-logo.png similarity index 100% rename from App/Blurt/Blurt/Branding/blurt-ready-logo.png rename to Design/brand/blurt-ready-logo.png