diff --git a/AGENTS.md b/AGENTS.md index 68e821a..04bea7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,15 @@ mix test --exclude integration # skip the device-dependent ones `narrow_platforms_for_device/2` and is the single source of truth for both build and deploy. Bypass it and you'll get either spurious "No device matched" warnings (deploy) or builds for the wrong platform (build). +- **Deployment BEAM discovery follows Mix's active paths.** Use + `Mix.Project.build_path/0` for dependency output and + `Mix.Project.compile_path/0` for every application BEAM, including modules + compiled from `erlc_paths`. Hard-coding `_build/dev` can push a stale, + incomplete override that shadows the complete application bundle. +- **Physical iOS BEAM overrides must be exact and self-verifying.** The app + prefers `Documents/otp/` over its complete signed bundle. Replace that + directory rather than incrementally merging it, require `.beam` before + transfer, and verify the received bootstrap bytes before restarting. - **`xcodebuild` errors get rewritten** to actionable hints by `diagnose_xcodebuild_failure/1` in `mob.provision`. Apple's verbatim text is preserved alongside our hint so the snippet stays google-able. Add new diff --git a/CHANGELOG.md b/CHANGELOG.md index e87c62b..d8915a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,36 @@ Full module documentation: [hexdocs.pm/mob_dev](https://hexdocs.pm/mob_dev). ## [Unreleased] +### Fixed +- **`mix mob.deploy` no longer drops dependencies reachable only through + `extra_applications`.** The runtime filter stopped consulting the project's + own `.app` file, so a dep declared `runtime: false` and opted back in via + `extra_applications:` — the documented idiom — was silently never pushed. The + app booted and died with `undef` on first use. The project's `.app` is + traversed again, with dev-only deps subtracted afterwards rather than by + skipping the traversal. +- BEAM discovery follows `Mix.Project.build_path/0` and `compile_path/0` instead + of a hardcoded `_build/dev`, so a non-dev `MIX_ENV` or a custom `:build_path` + is honoured. Projects using `build_per_environment: false` previously pushed + no dependency BEAMs at all. +- `mix mob.deploy` from an umbrella root now reports that mob does not support + umbrellas instead of leaking a raw `Mix.Project.app_path/1` error. +- A physical-iOS deploy targeting a WiFi-only device returns a per-device error + instead of throwing past the run and aborting every other device's summary. +- iOS staging directories are PID-qualified, so two concurrent deploys cannot + delete each other's staging mid-copy. + +### Changed +- **Physical iOS overrides are now replaced, not merged** + (`devicectl --remove-existing-content`), and the transfer is verified before + the app is restarted. Note the trade-off: because `mob_beam.m` prefers + `Documents/otp/` on directory existence alone, a transfer interrupted + after the copy begins leaves an incomplete override that the next launch will + still prefer over the signed bundle. There is no rollback — `devicectl` has no + delete verb, and an empty directory is still preferred — so the deploy now + says so explicitly and names the recoveries (re-run the deploy, or reinstall). + + ### Added - **`mix mob.doctor` warns when a project still carries the pre-MOB-104 sheet dismissal wiring.** An app generated before the fix routes diff --git a/android/app/src/main/assets/mob_logo_dark.png b/android/app/src/main/assets/mob_logo_dark.png new file mode 100644 index 0000000..72ad18b Binary files /dev/null and b/android/app/src/main/assets/mob_logo_dark.png differ diff --git a/android/app/src/main/assets/mob_logo_light.png b/android/app/src/main/assets/mob_logo_light.png new file mode 100644 index 0000000..f60717c Binary files /dev/null and b/android/app/src/main/assets/mob_logo_light.png differ diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..637ad96 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android/app/src/main/res/xml/file_provider_paths.xml b/android/app/src/main/res/xml/file_provider_paths.xml new file mode 100644 index 0000000..85df8c5 --- /dev/null +++ b/android/app/src/main/res/xml/file_provider_paths.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..f3d7653 --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,8 @@ + + + + + 127.0.0.1 + localhost + + diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..033e24c Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..9f4197d --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..fcb6fca --- /dev/null +++ b/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/lib/mob_dev/deployer.ex b/lib/mob_dev/deployer.ex index 80d2c78..6ab3f24 100644 --- a/lib/mob_dev/deployer.ex +++ b/lib/mob_dev/deployer.ex @@ -1,6 +1,6 @@ defmodule MobDev.Deployer do @moduledoc """ - Pushes compiled BEAM files from `_build/dev/lib/*/ebin/` to connected devices. + Pushes compiled BEAM files from the active Mix build path to connected devices. Does NOT rebuild APKs or recompile native code — that's `deploy.sh` (first-time setup). Use this for day-to-day code iteration: edit Elixir → `mix mob.deploy` → code running. @@ -963,27 +963,149 @@ defmodule MobDev.Deployer do # # The merged staging dir is named so that devicectl's directory-copy # semantics land the files at Documents/otp// on device. + @doc false + @spec validate_ios_override(String.t(), String.t(), String.t()) :: + :ok | {:error, String.t()} + def validate_ios_override(compile_path, staging_dir, app) do + active_bootstrap = Path.join(compile_path, "#{app}.beam") + staged_bootstrap = Path.join(staging_dir, "#{app}.beam") + + case File.stat(active_bootstrap) do + {:ok, %{type: :regular, size: size}} when size > 0 -> + cond do + not File.regular?(staged_bootstrap) -> + # Absence, not mismatch. Wrapping the enoent inside "does not match + # active compile output" pointed the user at the wrong file. + {:error, + "staged iOS override is missing the application bootstrap " <> + "(#{Path.basename(staged_bootstrap)}) — the staging copy dropped it"} + + true -> + case verify_ios_bootstrap(active_bootstrap, staged_bootstrap) do + :ok -> + :ok + + {:error, reason} -> + {:error, "iOS override does not match active compile output: #{reason}"} + end + end + + _ -> + {:error, + "active Mix compile output missing required application bootstrap: #{active_bootstrap}"} + end + end + + @doc false + @spec ios_override_copy_args(String.t(), String.t(), String.t(), String.t()) :: [String.t()] + def ios_override_copy_args(udid, bundle, staging_dir, app) do + [ + "devicectl", + "device", + "copy", + "to", + "--device", + udid, + "--domain-type", + "appDataContainer", + "--domain-identifier", + bundle, + "--source", + staging_dir, + "--destination", + "Documents/otp/#{app}", + "--remove-existing-content", + "true" + ] + end + + @doc false + @spec ios_override_verify_args(String.t(), String.t(), String.t(), String.t()) :: [String.t()] + def ios_override_verify_args(udid, bundle, destination, app) do + [ + "devicectl", + "device", + "copy", + "from", + "--device", + udid, + "--domain-type", + "appDataContainer", + "--domain-identifier", + bundle, + "--source", + "Documents/otp/#{app}/#{app}.beam", + "--destination", + destination + ] + end + + @doc false + @spec verify_ios_bootstrap(String.t(), String.t()) :: :ok | {:error, String.t()} + def verify_ios_bootstrap(expected, actual) do + with {:ok, expected_binary} <- File.read(expected), + {:ok, ^expected_binary} <- File.read(actual) do + :ok + else + {:ok, _other} -> {:error, "physical iOS bootstrap verification mismatch"} + {:error, reason} -> {:error, "physical iOS bootstrap verification failed: #{reason}"} + end + end + defp deploy_ios_physical(%Device{serial: udid} = device, beam_dirs, opts) do restart = Keyword.get(opts, :restart, true) beam_flags = Keyword.get(opts, :beam_flags, nil) bundle = ios_bundle_id() app = app_name() + compile_path = Mix.Project.compile_path() + active_bootstrap = Path.join(compile_path, "#{app}.beam") # When discovered via WiFi-only EPMD scan the serial is the IP address, which # xcrun devicectl does not accept as a --device argument. Resolve to a hardware # UDID before proceeding. udid = resolve_ios_udid_if_ip(udid) - if Regex.match?(Regex.compile!("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$"), udid) do - throw( - {:error, - "device only reachable via WiFi (#{udid}) — use `mix mob.push` for BEAM-only updates, or connect via USB for a native deploy"} - ) + # Returned, not thrown: this sits OUTSIDE the try/catch below, so a throw + # here escapes deploy_ios/3 and deploy_all/1 (neither catches) and aborts + # the whole run with a raw ** (throw) — losing the summary for devices that + # already succeeded, instead of marking just this one failed. + wifi_only? = + Regex.match?(Regex.compile!("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$"), udid) + + if wifi_only? do + {:error, + "device only reachable via WiFi (#{udid}) — use `mix mob.push` for BEAM-only updates, or connect via USB for a native deploy"} + else + do_deploy_ios_physical(device, beam_dirs, %{ + udid: udid, + bundle: bundle, + app: app, + compile_path: compile_path, + active_bootstrap: active_bootstrap, + restart: restart, + beam_flags: beam_flags + }) end + end + defp do_deploy_ios_physical(device, beam_dirs, %{ + udid: udid, + bundle: bundle, + app: app, + compile_path: compile_path, + active_bootstrap: active_bootstrap, + restart: restart, + beam_flags: beam_flags + }) do # Stage all BEAMs (and priv/) into a temp dir named . + # PID-qualified: unique_integer restarts low in a fresh VM, so two + # concurrent `mix mob.deploy` runs could previously collide and one's + # `after` cleanup would delete the other's staging mid-copy. staging_parent = - Path.join(System.tmp_dir!(), "mob_ios_deploy_#{:erlang.unique_integer([:positive])}") + Path.join( + System.tmp_dir!(), + "mob_ios_deploy_#{:os.getpid()}_#{:erlang.unique_integer([:positive])}" + ) staging_dir = Path.join(staging_parent, app) File.mkdir_p!(staging_dir) @@ -1016,27 +1138,25 @@ defmodule MobDev.Deployer do File.write!(Path.join(staging_dir, "mob_beam_flags"), beam_flags) end + case validate_ios_override(compile_path, staging_dir, app) do + :ok -> :ok + {:error, reason} -> throw({:error, reason}) + end + # devicectl copies the contents of --source into --destination. # To land BEAMs at Documents/otp//, the destination must include # the app subdirectory explicitly (staging_dir naming alone is not enough). + # From here the on-device override is being REPLACED, not merged + # (--remove-existing-content). Any failure past this point leaves it in + # an unknown state, and mob_beam.m prefers Documents/otp/ on mere + # directory existence — so there is no falling back to the signed + # bundle, and `xcrun devicectl device` has no delete verb to undo it. + # Record that we crossed the line so the error can say so. + Process.put(:mob_ios_override_replaced, true) + case System.cmd( "xcrun", - [ - "devicectl", - "device", - "copy", - "to", - "--device", - udid, - "--domain-type", - "appDataContainer", - "--domain-identifier", - bundle, - "--source", - staging_dir, - "--destination", - "Documents/otp/#{app}" - ], + ios_override_copy_args(udid, bundle, staging_dir, app), stderr_to_stdout: true ) do {_, 0} -> @@ -1067,16 +1187,60 @@ defmodule MobDev.Deployer do throw({:error, reason}) end + received_bootstrap = Path.join(staging_parent, "received_#{app}.beam") + + case System.cmd( + "xcrun", + ios_override_verify_args(udid, bundle, received_bootstrap, app), + stderr_to_stdout: true + ) do + {_, 0} -> + case verify_ios_bootstrap(active_bootstrap, received_bootstrap) do + :ok -> :ok + {:error, reason} -> throw({:error, reason}) + end + + {out, _} -> + throw({:error, "physical iOS bootstrap verification failed: #{out}"}) + end + if restart, do: IOS.restart_app_physical(udid, bundle) + Process.delete(:mob_ios_override_replaced) {:ok, device} catch - {:error, reason} -> {:error, reason} + {:error, reason} -> + {:error, annotate_override_state(reason, app)} after + Process.delete(:mob_ios_override_replaced) File.rm_rf!(staging_parent) end end + # If the destructive copy had already begun, the device is now in a worse + # state than we found it: Documents/otp/ exists but is incomplete, and + # mob_beam.m selects that override on directory existence alone. The app will + # boot from it — and fail — on the next launch from Springboard, even though + # we correctly skipped the restart. + # + # There is no clean rollback. `xcrun devicectl device` has no delete verb, and + # copying an EMPTY directory does not help: the directory still exists, so it + # is still preferred, and the app would then have no BEAMs at all. So say so + # plainly and give the two recoveries that do work. + defp annotate_override_state(reason, app) do + if Process.get(:mob_ios_override_replaced) do + reason <> + "\n\nThe on-device override at Documents/otp/#{app} was already being " <> + "replaced when this failed, so it is now incomplete. The app prefers that " <> + "directory over its signed bundle, so the next launch will fail even " <> + "though it was not restarted.\n" <> + "Recover with a successful `mix mob.deploy` (or `--native`), or delete " <> + "and reinstall the app." + else + reason + end + end + # ── iOS WiFi UDID resolution ────────────────────────────────────────────────── # When a physical device was discovered only via LAN EPMD scan (no USB), its diff --git a/lib/mob_dev/hot_push.ex b/lib/mob_dev/hot_push.ex index f9de282..0a6192d 100644 --- a/lib/mob_dev/hot_push.ex +++ b/lib/mob_dev/hot_push.ex @@ -44,7 +44,7 @@ defmodule MobDev.HotPush do end @doc """ - Pushes all compiled BEAM files from `_build/dev/lib/*/ebin/` to `nodes`. + Pushes all compiled BEAM files from the active Mix build path to `nodes`. Only pushes BEAMs for runtime dependencies — deps marked `only: :dev` or `runtime: false` in `mix.exs` (and their transitive deps) are excluded. @@ -106,12 +106,14 @@ defmodule MobDev.HotPush do # their transitive deps (resolved via OTP .app files). defp runtime_beam_paths do runtime = runtime_lib_names() + project_app = to_string(Mix.Project.config()[:app]) - Path.wildcard("_build/dev/lib/*/ebin/*.beam") - |> Enum.filter(fn path -> - lib = path |> Path.split() |> Enum.at(-3) - MapSet.member?(runtime, lib) - end) + select_runtime_beam_paths( + Mix.Project.build_path(), + active_compile_path(), + runtime, + project_app + ) end @doc """ @@ -121,39 +123,147 @@ defmodule MobDev.HotPush do @spec runtime_beam_dirs() :: [String.t()] def runtime_beam_dirs do runtime = runtime_lib_names() + project_app = to_string(Mix.Project.config()[:app]) - case File.ls("_build/dev/lib") do - {:ok, libs} -> - libs - |> Enum.filter(&MapSet.member?(runtime, &1)) - |> Enum.map(&"_build/dev/lib/#{&1}/ebin") - |> Enum.filter(&File.dir?/1) + select_runtime_beam_dirs( + Mix.Project.build_path(), + active_compile_path(), + runtime, + project_app + ) + end - {:error, _} -> - [] + @doc false + @spec select_runtime_beam_paths(String.t(), String.t(), MapSet.t(String.t()), String.t()) :: + [String.t()] + def select_runtime_beam_paths(build_path, compile_path, runtime, project_app) do + build_path + |> select_runtime_beam_dirs(compile_path, runtime, project_app) + |> Enum.flat_map(&beam_files/1) + end + + @doc false + @spec select_runtime_beam_dirs(String.t(), String.t(), MapSet.t(String.t()), String.t()) :: + [String.t()] + def select_runtime_beam_dirs(build_path, compile_path, runtime, project_app) do + lib_path = Path.join(build_path, "lib") + + runtime_dirs = + case File.ls(lib_path) do + {:ok, libs} -> + libs + |> Enum.filter(&(MapSet.member?(runtime, &1) and &1 != project_app)) + |> Enum.map(&Path.join([lib_path, &1, "ebin"])) + |> Enum.filter(&File.dir?/1) + + {:error, _} -> + [] + end + + dependency_dirs = Enum.sort(runtime_dirs) + + if File.dir?(compile_path), do: dependency_dirs ++ [compile_path], else: dependency_dirs + end + + # Mix.Project.compile_path/0 RAISES for an umbrella root ("umbrellas have no + # app"), where the previous hardcoded _build/dev wildcard just returned + # nothing. mob does not support umbrellas (MobDev.AdoptGuard refuses them + # outright), so fail with that message rather than leaking Mix's internal + # error out of a deploy. + defp active_compile_path do + if Mix.Project.umbrella?() do + Mix.raise( + "mob does not support umbrella applications — run mix mob.deploy from a " <> + "child app, not the umbrella root" + ) end + + Mix.Project.compile_path() end + @doc false + # Test seam. runtime_lib_names/0 reads Mix.Project.config() and is the + # function that actually decides what gets pushed, but being private it had + # no coverage — the tests all hand-built the MapSet it produces and so could + # not catch a regression in it. Exposed so a fixture project can drive it. + @spec __runtime_lib_names__() :: MapSet.t(String.t()) + def __runtime_lib_names__, do: runtime_lib_names() + defp runtime_lib_names do - project_app = to_string(Mix.Project.config()[:app]) + config = Mix.Project.config() + project_app = to_string(config[:app]) # Direct runtime deps: no only: :dev and not runtime: false direct = - Mix.Project.config() + config |> Keyword.get(:deps, []) |> Enum.flat_map(&dep_runtime_name/1) |> MapSet.new() + + # Seed with the project itself so its OWN .app is traversed. A dependency + # can be reachable only through the project's `applications:` list — the + # documented `runtime: false` + `extra_applications:` idiom, where + # extra_applications deliberately overrides the runtime: false flag. Drop + # the seed and those libs are never pushed; the app boots and dies with + # undef on first use, which is the same failure class this module exists + # to avoid. + # + # The reason the seed was previously removed is real though: the project's + # .app also lists `only: :dev` deps under MIX_ENV=dev, which leaked them + # into the runtime set and contradicted this module's docs. So expand + # first, then subtract the deps we know are dev-only or runtime: false. + # project_app itself is excluded at the directory-selection step + # (select_runtime_beam_dirs/4), where the stale-output concern lives. + expanded = + direct |> MapSet.put(project_app) + |> expand_runtime_libs(Mix.Project.build_path()) + + MapSet.difference(expanded, non_runtime_dep_names(config)) + end - expand_runtime_libs(direct) + # Names of direct deps explicitly marked `only: :dev`/`:test` or + # `runtime: false`, EXCEPT any the project re-declares in + # `extra_applications` — that combination is how a build-time dep is opted + # back into the runtime application list, and it must survive the subtraction. + defp non_runtime_dep_names(config) do + # extra_applications lives on the project module's application/0 callback, + # NOT in Mix.Project.config/0 — reading it from config silently yields [] + # and subtracts the very libs this is meant to keep. + kept = + case Mix.Project.get() do + nil -> + MapSet.new() + + module -> + if function_exported?(module, :application, 0) do + module.application() + |> Keyword.get(:extra_applications, []) + |> Enum.map(&to_string/1) + |> MapSet.new() + else + MapSet.new() + end + end + + config + |> Keyword.get(:deps, []) + |> Enum.flat_map(fn dep -> + case dep_runtime_name(dep) do + [] -> [to_string(elem(dep, 0))] + _ -> [] + end + end) + |> MapSet.new() + |> MapSet.difference(kept) end # Expand a set of lib names to include their transitive OTP deps, - # by reading each lib's .app file in _build/dev. - defp expand_runtime_libs(libs) do + # by reading each lib's .app file in the active Mix build path. + defp expand_runtime_libs(libs, build_path) do new_libs = Enum.flat_map(libs, fn lib -> - case Path.wildcard("_build/dev/lib/#{lib}/ebin/*.app") do + case app_files(Path.join([build_path, "lib", lib, "ebin"])) do [app_file | _] -> case :file.consult(String.to_charlist(app_file)) do {:ok, [{:application, _app, props}]} -> @@ -173,7 +283,33 @@ defmodule MobDev.HotPush do if MapSet.size(new_libs) == 0 do libs else - expand_runtime_libs(MapSet.union(libs, new_libs)) + expand_runtime_libs(MapSet.union(libs, new_libs), build_path) + end + end + + defp beam_files(dir) do + case File.ls(dir) do + {:ok, files} -> + files + |> Enum.filter(&String.ends_with?(&1, ".beam")) + |> Enum.sort() + |> Enum.map(&Path.join(dir, &1)) + + {:error, _} -> + [] + end + end + + defp app_files(dir) do + case File.ls(dir) do + {:ok, files} -> + files + |> Enum.filter(&String.ends_with?(&1, ".app")) + |> Enum.sort() + |> Enum.map(&Path.join(dir, &1)) + + {:error, _} -> + [] end end diff --git a/test/mob_dev/deployer_test.exs b/test/mob_dev/deployer_test.exs index 3af517d..cc50102 100644 --- a/test/mob_dev/deployer_test.exs +++ b/test/mob_dev/deployer_test.exs @@ -3,6 +3,117 @@ defmodule MobDev.DeployerTest do alias MobDev.Deployer + describe "physical iOS override" do + test "requires staged bootstrap bytes to match the active compile output" do + root = Path.join(System.tmp_dir!(), "mob_ios_override_#{System.unique_integer()}") + compile_path = Path.join(root, "compile") + staging_dir = Path.join(root, "staging") + File.mkdir_p!(compile_path) + File.mkdir_p!(staging_dir) + on_exit(fn -> File.rm_rf!(root) end) + + assert {:error, message} = + Deployer.validate_ios_override(compile_path, staging_dir, "sample_app") + + assert message =~ "active Mix compile output" + assert message =~ Path.join(compile_path, "sample_app.beam") + + File.write!(Path.join(compile_path, "sample_app.beam"), "current bootstrap") + + # Staged file ABSENT is a different fault from staged file DIFFERENT, and + # must say so — wrapping the enoent in "does not match active compile + # output" sent the reader to the wrong file. + assert {:error, message} = + Deployer.validate_ios_override(compile_path, staging_dir, "sample_app") + + assert message =~ "missing the application bootstrap" + assert message =~ "sample_app.beam" + refute message =~ "does not match active compile output" + + File.write!(Path.join(staging_dir, "sample_app.beam"), "stale dependency bootstrap") + + assert {:error, message} = + Deployer.validate_ios_override(compile_path, staging_dir, "sample_app") + + assert message =~ "does not match active compile output" + assert message =~ "verification mismatch" + + File.write!(Path.join(staging_dir, "sample_app.beam"), "current bootstrap") + + assert :ok = + Deployer.validate_ios_override(compile_path, staging_dir, "sample_app") + end + + test "copy command replaces the exact override directory" do + args = Deployer.ios_override_copy_args("device-id", "com.example.app", "/tmp/app", "app") + + assert args == [ + "devicectl", + "device", + "copy", + "to", + "--device", + "device-id", + "--domain-type", + "appDataContainer", + "--domain-identifier", + "com.example.app", + "--source", + "/tmp/app", + "--destination", + "Documents/otp/app", + "--remove-existing-content", + "true" + ] + end + + test "verification command retrieves the exact remote bootstrap" do + assert Deployer.ios_override_verify_args( + "device-id", + "com.example.app", + "/tmp/received.beam", + "app" + ) == [ + "devicectl", + "device", + "copy", + "from", + "--device", + "device-id", + "--domain-type", + "appDataContainer", + "--domain-identifier", + "com.example.app", + "--source", + "Documents/otp/app/app.beam", + "--destination", + "/tmp/received.beam" + ] + end + + test "remote bootstrap must match staged bytes" do + dir = Path.join(System.tmp_dir!(), "mob_ios_verify_#{System.unique_integer()}") + staged = Path.join(dir, "staged.beam") + received = Path.join(dir, "received.beam") + File.mkdir_p!(dir) + File.write!(staged, "same beam") + File.write!(received, "same beam") + on_exit(fn -> File.rm_rf!(dir) end) + + assert :ok = Deployer.verify_ios_bootstrap(staged, received) + + File.write!(received, "different beam") + + assert {:error, "physical iOS bootstrap verification mismatch"} = + Deployer.verify_ios_bootstrap(staged, received) + + File.rm!(received) + + assert {:error, "physical iOS bootstrap verification failed: enoent"} = + Deployer.verify_ios_bootstrap(staged, received) + end + end + # ── generate_crypto_shim/0 ──────────────────────────────────────────────── describe "generate_crypto_shim/0" do diff --git a/test/mob_dev/hot_push_test.exs b/test/mob_dev/hot_push_test.exs index ee9f3b2..fa52466 100644 --- a/test/mob_dev/hot_push_test.exs +++ b/test/mob_dev/hot_push_test.exs @@ -3,6 +3,105 @@ defmodule MobDev.HotPushTest do alias MobDev.HotPush + describe "select_runtime_beam_paths/4" do + test "uses only active app beams when the build path has stale app output" do + root = Path.join(System.tmp_dir!(), "mob_[edge]_paths_#{System.unique_integer()}") + build_path = Path.join(root, "active-build") + app_ebin = Path.join(root, "application-ebin") + File.mkdir_p!(app_ebin) + + erlang_bootstrap = Path.join(app_ebin, "sample_app.beam") + elixir_module = Path.join(app_ebin, "Elixir.SampleApp.beam") + File.write!(erlang_bootstrap, "erlang") + File.write!(elixir_module, "elixir") + File.write!(Path.join(app_ebin, "sample_app.app"), "app metadata") + + stale_app_ebin = Path.join([build_path, "lib", "sample_app", "ebin"]) + File.mkdir_p!(stale_app_ebin) + stale_bootstrap = Path.join(stale_app_ebin, "sample_app.beam") + removed_module = Path.join(stale_app_ebin, "Elixir.RemovedModule.beam") + File.write!(stale_bootstrap, "stale erlang") + File.write!(removed_module, "removed") + + runtime_ebin = Path.join([build_path, "lib", "runtime_dep", "ebin"]) + File.mkdir_p!(runtime_ebin) + runtime_module = Path.join(runtime_ebin, "Elixir.RuntimeDep.beam") + File.write!(runtime_module, "runtime") + + dev_ebin = Path.join([build_path, "lib", "dev_tool", "ebin"]) + File.mkdir_p!(dev_ebin) + File.write!(Path.join(dev_ebin, "Elixir.DevTool.beam"), "dev") + + on_exit(fn -> File.rm_rf!(root) end) + + runtime = MapSet.new(["sample_app", "runtime_dep"]) + + selected = + HotPush.select_runtime_beam_paths(build_path, app_ebin, runtime, "sample_app") + + assert selected == [ + runtime_module, + elixir_module, + erlang_bootstrap + ] + + refute stale_bootstrap in selected + refute removed_module in selected + end + end + + describe "select_runtime_beam_dirs/4" do + test "selects the active application ebin and excludes stale build output" do + root = Path.join(System.tmp_dir!(), "mob_[edge]_dirs_#{System.unique_integer()}") + build_path = Path.join(root, "active-build") + app_ebin = Path.join(root, "application-ebin") + File.mkdir_p!(app_ebin) + File.write!(Path.join(app_ebin, "sample_app.beam"), "erlang") + File.write!(Path.join(app_ebin, "Elixir.SampleApp.beam"), "elixir") + + stale_app_ebin = Path.join([build_path, "lib", "sample_app", "ebin"]) + File.mkdir_p!(stale_app_ebin) + File.write!(Path.join(stale_app_ebin, "sample_app.beam"), "stale erlang") + File.write!(Path.join(stale_app_ebin, "Elixir.RemovedModule.beam"), "removed") + + dep_ebin = Path.join([build_path, "lib", "runtime_dep", "ebin"]) + File.mkdir_p!(dep_ebin) + File.write!(Path.join(dep_ebin, "Elixir.RuntimeDep.beam"), "dep") + + dev_ebin = Path.join([build_path, "lib", "dev_tool", "ebin"]) + File.mkdir_p!(dev_ebin) + File.write!(Path.join(dev_ebin, "Elixir.DevTool.beam"), "dev") + + on_exit(fn -> File.rm_rf!(root) end) + + dirs = + HotPush.select_runtime_beam_dirs( + build_path, + app_ebin, + MapSet.new(["sample_app", "runtime_dep"]), + "sample_app" + ) + + assert dirs == [dep_ebin, app_ebin] + refute dev_ebin in dirs + refute stale_app_ebin in dirs + + staged_names = + dirs + |> Enum.flat_map(&File.ls!/1) + |> MapSet.new() + + assert MapSet.subset?( + MapSet.new([ + "sample_app.beam", + "Elixir.SampleApp.beam", + "Elixir.RuntimeDep.beam" + ]), + staged_names + ) + end + end + # ── snapshot_beams/0 ───────────────────────────────────────────────────────── describe "snapshot_beams/0" do @@ -65,11 +164,56 @@ defmodule MobDev.HotPushTest do test "push count is less than total beam files in _build" do # push_all only pushes runtime deps — dev-only deps (mob_dev itself, Bandit, - # Phoenix, etc.) must be excluded even though their BEAMs are in _build/dev. - total_beams = Path.wildcard("_build/dev/lib/*/ebin/*.beam") |> length() + # Phoenix, etc.) must be excluded even though their BEAMs are in the build path. + total_beams = + Mix.Project.build_path() + |> Path.join("lib") + |> File.ls!() + |> Enum.map(&Path.join([Mix.Project.build_path(), "lib", &1, "ebin"])) + |> Enum.filter(&File.dir?/1) + |> Enum.flat_map(&File.ls!/1) + |> Enum.count(&String.ends_with?(&1, ".beam")) + {pushed, _} = HotPush.push_all([]) assert pushed > 0 assert pushed < total_beams end end + + describe "runtime_lib_names/0 (drives what actually gets pushed)" do + # This is the function that decides the push set, and until now it had no + # coverage: every other test hand-builds the MapSet it produces, so a + # regression inside it stayed green. Driven here against mob_dev's own + # project, which has both real runtime deps and only: :dev ones. + test "keeps runtime deps and excludes dev-only ones" do + libs = MobDev.HotPush.__runtime_lib_names__() + + assert MapSet.member?(libs, "mob_dev") + + for dev_only <- ["credo", "ex_slop", "mix_audit", "ex_doc"] do + refute MapSet.member?(libs, dev_only), + "#{dev_only} is only: :dev and must not be pushed to a device" + end + end + + test "traverses the project's own .app so extra_applications survive" do + # The regression this guards: dropping project_app from the expansion + # seed stops the project's .app being read at all, so a dependency + # reachable only via `runtime: false` + `extra_applications:` — the + # documented idiom for opting a build-time dep back into the runtime + # application list — is silently never pushed. The app then boots and + # dies with undef on first use. + libs = MobDev.HotPush.__runtime_lib_names__() + + extra = + Mix.Project.get().application() + |> Keyword.get(:extra_applications, []) + |> Enum.map(&to_string/1) + + for app <- extra do + assert MapSet.member?(libs, app), + "#{app} is in extra_applications and must survive the runtime filter" + end + end + end end