defmodule Mix.Tasks.Mob.Provision do use Mix.Task @shortdoc "Register your app ID and download an iOS provisioning profile" @moduledoc """ Registers your app's bundle ID with Apple and downloads an iOS provisioning profile. Two modes: mix mob.provision # development profile (default) mix mob.provision --distribution # App Store distribution profile Run development provisioning once before your first `mix mob.deploy --native`. Run distribution provisioning once before your first `mix mob.release`. ## What you need first 1. **Apple ID** — free at https://appleid.apple.com 2. **Xcode signed in** with that Apple ID: open Xcode → Settings → Accounts → [+] → Apple ID 3. **Apple Developer Program** — optional for personal device development, required for App Store distribution ($99/year). Free accounts can deploy to their own devices; profiles expire every 7 days. Paid accounts get 1-year profiles and App Store access. Enroll at https://developer.apple.com/programs/enroll/ Distribution mode requires a paid Developer Program membership. ## Headless / unattended provisioning (App Store Connect API key) Step 2 (an interactive Xcode Apple ID account) is impossible for an unattended user — a CI runner or a headless agent account with no GUI login. Instead, authenticate `-allowProvisioningUpdates` with an **App Store Connect API key** by setting three env vars; when they are present the task passes them to `xcodebuild` and no signed-in Xcode account is needed: * `APP_STORE_CONNECT_KEY_ID` — the API key's Key ID * `APP_STORE_CONNECT_ISSUER_ID` — your team's Issuer ID * `APP_STORE_CONNECT_API_KEY_PATH` — path to the downloaded `AuthKey_.p8` Create the key at App Store Connect → Users and Access → Integrations → App Store Connect API, with a role that can manage certificates/profiles/devices (Admin or App Manager). The `.p8` downloads once — store it read-only. Set all three or none (a partial set raises); with none set, the signed-in Xcode account is used as before. Signing still needs the certificate + private key in an unlocked keychain — the API key only authorizes the profile/device calls. ## What it does (development) 1. Reads your signing team from the macOS keychain or existing profiles 2. Generates `ios/Provision.xcodeproj` — a minimal Xcode project used only for provisioning (safe to commit) 3. Generates `ios/MobProvision.swift` — a two-line SwiftUI stub 4. Runs `xcodebuild -allowProvisioningUpdates build` which contacts Apple to: - Register your bundle ID in your developer account (if not registered) - Create a development provisioning profile - Download it to ~/Library/Developer/Xcode/.../Provisioning Profiles/ 5. Verifies the profile is present ## What it does (distribution) Same as above, but runs `xcodebuild archive -allowProvisioningUpdates` with `CODE_SIGN_STYLE=Automatic` against the Release configuration. Apple creates an App Store provisioning profile (and an Apple Distribution certificate, if missing) and downloads them to your keychain + provisioning profile directory. """ @switches [distribution: :boolean] @impl Mix.Task def run(argv) do {opts, _, _} = OptionParser.parse(argv, strict: @switches) mode = if opts[:distribution], do: :distribution, else: :development unless macos?() do Mix.raise("mix mob.provision is only supported on macOS.") end unless File.dir?("ios") do Mix.raise("No ios/ directory found. Run from the root of a mob iOS project.") end IO.puts("") label = if mode == :distribution, do: "Distribution", else: "Development" IO.puts("#{cyan()}=== iOS Provisioning (#{label}) ===#{reset()}") IO.puts("") IO.puts("#{bright()}What you need before this step:#{reset()}") IO.puts("") IO.puts(" 1. Apple ID — free at #{cyan()}https://appleid.apple.com#{reset()}") IO.puts(" 2. Xcode signed in with that Apple ID") IO.puts(" Open Xcode → Settings → Accounts → [+] → Apple ID") IO.puts(" 3. (App Store only) Apple Developer Program — $99/year") IO.puts(" Free accounts work for deploying to your own devices.") IO.puts("") IO.puts("#{bright()}Checking...#{reset()}") IO.puts("") check_signing_identity!(mode) team_id = resolve_team_id() bundle_id = check_bundle_id!() IO.puts("") IO.puts(" Bundle ID : #{cyan()}#{bundle_id}#{reset()}") IO.puts(" Team ID : #{cyan()}#{team_id}#{reset()}") IO.puts("") # For distribution mode: if the user already has an App Store profile # for this bundle ID locally, use its actual UUID rather than guessing # the name. xcodebuild's PROVISIONING_PROFILE_SPECIFIER accepts both # names and UUIDs; UUIDs are unambiguous and survive any naming # convention the user picked when they downloaded the profile from # developer.apple.com or via Xcode UI. # # For dev mode the Release config isn't used (build action defaults # to Debug), but the pbxproj template always emits both configs, so # we pass the predicted name as a harmless placeholder. profile_specifier = case mode do :distribution -> discover_dist_profile(bundle_id, team_id) || default_profile_name(bundle_id) _ -> default_profile_name(bundle_id) end generate_xcodeproj(bundle_id, team_id, profile_specifier) generate_swift_stub() IO.puts("") IO.puts("#{bright()}Contacting Apple to register App ID and download profile...#{reset()}") IO.puts("(requires internet — may take 10–30 seconds)") IO.puts("") run_xcodebuild!(mode) verify_profile!(bundle_id, mode) IO.puts("") IO.puts("#{green()}✓ Provisioning complete!#{reset()}") IO.puts("") case mode do :distribution -> IO.puts("Next step: #{cyan()}mix mob.release#{reset()}") _ -> IO.puts("Next step: #{cyan()}mix mob.deploy --native#{reset()}") end IO.puts("") IO.puts( "#{faint()}Free Apple ID profiles expire every 7 days — re-run mix mob.provision when that happens." ) IO.puts("Paid Developer Program profiles last 1 year.#{reset()}") end # ── Prerequisite checks ─────────────────────────────────────────────────────── defp check_signing_identity!(mode) do cert_kind = case mode do :distribution -> "Apple Distribution" _ -> "Apple Development" end case System.cmd("security", ["find-identity", "-v", "-p", "codesigning"], stderr_to_stdout: true ) do {output, 0} -> identities = Regex.scan(Regex.compile!("\\d+\\) [0-9A-F]+ \"([^\"]+)\""), output) |> Enum.map(fn [_, id] -> id end) |> Enum.filter(&String.contains?(&1, cert_kind)) |> Enum.uniq() case identities do [] -> # For distribution, xcodebuild -allowProvisioningUpdates with the # archive action can create the cert if missing — so this isn't # fatal in distribution mode. Just warn and let xcodebuild try. IO.puts(" #{yellow()}?#{reset()} #{cert_kind} certificate — not yet in keychain") if mode == :distribution do IO.puts( " #{faint()}xcodebuild will attempt to create one when contacting Apple.#{reset()}" ) else Mix.raise(""" No #{cert_kind} signing certificate found. One-time setup: 1. Open Xcode 2. Xcode → Settings → Accounts → [+] → add your Apple ID 3. Select your team → click "Manage Certificates" → "+" 4. Re-run: mix mob.provision """) end [id] -> IO.puts(" #{green()}✓#{reset()} Signing certificate — #{faint()}#{id}#{reset()}") many -> IO.puts( " #{green()}✓#{reset()} Signing certificate — #{faint()}#{hd(many)}#{reset()} (#{length(many)} found, using first)" ) end _ -> Mix.raise("Could not query keychain — is this macOS?") end end defp resolve_team_id do cfg = MobDev.Config.load_mob_config() cond do team = cfg[:ios_team_id] -> IO.puts(" #{green()}✓#{reset()} Team ID — #{team} #{faint()}(from mob.exs)#{reset()}") team team = team_from_any_profile() -> IO.puts( " #{green()}✓#{reset()} Team ID — #{team} #{faint()}(auto-detected from existing profile)#{reset()}" ) team true -> IO.puts(" #{yellow()}?#{reset()} Team ID — could not auto-detect") IO.puts(" Paid Apple Developer Program ($99/yr):") IO.puts( " #{cyan()}https://developer.apple.com/account#{reset()} → Membership → Team ID" ) IO.puts(" Free tier (Personal Team, no $99):") IO.puts(" Xcode → Settings → Accounts → [your Apple ID] → Team column") team = Mix.shell().prompt(" Enter Team ID:") |> String.trim() unless Regex.match?(Regex.compile!("^[A-Z0-9]{10}$"), team) do Mix.raise( "Invalid Team ID '#{team}' — expected 10 uppercase alphanumeric characters (e.g. Q89CW299G8)" ) end team end end defp team_from_any_profile do profile_dirs = [ Path.expand("~/Library/Developer/Xcode/UserData/Provisioning Profiles"), Path.expand("~/Library/MobileDevice/Provisioning Profiles") ] Enum.flat_map(profile_dirs, &Path.wildcard(Path.join(&1, "*.mobileprovision"))) |> Enum.find_value(fn path -> with {:ok, data} <- File.read(path), {s, _} <- :binary.match(data, "") do xml = binary_part(data, s, e - s + len) case Regex.run( Regex.compile!("TeamIdentifier\\s*\\s*([^<]+)"), xml ) do [_, team] -> String.trim(team) _ -> nil end else _ -> nil end end) end defp check_bundle_id! do bundle_id = MobDev.Config.bundle_id() IO.puts(" #{green()}✓#{reset()} Bundle ID — #{bundle_id}") bundle_id end # ── File generation ─────────────────────────────────────────────────────────── defp generate_xcodeproj(bundle_id, team_id, profile_specifier) do proj_dir = "ios/Provision.xcodeproj" proj_file = Path.join(proj_dir, "project.pbxproj") entitlements = detect_push_entitlements() if entitlements, do: IO.puts(" Push entitlements detected: #{entitlements}") expected = project_pbxproj(bundle_id, team_id, profile_specifier, entitlements) needs_write = case File.read(proj_file) do {:ok, ^expected} -> false # Any drift — wrong bundle/team, different profile specifier # (user downloaded a new profile), missing settings added in # newer mob_dev versions, hand-edits — gets rewritten. Cheap to # do (one local file write) and catches the common "old project # generated by an older mob_dev" trap on upgrade. _ -> true end if needs_write do IO.puts(" Writing ios/Provision.xcodeproj...") File.mkdir_p!(proj_dir) File.write!(proj_file, expected) else IO.puts(" #{green()}✓#{reset()} ios/Provision.xcodeproj — up to date") end end # Look for ios/*.entitlements files that declare aps-environment, indicating # the app wants push notifications. Returns the filename (not full path) of # the first matching file, or nil if none found. defp detect_push_entitlements do "ios/*.entitlements" |> Path.wildcard() |> Enum.find(fn path -> case File.read(path) do {:ok, content} -> String.contains?(content, "aps-environment") _ -> false end end) |> case do nil -> nil path -> Path.basename(path) end end # Discover an existing App Store profile for the bundle ID by parsing # the profiles in ~/Library/Developer/Xcode/UserData/Provisioning Profiles/. # Returns the profile UUID (preferred over name — UUIDs don't change # if the user renames their profile), or nil if none found. # # Reuses MobDev.Release.parse_mobileprovision/1 which already filters # for App Store profiles (no provisioned-devices, no provisions-all-devices). defp discover_dist_profile(bundle_id, team_id) do profile_dirs = [ Path.expand("~/Library/Developer/Xcode/UserData/Provisioning Profiles"), Path.expand("~/Library/MobileDevice/Provisioning Profiles") ] matches = profile_dirs |> Enum.flat_map(&Path.wildcard(Path.join(&1, "*.mobileprovision"))) |> Enum.flat_map(&MobDev.Release.parse_mobileprovision/1) |> Enum.filter(fn p -> # App Store profile (not dev / ad-hoc / enterprise) for our bundle + team. not p.provisioned_devices? and not p.provisions_all_devices? and p.team_id == team_id and (String.ends_with?(p.app_id, ".#{bundle_id}") or String.ends_with?(p.app_id, ".*")) end) case matches do [%{uuid: uuid}] -> IO.puts(" #{green()}✓#{reset()} App Store profile — found locally (UUID #{uuid})") uuid [] -> nil many -> # Prefer exact bundle ID over wildcard. exact = Enum.filter(many, &String.ends_with?(&1.app_id, ".#{bundle_id}")) case exact do [%{uuid: uuid} | _] -> IO.puts(" #{green()}✓#{reset()} App Store profile — found locally (UUID #{uuid})") uuid [] -> %{uuid: uuid} = hd(many) IO.puts( " #{yellow()}?#{reset()} Multiple wildcard profiles match — using first (UUID #{uuid})" ) uuid end end end # Fallback profile-specifier name pattern (used only when no profile is # locally cached yet — first --distribution run before the user has # downloaded anything). Won't match a user-renamed profile, but xcodebuild # will produce a clear "no profile matching..." error in that case which # our diagnose_xcodebuild_failure picks up. defp default_profile_name(bundle_id), do: "iOS Team Store Provisioning Profile: #{bundle_id}" defp generate_swift_stub do path = "ios/MobProvision.swift" if File.exists?(path) do IO.puts(" #{green()}✓#{reset()} ios/MobProvision.swift — already exists") else IO.puts(" Writing ios/MobProvision.swift...") File.write!(path, """ import SwiftUI @main struct MobProvision: App { var body: some Scene { WindowGroup { EmptyView() } } } """) end end # ── xcodebuild ──────────────────────────────────────────────────────────────── @asc_key_id "APP_STORE_CONNECT_KEY_ID" @asc_issuer_id "APP_STORE_CONNECT_ISSUER_ID" @asc_key_path "APP_STORE_CONNECT_API_KEY_PATH" @doc false # App Store Connect API-key auth flags for xcodebuild, from env. Lets an # unattended user (a headless agent / CI) provision without an interactive # Xcode Apple ID account: with the key set, `-allowProvisioningUpdates` # authenticates against App Store Connect directly. Returns `[]` when none of # the vars are set (falls back to the signed-in Xcode account, the default). # Raises on partial config — a half-set key is a mistake worth surfacing, not # a silent fall-back to a different auth path. # # * `APP_STORE_CONNECT_KEY_ID` — the API key's Key ID # * `APP_STORE_CONNECT_ISSUER_ID` — your team's Issuer ID # * `APP_STORE_CONNECT_API_KEY_PATH` — path to the downloaded `AuthKey_.p8` @spec asc_auth_args(map()) :: [String.t()] def asc_auth_args(env) do id = present(env, @asc_key_id) issuer = present(env, @asc_issuer_id) path = present(env, @asc_key_path) cond do is_nil(id) and is_nil(issuer) and is_nil(path) -> [] is_binary(id) and is_binary(issuer) and is_binary(path) -> [ "-authenticationKeyID", id, "-authenticationKeyIssuerID", issuer, "-authenticationKeyPath", path ] true -> set = for {v, k} <- [{id, @asc_key_id}, {issuer, @asc_issuer_id}, {path, @asc_key_path}], v, do: k missing = Enum.reject([@asc_key_id, @asc_issuer_id, @asc_key_path], &(&1 in set)) Mix.raise(""" Incomplete App Store Connect API key config. Set #{Enum.join(set, ", ")} but missing #{Enum.join(missing, ", ")}. Provide all three (#{@asc_key_id}, #{@asc_issuer_id}, #{@asc_key_path}) to provision via an API key, or none to use the signed-in Xcode account. """) end end # nil for an unset OR empty env var, so `APP_STORE_CONNECT_KEY_ID=` counts as absent. defp present(env, key) do case Map.get(env, key) do v when is_binary(v) and v != "" -> v _ -> nil end end # Fail early with a clear message rather than an opaque xcodebuild error when # the key file is missing — the common headless-setup slip. defp verify_asc_key_file!(path) when is_binary(path) and path != "" do unless File.exists?(path) do Mix.raise("#{@asc_key_path} points to a file that does not exist: #{path}") end end defp verify_asc_key_file!(_), do: :ok defp run_xcodebuild!(mode) do # `-scheme MobProvision` rather than `-target MobProvision`: Xcode 16+ # rejects `-archivePath` paired with `-target` ("The flag -scheme is # required when specifying -archivePath but not -exportArchive"). # Both forms work for the build action, so we use scheme for both # to keep the invocation consistent. base = [ "-project", "ios/Provision.xcodeproj", "-scheme", "MobProvision", "-destination", "generic/platform=iOS", "-allowProvisioningUpdates", "-allowProvisioningDeviceRegistration" ] ++ asc_auth_args(System.get_env()) verify_asc_key_file!(System.get_env(@asc_key_path)) args = case mode do :distribution -> # `archive` + Release config triggers Apple to create or refresh # the App Store provisioning profile (and Distribution cert if # missing) under automatic signing. # # Don't override SYMROOT/OBJROOT for archive — Xcode 26's # archive action expects its own DerivedData layout (creates # ArchiveIntermediates/.../BuildProductsPath/SwiftSupport # internally), and pointing OBJROOT to /tmp leaves # BuildProductsPath unwritten and the archive packaging step # fails with "BuildProductsPath couldn't be opened". base ++ [ "-configuration", "Release", "-archivePath", "/tmp/mob_provision_build/Provision.xcarchive", "archive" ] _ -> # Build action is fine with the /tmp scratch dir — only the # archive action has the BuildProductsPath layout requirement. base ++ [ "SYMROOT=/tmp/mob_provision_build", "OBJROOT=/tmp/mob_provision_build", "build" ] end {output, rc} = System.cmd("xcodebuild", args, stderr_to_stdout: true) if rc != 0 do # Show the full xcodebuild output first — keeps Apple's exact error # text visible for google searches and for users comparing notes with # online answers. The targeted hint below it is additive. IO.puts(output) hint = diagnose_xcodebuild_failure(output) Mix.raise(format_xcodebuild_error(rc, hint, args)) end # Print only the summary line on success output |> String.split("\n") |> Enum.filter(&(&1 =~ Regex.compile!("^\\*\\* BUILD (SUCCEEDED|FAILED)"))) |> Enum.each(&IO.puts/1) :ok end # ── xcodebuild error diagnosis ──────────────────────────────────────────── # # Pattern-match against known Apple error strings and return a {label, # snippet, hint} describing the targeted fix. Returns nil for unmatched # errors — the caller falls back to a generic "common causes" message. # # The Apple/xcodebuild text is preserved verbatim in `:snippet` so users # can paste it into a search engine and find existing community # answers — our hint is additive, not a replacement. # # ## URL stability # # Each hint includes a link to Apple's official docs (`developer.apple.com/help/account/...`), # which is Apple's account-management knowledge base — more stable than # blog posts or Developer Forum threads. Apple does occasionally # reorganise these; if a link 404s, search "site:developer.apple.com" # for the section title to find its new home, then update the # `@apple_url_*` module attributes below. The pattern matchers are the # long-term backstop: they catch the error even when the URL goes stale. @apple_url_app_id "https://developer.apple.com/help/account/identifiers/register-an-app-id" @apple_url_signing_cert "https://developer.apple.com/help/account/create-certificates/create-signing-certificates" @apple_url_team_id "https://developer.apple.com/help/account/manage-your-team/locate-your-team-id" @doc false @spec diagnose_xcodebuild_failure(String.t()) :: {label :: String.t(), snippet :: String.t(), hint :: String.t()} | nil def diagnose_xcodebuild_failure(output) do cond do snippet = match_no_store_profile(output) -> {"Distribution profile can't be auto-created for an unregistered App ID", snippet, """ xcodebuild can manage existing profiles via -allowProvisioningUpdates, but it won't register a brand-new bundle ID *and* create the App Store profile in one shot — Apple's distribution flow needs the App ID to exist first. Register the App ID once (1 minute), then re-run mix mob.provision: 1. https://developer.apple.com/account/resources/identifiers/list 2. Click + → App IDs → Continue → App → Continue 3. Description: Bundle ID: select Explicit, paste your bundle id Capabilities: leave defaults 4. Continue → Register Then: mix mob.provision --distribution """} snippet = match_invalid_app_id_name(output) -> {"Apple rejected the auto-generated App ID display name", snippet, """ Apple derives the App ID display name from your bundle ID by prepending "XC " and replacing dots with spaces. The result has to fit Apple's portal validation (~30-char limit, no characters their validator rejects — underscores have been flagged in some years). Fix: shorten the bundle ID's last segment in mob.exs: config :mob_dev, bundle_id: "com.example." Or regenerate with a shorter app name: mix mob.new Apple's App ID rules: #{@apple_url_app_id} """} snippet = match_no_signing_cert(output) -> {"No Apple Development signing certificate", snippet, """ Open Xcode → Settings → Accounts: 1. [+] → add your Apple ID (free at https://appleid.apple.com) 2. select your team → "Manage Certificates" → "+" → Apple Development Then re-run `mix mob.provision`. Apple's signing certificate guide: #{@apple_url_signing_cert} """} snippet = match_no_team(output) -> {"No team available for signing", snippet, """ Set your Team ID in mob.exs: config :mob_dev, ios_team_id: "ABC123XYZ4" Find yours at: Paid ($99/yr): https://developer.apple.com/account → Membership Free (Personal Team): Xcode → Settings → Accounts → [your Apple ID] → Team column Apple's "locate your Team ID" guide: #{@apple_url_team_id} """} snippet = match_app_id_quota(output) -> {"Free-tier App ID limit (3 per 7 days) hit", snippet, """ Apple caps Personal Team accounts at 3 distinct bundle IDs registered per rolling 7-day window. Either wait it out, or reuse a bundle ID Xcode already provisioned for you by setting it explicitly in mob.exs: config :mob_dev, bundle_id: "com.example." Apple's App ID registration page (mentions registration limits): #{@apple_url_app_id} """} snippet = match_bundle_id_taken(output) -> {"Bundle ID belongs to a different team", snippet, """ Apple won't let two teams own the same App ID. Pick a unique bundle ID — for personal projects, append your initials or a random suffix: config :mob_dev, bundle_id: "com.example.." Or change MOB_BUNDLE_PREFIX away from the conflicting reverse-DNS. Apple's App ID rules (uniqueness across teams): #{@apple_url_app_id} """} true -> nil end end # Each match_* helper returns the verbatim snippet from xcodebuild output # if the pattern is present, else nil. Keeping the snippet in the user's # output (rather than rephrasing) keeps it google-searchable. defp match_no_store_profile(output) do grep_first(output, "iOS Team Store Provisioning Profile") && grep_first(output, "No profile for team") end defp match_invalid_app_id_name(output) do grep_first(output, "The attribute 'name' is invalid") end defp match_no_signing_cert(output) do grep_first(output, "No signing certificate") || grep_first(output, "no Apple Development cert") || grep_first(output, "requires a development team") end defp match_no_team(output) do grep_first(output, "no eligible accounts") || grep_first(output, "doesn't include any iOS App Development") || grep_first(output, "No development team") end defp match_app_id_quota(output) do grep_first(output, "There are too many App IDs") || grep_first(output, "maximum allowed number of App IDs") || grep_first(output, "Maximum App IDs Reached") end defp match_bundle_id_taken(output) do grep_first(output, "Failed to register bundle identifier") || grep_first(output, "An App ID with Identifier") || grep_first(output, "is not available. Please enter a different string") end # First line of `output` containing `needle`, or nil. defp grep_first(output, needle) do output |> String.split("\n") |> Enum.find(&String.contains?(&1, needle)) |> case do nil -> nil line -> String.trim(line) end end defp format_xcodebuild_error(rc, nil, args) do """ xcodebuild provisioning failed (exit #{rc}). Common causes: - Xcode not signed in: open Xcode → Settings → Accounts → add Apple ID - Bundle ID registered to a different team - No internet connection (provisioning contacts Apple's servers) - Free-tier Apple ID hit the 3-App-IDs-per-7-days limit The full xcodebuild output is above; search any error line you don't recognise — Apple's text is fairly distinctive and there's almost always a Stack Overflow / forum hit for it. To debug, run manually from #{File.cwd!()}: xcodebuild #{Enum.join(args, " ")} """ end defp format_xcodebuild_error(rc, {label, snippet, hint}, _args) do """ xcodebuild provisioning failed (exit #{rc}). #{IO.ANSI.bright()}#{label}#{IO.ANSI.reset()} Apple's exact error (paste this into a search engine for community answers): #{snippet} #{hint} """ end defp verify_profile!(bundle_id, mode) do profile_dirs = [ Path.expand("~/Library/Developer/Xcode/UserData/Provisioning Profiles"), Path.expand("~/Library/MobileDevice/Provisioning Profiles") ] matching = Enum.flat_map(profile_dirs, &Path.wildcard(Path.join(&1, "*.mobileprovision"))) |> Enum.filter(fn path -> case File.read(path) do {:ok, data} -> bundle_match = String.contains?(data, bundle_id) or Regex.match?( Regex.compile!( "application-identifier\\s*[^<]+\\.\\*" ), data ) mode_match = case mode do :distribution -> # App Store profiles have no ProvisionedDevices array not String.contains?(data, "ProvisionedDevices") and not String.contains?(data, "ProvisionsAllDevices") _ -> # Development profiles list ProvisionedDevices String.contains?(data, "ProvisionedDevices") end bundle_match and mode_match _ -> false end end) if matching != [] do label = if mode == :distribution, do: "App Store", else: "development" IO.puts(" #{green()}✓#{reset()} #{label} provisioning profile ready") else IO.puts( " #{yellow()}⚠#{reset()} Profile not found — re-run `mix mob.provision#{if mode == :distribution, do: " --distribution", else: ""}` if needed" ) end end # ── project.pbxproj template ────────────────────────────────────────────────── # # Release config note: Manual signing + explicit Apple Distribution # identity is the Apple-blessed pattern when the team already has a # wildcard Apple Development profile (most do). Under automatic # signing, Xcode greedily picks that wildcard profile (it satisfies # the bundle ID), never enters distribution mode, and then refuses # any manual identity override with "conflicting provisioning # settings". Manual + `-allowProvisioningUpdates` tells xcodebuild # "fetch (and create at Apple if needed) the App Store profile for # this bundle ID" — which is exactly what we want for the one-shot # provision flow. # # A minimal Xcode project with a single Swift target. The UUIDs are fixed (they # only need to be unique within this file). MobProvision.swift is referenced # relative to the ios/ directory (the directory containing Provision.xcodeproj). defp project_pbxproj(bundle_id, team_id, profile_specifier, entitlements_file) when is_binary(profile_specifier) do entitlements_ref = if entitlements_file do "\t\tAA00000F /* #{entitlements_file} */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = #{entitlements_file}; sourceTree = \"\"; };\n" else "" end entitlements_group_entry = if entitlements_file do "\t\t\t\tAA00000F /* #{entitlements_file} */,\n" else "" end target_attributes = if entitlements_file do "\t\t\t\tTargetAttributes = {\n\t\t\t\t\tAA000006 = {\n\t\t\t\t\t\tSystemCapabilities = {\n\t\t\t\t\t\t\t\"com.apple.Push\" = {\n\t\t\t\t\t\t\t\tenabled = 1;\n\t\t\t\t\t\t\t};\n\t\t\t\t\t\t};\n\t\t\t\t\t};\n\t\t\t\t};\n" else "" end entitlements_setting = if entitlements_file do "\t\t\t\tCODE_SIGN_ENTITLEMENTS = #{entitlements_file};\n" else "" end """ // !$*UTF8*$! { \tarchiveVersion = 1; \tclasses = { \t}; \tobjectVersion = 77; \tobjects = { /* Begin PBXBuildFile section */ \t\tAA000001 /* MobProvision.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002 /* MobProvision.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ \t\tAA000002 /* MobProvision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobProvision.swift; sourceTree = ""; }; \t\tAA000003 /* MobProvision.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MobProvision.app; sourceTree = BUILT_PRODUCTS_DIR; }; #{entitlements_ref}/* End PBXFileReference section */ /* Begin PBXGroup section */ \t\tAA000004 = { \t\t\tisa = PBXGroup; \t\t\tchildren = ( \t\t\t\tAA000002 /* MobProvision.swift */, #{entitlements_group_entry}\t\t\t\tAA000005 /* Products */, \t\t\t); \t\t\tsourceTree = ""; \t\t}; \t\tAA000005 /* Products */ = { \t\t\tisa = PBXGroup; \t\t\tchildren = ( \t\t\t\tAA000003 /* MobProvision.app */, \t\t\t); \t\t\tname = Products; \t\t\tsourceTree = ""; \t\t}; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ \t\tAA000006 /* MobProvision */ = { \t\t\tisa = PBXNativeTarget; \t\t\tbuildConfigurationList = AA000007 /* Build configuration list for PBXNativeTarget "MobProvision" */; \t\t\tbuildPhases = ( \t\t\t\tAA000008 /* Sources */, \t\t\t); \t\t\tbuildRules = ( \t\t\t); \t\t\tdependencies = ( \t\t\t); \t\t\tname = MobProvision; \t\t\tproductName = MobProvision; \t\t\tproductReference = AA000003 /* MobProvision.app */; \t\t\tproductType = "com.apple.product-type.application"; \t\t}; /* End PBXNativeTarget section */ /* Begin PBXProject section */ \t\tAA000009 /* Project object */ = { \t\t\tisa = PBXProject; \t\t\tattributes = { \t\t\t\tBuildIndependentTargetsInParallel = YES; \t\t\t\tLastUpgradeCheck = 1600; #{target_attributes}\t\t\t}; \t\t\tbuildConfigurationList = AA00000A /* Build configuration list for PBXProject "Provision" */; \t\t\tdevelopmentRegion = en; \t\t\thasScannedForEncodings = 0; \t\t\tknownRegions = ( \t\t\t\tBase, \t\t\t\ten, \t\t\t); \t\t\tmainGroup = AA000004; \t\t\tproductRefGroup = AA000005 /* Products */; \t\t\tprojectDirPath = ""; \t\t\tprojectRoot = ""; \t\t\ttargets = ( \t\t\t\tAA000006 /* MobProvision */, \t\t\t); \t\t}; /* End PBXProject section */ /* Begin PBXSourcesBuildPhase section */ \t\tAA000008 /* Sources */ = { \t\t\tisa = PBXSourcesBuildPhase; \t\t\tbuildActionMask = 2147483647; \t\t\tfiles = ( \t\t\t\tAA000001 /* MobProvision.swift in Sources */, \t\t\t); \t\t\trunOnlyForDeploymentPostprocessing = 0; \t\t}; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ \t\tAA00000B /* Debug */ = { \t\t\tisa = XCBuildConfiguration; \t\t\tbuildSettings = { \t\t\t\tCODE_SIGN_STYLE = Automatic; #{entitlements_setting}\t\t\t\tDEVELOPMENT_TEAM = #{team_id}; \t\t\t\tGENERATE_INFOPLIST_FILE = YES; \t\t\t\tINFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; \t\t\t\tINFOPLIST_KEY_UILaunchScreen_Generation = YES; \t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0; \t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = #{bundle_id}; \t\t\t\tPRODUCT_NAME = MobProvision; \t\t\t\tSDKROOT = iphoneos; \t\t\t\tSWIFT_VERSION = 5.9; \t\t\t\tTARGETED_DEVICE_FAMILY = "1,2"; \t\t\t}; \t\t\tname = Debug; \t\t}; \t\tAA00000C /* Release */ = { \t\t\tisa = XCBuildConfiguration; \t\t\tbuildSettings = { \t\t\t\tCODE_SIGN_STYLE = Manual; \t\t\t\tCODE_SIGN_IDENTITY = "Apple Distribution"; \t\t\t\t"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution"; #{entitlements_setting}\t\t\t\tDEVELOPMENT_TEAM = #{team_id}; \t\t\t\tPROVISIONING_PROFILE_SPECIFIER = "#{profile_specifier}"; \t\t\t\tGENERATE_INFOPLIST_FILE = YES; \t\t\t\tINFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; \t\t\t\tINFOPLIST_KEY_UILaunchScreen_Generation = YES; \t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0; \t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = #{bundle_id}; \t\t\t\tPRODUCT_NAME = MobProvision; \t\t\t\tSDKROOT = iphoneos; \t\t\t\tSWIFT_VERSION = 5.9; \t\t\t\tTARGETED_DEVICE_FAMILY = "1,2"; \t\t\t}; \t\t\tname = Release; \t\t}; \t\tAA00000D /* Debug */ = { \t\t\tisa = XCBuildConfiguration; \t\t\tbuildSettings = { \t\t\t\tALWAYS_SEARCH_USER_PATHS = NO; \t\t\t\tSDKROOT = iphoneos; \t\t\t}; \t\t\tname = Debug; \t\t}; \t\tAA00000E /* Release */ = { \t\t\tisa = XCBuildConfiguration; \t\t\tbuildSettings = { \t\t\t\tALWAYS_SEARCH_USER_PATHS = NO; \t\t\t\tSDKROOT = iphoneos; \t\t\t}; \t\t\tname = Release; \t\t}; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ \t\tAA000007 /* Build configuration list for PBXNativeTarget "MobProvision" */ = { \t\t\tisa = XCConfigurationList; \t\t\tbuildConfigurations = ( \t\t\t\tAA00000B /* Debug */, \t\t\t\tAA00000C /* Release */, \t\t\t); \t\t\tdefaultConfigurationIsVisible = 0; \t\t\tdefaultConfigurationName = Debug; \t\t}; \t\tAA00000A /* Build configuration list for PBXProject "Provision" */ = { \t\t\tisa = XCConfigurationList; \t\t\tbuildConfigurations = ( \t\t\t\tAA00000D /* Debug */, \t\t\t\tAA00000E /* Release */, \t\t\t); \t\t\tdefaultConfigurationIsVisible = 0; \t\t\tdefaultConfigurationName = Debug; \t\t}; /* End XCConfigurationList section */ \t}; \trootObject = AA000009 /* Project object */; } """ end # ── ANSI helpers ────────────────────────────────────────────────────────────── defp macos?, do: match?({:unix, :darwin}, :os.type()) defp green, do: IO.ANSI.green() defp yellow, do: IO.ANSI.yellow() defp cyan, do: IO.ANSI.cyan() defp bright, do: IO.ANSI.bright() defp faint, do: IO.ANSI.faint() defp reset, do: IO.ANSI.reset() end