diff --git a/MealMood/Services/SpeechDictationService.swift b/MealMood/Services/SpeechDictationService.swift index cef9c97..f510d99 100644 --- a/MealMood/Services/SpeechDictationService.swift +++ b/MealMood/Services/SpeechDictationService.swift @@ -73,6 +73,35 @@ final class SpeechDictationService: ObservableObject { } } + /// Installs the microphone tap from a `nonisolated` context. + /// + /// `AVAudioNodeTapBlock` is pre-concurrency, so a closure literal written + /// inside a `@MainActor` method is inferred main-actor isolated — and the tap + /// is fired from the realtime audio thread, so Swift 6's isolation check + /// trapped there. Dropping the `self` access was not enough: the inference + /// comes from where the closure is *written*, not from what it captures. + /// Declaring these helpers `nonisolated` is what actually removes it. + /// + /// The handler is built here, in a `nonisolated` context, and handed to + /// `installTap` rather than written inline at the call site — that is the + /// whole point, and it is also what makes it testable: a test can call this + /// and invoke the result off the main thread, which is exactly the condition + /// that trapped, without needing a working microphone. + nonisolated static func makeTapHandler( + feeding request: SFSpeechAudioBufferRecognitionRequest + ) -> (AVAudioPCMBuffer, AVAudioTime) -> Void { + { buffer, _ in + request.append(buffer) + } + } + + nonisolated static func installTap(on node: AVAudioInputNode, + format: AVAudioFormat, + feeding request: SFSpeechAudioBufferRecognitionRequest) { + node.installTap(onBus: 0, bufferSize: 1024, format: format, + block: makeTapHandler(feeding: request)) + } + private func beginSession(localeIdentifier: String) { guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: localeIdentifier)), recognizer.isAvailable else { @@ -96,12 +125,7 @@ final class SpeechDictationService: ObservableObject { let inputNode = audioEngine.inputNode let format = inputNode.outputFormat(forBus: 0) inputNode.removeTap(onBus: 0) - // Captures the request directly instead of reaching through `self`: - // this runs on the realtime audio thread, which must never touch - // main-actor state. `append` is designed to be fed from that thread. - inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in - request.append(buffer) - } + Self.installTap(on: inputNode, format: format, feeding: request) audioEngine.prepare() try audioEngine.start() diff --git a/MealMoodTests/SpeechDictationServiceTests.swift b/MealMoodTests/SpeechDictationServiceTests.swift index 07e6f33..ed8b543 100644 --- a/MealMoodTests/SpeechDictationServiceTests.swift +++ b/MealMoodTests/SpeechDictationServiceTests.swift @@ -1,4 +1,6 @@ import XCTest +import AVFoundation +import Speech @testable import MealMood /// Smoke cover for the dictation entry point. @@ -32,6 +34,36 @@ final class SpeechDictationServiceTests: XCTestCase { "No permission is granted in this environment, so it must not record") } + /// This one *does* reproduce the crash class, without needing a microphone. + /// + /// In 2.0.3 build 68 the app still died at the tap: `AVAudioNodeTapBlock` is + /// pre-concurrency, so a closure literal written inside a `@MainActor` method + /// was inferred main-actor isolated, and the tap fires from the realtime + /// audio thread — Swift 6's isolation check tripped `dispatch_assert_queue`. + /// Dropping the `self` capture had not been enough; isolation is inferred + /// from where the closure is written. + /// + /// Calling the handler off the main thread is the exact condition that + /// trapped, so this fails loudly if the isolation ever creeps back. + func testTapHandlerRunsOffTheMainThread() async throws { + let request = SFSpeechAudioBufferRecognitionRequest() + let handler = SpeechDictationService.makeTapHandler(feeding: request) + + let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate: 44_100, channels: 1)) + let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: 1024)) + buffer.frameLength = 1024 + + let ran = expectation(description: "tap handler ran on a background queue") + DispatchQueue.global(qos: .userInitiated).async { + XCTAssertFalse(Thread.isMainThread) + handler(buffer, AVAudioTime(sampleTime: 0, atRate: 44_100)) + ran.fulfill() + } + await fulfillment(of: [ran], timeout: 5) + + request.endAudio() + } + func testStartTwiceDoesNotBlowUp() { let service = SpeechDictationService() service.start(localeIdentifier: "en-US") diff --git a/fastlane/Fastfile b/fastlane/Fastfile index fe3fb55..3e8725d 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -95,6 +95,50 @@ platform :ios do UI.success(created ? "App Store version #{version} ready" : "App Store version #{version} already editable") end + desc "Show the latest TestFlight beta feedback (tester reports and crashes)" + lane :feedback do |options| + app = connect_api_app + # spaceship's get_beta_feedback hits v1/betaFeedbacks, a private endpoint + # Apple has since removed. These are the current public ones. + client = Spaceship::ConnectAPI.test_flight_request_client + limit = (options[:limit] || 10).to_i + + ["v1/apps/#{app.id}/betaFeedbackCrashSubmissions", "v1/apps/#{app.id}/betaFeedbackScreenshotSubmissions"].each do |path| + UI.header(path) + begin + resp = client.get(path, { + "include" => "build,tester", + "limit" => limit, + "sort" => "-createdDate" + }) + rows = resp.body["data"] || [] + UI.important("none") if rows.empty? + rows.each do |row| + a = row["attributes"] || {} + UI.message("── #{a['createdDate']} — #{a['deviceModel']} — #{a['osVersion']} — id #{row['id']}") + UI.message(" #{a['comment']}") if a["comment"] + UI.message(" crashLog: #{a['crashLog'] || row.dig('relationships', 'crashLog', 'links', 'related')}") + end + rescue => e + UI.error("#{path}: #{e.message[0, 200]}") + end + end + end + + desc "Download the crash log of a TestFlight feedback submission (id from `feedback`)" + lane :crashlog do |options| + UI.user_error!("pass id:") unless options[:id] + connect_api_app + client = Spaceship::ConnectAPI.test_flight_request_client + resp = client.get("v1/betaFeedbackCrashSubmissions/#{options[:id]}/crashLog", {}) + # The log comes back inline as logText, not as a download URL. + text = resp.body.dig("data", "attributes", "logText") + UI.user_error!("No logText in response: #{resp.body.to_s[0, 300]}") unless text + out = File.expand_path("../crashlog-#{options[:id]}.crash", __dir__) + File.write(out, text) + UI.success("Saved to #{out}") + end + desc "Show the App Store state of each version (review status, release type)" lane :status do app = connect_api_app diff --git a/fastlane/README.md b/fastlane/README.md index ecf6f0e..40ec404 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -23,6 +23,22 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do Push a new beta build to TestFlight +### ios feedback + +```sh +[bundle exec] fastlane ios feedback +``` + +Show the latest TestFlight beta feedback (tester reports and crashes) + +### ios crashlog + +```sh +[bundle exec] fastlane ios crashlog +``` + +Download the crash log of a TestFlight feedback submission (id from `feedback`) + ### ios status ```sh