39d3534c57
El fix anterior arreglo los permisos —el crash se movio de start a beginSession, y de TCC a AVAudioNodeTap::CheckEmitBuffer— pero el tap seguia petando. Mi error: quitar el acceso a self del closure no elimina la inferencia de aislamiento. La inferencia viene de DONDE se escribe el closure, no de lo que captura. Escrito dentro de un metodo @MainActor, seguia siendo main-actor isolated, y el tap se dispara desde el hilo de audio en tiempo real. Ahora el handler se construye en makeTapHandler, que es nonisolated, y se pasa a installTap. La estructura se auto-verifica: quitarle nonisolated a makeTapHandler no compila, porque installTap es nonisolated y no puede llamar a un metodo aislado. El bug pasa de crash en produccion a error de compilacion. Tests: testTapHandlerRunsOffTheMainThread invoca el handler desde una cola de fondo, que es exactamente la condicion que trapeaba. No hace falta microfono; el intento anterior con AVAudioEngine se saltaba siempre porque el simulador no tiene entrada de audio utilizable. Barrido del mismo patron en el resto de servicios @MainActor: CalendarService y NotificationService usan las variantes async/await, que estan anotadas y no tienen closures. SpeechDictationService era el unico sitio. Lanes feedback y crashlog para leer los reportes de TestFlight desde la API: spaceship apunta a v1/betaFeedbacks, que Apple ya retiro; el endpoint vivo es v1/apps/<id>/betaFeedbackCrashSubmissions y el log viene inline en logText. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Ks8uUcMA9mjypVK7F2Pkt
241 lines
8.9 KiB
Ruby
241 lines
8.9 KiB
Ruby
default_platform(:ios)
|
|
|
|
# Credentials: App Store Connect API key via `pass`.
|
|
# The api_key block uses the key inline — no session/2FA needed.
|
|
|
|
platform :ios do
|
|
EXPORT_OPTIONS = {
|
|
method: "app-store",
|
|
signingStyle: "manual",
|
|
manageAppVersionAndBuildNumber: false,
|
|
provisioningProfiles: {
|
|
"com.alexandrevazquez.mealmood" => "com.alexandrevazquez.mealmood AppStore",
|
|
"com.alexandrevazquez.mealmood.widget" => "com.alexandrevazquez.mealmood.widget AppStore"
|
|
}
|
|
}
|
|
|
|
def asc_api_key
|
|
app_store_connect_api_key(
|
|
key_id: `pass show appstore/api-key-id`.strip,
|
|
issuer_id: `pass show appstore/issuer-id`.strip,
|
|
key_content: `pass show appstore/api-key-p8`.strip,
|
|
in_house: false
|
|
)
|
|
end
|
|
|
|
desc "Push a new beta build to TestFlight"
|
|
lane :beta do
|
|
increment_build_number(xcodeproj: "MealMood.xcodeproj")
|
|
build_app(
|
|
scheme: "MealMood",
|
|
export_method: "app-store",
|
|
sdk: "iphoneos26.5",
|
|
export_options: EXPORT_OPTIONS
|
|
)
|
|
upload_to_testflight(
|
|
api_key: asc_api_key,
|
|
skip_waiting_for_build_processing: true
|
|
)
|
|
end
|
|
|
|
# Firebase arrives via SPM, so upload-symbols sits in DerivedData instead of
|
|
# the ./Pods path fastlane looks in by default.
|
|
def crashlytics_upload_symbols_binary
|
|
pattern = File.expand_path(
|
|
"~/Library/Developer/Xcode/DerivedData/MealMood-*/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/upload-symbols"
|
|
)
|
|
found = Dir.glob(pattern)
|
|
if found.empty?
|
|
UI.user_error!("upload-symbols not found. Build the project once so SPM checks out firebase-ios-sdk.")
|
|
end
|
|
found.max_by { |path| File.mtime(path) }
|
|
end
|
|
|
|
# Every dSYM (app + widget) of the most recently archived build.
|
|
def latest_archive_dsyms
|
|
pattern = File.expand_path("~/Library/Developer/Xcode/Archives/*/MealMood*.xcarchive/dSYMs/*.dSYM")
|
|
by_archive = Dir.glob(pattern).group_by { |path| path[/.*\.xcarchive/] }
|
|
newest = by_archive.max_by { |archive, _| File.mtime(archive) }
|
|
newest ? newest.last : []
|
|
end
|
|
|
|
# The shipped version lives in the Info.plist, not in MARKETING_VERSION.
|
|
# Absolute path: the lane's working directory is not guaranteed to be the repo root.
|
|
def shipped_app_version
|
|
plist = File.expand_path("../MealMood/Resources/Info.plist", __dir__)
|
|
value = `/usr/bin/plutil -extract CFBundleShortVersionString raw "#{plist}"`.strip
|
|
UI.user_error!("Could not read the app version from #{plist}") if value.empty?
|
|
value
|
|
end
|
|
|
|
# asc_api_key returns fastlane's option hash, not a Spaceship token.
|
|
def connect_api_app
|
|
require 'spaceship'
|
|
Spaceship::ConnectAPI.token = Spaceship::ConnectAPI::Token.create(
|
|
key_id: `pass show appstore/api-key-id`.strip,
|
|
issuer_id: `pass show appstore/issuer-id`.strip,
|
|
key: `pass show appstore/api-key-p8`.strip
|
|
)
|
|
Spaceship::ConnectAPI::App.find("com.alexandrevazquez.mealmood")
|
|
end
|
|
|
|
# `deliver` does not create the App Store version record — it fails with
|
|
# "could not find an editable version for 'IOS'" when the previous version is
|
|
# already READY_FOR_SALE and nothing is open for editing.
|
|
def ensure_editable_version(version)
|
|
app = connect_api_app
|
|
editable = app.get_edit_app_store_version(platform: "IOS")
|
|
|
|
if editable && editable.version_string != version
|
|
# ensure_version! would silently renumber it — say so before it happens.
|
|
UI.important("App Store Connect had #{editable.version_string} open for editing; renaming it to #{version}")
|
|
end
|
|
|
|
created = app.ensure_version!(version, platform: "IOS")
|
|
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:<submission 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
|
|
app.get_app_store_versions(includes: "build").each do |v|
|
|
UI.message("#{v.version_string} — #{v.app_store_state} — release: #{v.release_type} — build: #{v.build&.version}")
|
|
end
|
|
end
|
|
|
|
# For a build that is already on TestFlight: `release` would rebuild it and
|
|
# `publish` never submits. Screenshots are left alone — they carry over.
|
|
desc "Submit an already-uploaded build for App Store review (automatic release)"
|
|
lane :submit do |options|
|
|
UI.user_error!("pass build:<number>") unless options[:build]
|
|
version = options[:version] || shipped_app_version
|
|
ensure_editable_version(version)
|
|
upload_to_app_store(
|
|
api_key: asc_api_key,
|
|
app_version: version,
|
|
build_number: options[:build],
|
|
skip_binary_upload: true,
|
|
skip_metadata: false,
|
|
skip_screenshots: true,
|
|
overwrite_screenshots: false,
|
|
submit_for_review: true,
|
|
automatic_release: true,
|
|
force: true,
|
|
precheck_include_in_app_purchases: false,
|
|
run_precheck_before_submit: false
|
|
)
|
|
end
|
|
|
|
# Safety net for the Xcode build phase, which only uploads the dSYM produced
|
|
# locally. Run it once App Store Connect has finished processing the build —
|
|
# `beta` uses skip_waiting_for_build_processing, so right after an upload the
|
|
# dSYMs are not there yet and this would download nothing.
|
|
desc "Download processed dSYMs from App Store Connect and send them to Crashlytics"
|
|
lane :refresh_dsyms do |options|
|
|
download_dsyms(
|
|
api_key: asc_api_key,
|
|
version: options[:version] || "latest",
|
|
build_number: options[:build]
|
|
)
|
|
|
|
dsyms = lane_context[SharedValues::DSYM_PATHS] || []
|
|
if dsyms.empty?
|
|
# Expected for a post-bitcode app: Apple recompiles nothing, so it keeps
|
|
# no dSYM of its own. Fall back to the ones the local archive produced.
|
|
dsyms = latest_archive_dsyms
|
|
UI.important("App Store Connect has no dSYMs for this build; using the latest local archive.")
|
|
end
|
|
|
|
if dsyms.empty?
|
|
UI.important("No dSYMs found either — nothing to upload.")
|
|
next
|
|
end
|
|
|
|
upload_symbols_to_crashlytics(
|
|
gsp_path: "MealMood/Resources/GoogleService-Info.plist",
|
|
binary_path: crashlytics_upload_symbols_binary,
|
|
dsym_paths: dsyms
|
|
)
|
|
clean_build_artifacts
|
|
end
|
|
|
|
desc "Upload metadata + localized screenshots for an existing TestFlight build (no rebuild, no submit)"
|
|
lane :publish do |options|
|
|
version = options[:version] || "1.2.0"
|
|
build = options[:build] || "55"
|
|
upload_to_app_store(
|
|
api_key: asc_api_key,
|
|
app_version: version,
|
|
build_number: build,
|
|
skip_binary_upload: true,
|
|
skip_metadata: false,
|
|
skip_screenshots: false,
|
|
overwrite_screenshots: true,
|
|
submit_for_review: false,
|
|
automatic_release: false,
|
|
precheck_include_in_app_purchases: false,
|
|
force: true,
|
|
run_precheck_before_submit: false
|
|
)
|
|
end
|
|
|
|
desc "Push a new release build to the App Store"
|
|
lane :release do
|
|
increment_build_number(xcodeproj: "MealMood.xcodeproj")
|
|
build_app(
|
|
scheme: "MealMood",
|
|
sdk: "iphoneos26.5",
|
|
export_options: EXPORT_OPTIONS
|
|
)
|
|
upload_to_app_store(
|
|
api_key: asc_api_key,
|
|
automatic_release: true,
|
|
force: true
|
|
)
|
|
end
|
|
end
|