Files
FamilyMealPlanner/fastlane/Fastfile
T
alexandrev-tibco 39d3534c57 fix: el tap del microfono seguia aislado al main actor (2.0.3 build 68)
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
2026-08-20 09:21:52 +02:00

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